import { snapshotPlugins } from '../plugins/mount'; import { sidecarPort } from '../sidecar-registry'; import { on, type EventName, type FileEventPayload } from './bus'; /** * Letting plugins subscribe to platform events. * * ── The same shape as `fileTypes`, deliberately ── * * A manifest declares `hooks: { 'files:uploaded': '/v1/on-upload' }`. Core resolves that at emit time * from whatever is installed and enabled, POSTs the payload to the plugin's own sidecar, and knows * nothing about which plugin it is. Disable the plugin and the hook stops firing, with nothing to * unregister — the same property that makes a claimed file type disappear cleanly. * * ── Resolved at EMIT time, not at boot ── * * The obvious design registers every plugin's hooks once at startup. It is wrong here: plugins are * installed, updated, enabled and disabled while the server runs, and a registration made at boot * describes a machine that has since changed. Reading the current set costs one database call on an * event that has just done filesystem work, which is not the expensive part of anything. * * ── A hook cannot fail the operation that triggered it ── * * `emit` already swallows, and this adds nothing that could throw past it. The upload succeeded before * anybody was told. A plugin that is down, slow, or returns 500 gets a line in the log and no more — * because the alternative is an upload that reports failure for work that has nothing to do with it. */ /** Long enough for real work, short enough that a wedged plugin does not hold a socket all day. */ const HOOK_TIMEOUT_MS = 120_000; async function dispatch(event: EventName, payload: FileEventPayload): Promise { let states: Awaited>['states'] = []; try { ({ states } = await snapshotPlugins()); } catch { return; // a database that cannot be read is a louder problem elsewhere } for (const state of states) { if (!state.install?.enabled) continue; const path = state.plugin.manifest?.hooks?.[event]; if (!path) continue; const port = sidecarPort(state.plugin.appName); // Declared but not running — restarting, or never started. Skipped silently: a plugin being down is // not an error of the upload that happened to coincide with it. if (!port) continue; try { const res = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-officer-user': payload.email }, body: JSON.stringify({ event, ...payload }), signal: AbortSignal.timeout(HOOK_TIMEOUT_MS), }); if (!res.ok) { console.warn(`[hooks] ${state.plugin.appName} ${event} -> ${res.status}`); } } catch (err) { console.warn(`[hooks] ${state.plugin.appName} ${event} failed —`, err instanceof Error ? err.message : err); } } } /** * Wire plugin dispatch into every event. Called once, from the server's own start-up. * * One listener per event rather than one per plugin: which plugins care is resolved when the event * fires, so there is nothing to add or remove as they come and go. */ export function registerPluginHooks(): void { const events: EventName[] = ['files:uploaded', 'archive:extracted', 'files:placed']; for (const event of events) on(event, (payload) => dispatch(event, payload)); }