platform events, and thumbnails warmed the moment files arrive

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
This commit is contained in:
2026-08-29 08:20:35 +01:00
co-authored by Claude Opus 5
parent 39c1122651
commit 000df7fe45
8 changed files with 456 additions and 27 deletions
+15
View File
@@ -5,6 +5,8 @@ import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertPermissionTotality } from './servers/permissions/totality';
import { bootstrapCaldav } from './servers/caldav-bootstrap';
import { registerThumbnailWarmer } from './servers/events/warm-thumbnails';
import { registerPluginHooks } from './servers/events/plugin-hooks';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { listPluginRootClaims } from './servers/plugins/public-paths';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
@@ -252,6 +254,19 @@ if (secured.repaired.length) {
}
for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`);
/*
Subscribe to platform events before anything can emit one.
Both are pure registration — no I/O, nothing that can fail — so they sit above the plugin mount rather
than inside its try. If either threw, the server not starting would be the correct outcome, and
wrapping them in the plugin block's `catch` would hide that.
The thumbnail warmer is core subscribing to core: the cache belongs to the platform, so the work has to
happen here. Plugin hooks are the general mechanism, resolved per event from whatever is installed.
*/
registerThumbnailWarmer();
registerPluginHooks();
// Mount whatever is installed, BEFORE serve().
//
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,