a closed, named list of events — `files:uploaded`, `archive:extracted`,
`files:placed` — each carrying absolute paths and an owner. NOT an event bus in the
open sense: `emit(anyString, anything)` becomes an API nobody can change, because
the moment a plugin listens for a string that string is a contract with no list
saying so. a union means adding one is a deliberate edit and removing one fails to
compile.
TWO GUARANTEES, AND THEY ARE THE WHOLE DESIGN. emit never blocks: it schedules and
returns, so an upload does not wait for a subscriber thumbnailing a HEIC. and a
subscriber cannot fail the operation that triggered it — the upload is written and
answered before anybody is told, so a plugin that is down, slow or throwing gets a
line in the log. tested both ways, sync and async, plus that one failing listener
does not stop the next.
PLUGINS SUBSCRIBE THROUGH THE MANIFEST, same shape as `fileTypes`: `hooks: {
'files:uploaded': '/v1/on-upload' }`. resolved at EMIT time rather than at boot,
because plugins are installed, updated and disabled while the server runs and a
registration made at startup describes a machine that has since changed. nothing
here names a plugin.
CORE IS THE FIRST SUBSCRIBER, and that is deliberate rather than a shortcut. the
thumbnail cache belongs to the platform — its key, its route — so a plugin doing
this work would produce files the browser never looks at. the warmer calls the same
`buildThumbnail` the request path calls, which means HEIC still warms through
av-club via the fileTypes claim, without this file knowing that plugin exists.
AND A SINGLE-FLIGHT MAP, which the warmer made necessary. two callers now want the
same thumbnail as a matter of course: the warmer starts on upload and the browser
asks a moment later. measured before the fix — four concurrent requests plus the
warmer each decoded the same 4032x3024 HEIC and took a slot from a limit of four,
which is a warmer competing with the person waiting rather than helping them. after:
all five share one build, 5.5s once, then 130ms.
verified end to end: upload returns in 56ms, four concurrent requests all 200 in a
single 5.5s build, cached 130ms. 1679 tests, docs:check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
77 lines
3.3 KiB
TypeScript
77 lines
3.3 KiB
TypeScript
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<void> {
|
|
let states: Awaited<ReturnType<typeof snapshotPlugins>>['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));
|
|
}
|