Files
platform/src/servers/events/warm-thumbnails.ts
T
pastilhasandClaude Opus 5 000df7fe45 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
2026-08-29 08:20:35 +01:00

108 lines
4.3 KiB
TypeScript

import { join } from 'node:path';
import { DATA_PATH } from '../data-path';
import { buildThumbnail, thumbnailKind } from '../api/file-browser/thumbnails';
import { pluginFileTypes, sidecarFetch } from '../api/file-browser/plugin-file-types';
import { on, type FileEventPayload } from './bus';
/**
* Make the thumbnails before anybody asks for them.
*
* ── Why this is worth doing at all ──
*
* Thumbnails are generated on demand and cached. That is right — most files are never looked at — but
* it means the first person to open a folder waits for the work. For a jpeg that is 40ms nobody
* notices. For a HEIC it is four and a half seconds each, because ImageMagick decodes the whole
* 4032x3024 frame before sharp sees it. A folder of photographs from a phone is thirteen seconds of
* staring at file icons.
*
* An upload is the one moment the platform KNOWS which files are new, so it is the moment to do it.
*
* ── This is core subscribing to core, and that is the point ──
*
* The thumbnail cache belongs to the platform: keyed on its scheme, read by its route. A plugin doing
* this work would produce them somewhere the file browser never looks. So the warmer lives here and
* uses the same `buildThumbnail` the request path uses — which means a HEIC still warms through
* av-club, via the `fileTypes` claim, without this file knowing that plugin exists.
*
* ── ONE size, and it is the one both views ask for ──
*
* The cache key includes the size. Warming 128 when the browser asks for 256 is work that produces
* nothing anybody reads, so this must stay equal to what `FileItem` requests. Both views were made to
* ask for 256 for exactly this reason.
*/
const WARM_SIZE = 256;
/**
* How many to warm at once.
*
* Lower than the request path's own limit on purpose. This is background work competing with a person
* who is actually waiting: somebody opening a different folder while an upload warms must not queue
* behind it. `buildThumbnail` has its own cap too, so this is a second, tighter gate rather than the
* only one.
*/
const WARM_CONCURRENCY = 2;
/** Stop after this many. An unpacked archive can hold thousands, and warming all of them is not a favour. */
const WARM_LIMIT = 200;
async function warm(payload: FileEventPayload): Promise<void> {
const claimed = await pluginFileTypes();
// Only what will actually be asked for: core's own types plus whatever a plugin claims. Anything else
// has no thumbnail to make and would be a spawn per file for nothing.
const targets = payload.paths
.filter((path) => {
const name = path.slice(path.lastIndexOf('/') + 1);
return !!thumbnailKind(name) || claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
})
.slice(0, WARM_LIMIT);
if (targets.length === 0) return;
const userDataDir = join(DATA_PATH, payload.email);
let index = 0;
const worker = async () => {
while (index < targets.length) {
const path = targets[index++]!;
const name = path.slice(path.lastIndexOf('/') + 1);
const kind = thumbnailKind(name);
const claim = kind ? null : claimed.get(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
/*
Exactly what the request path does, including its cache and its remembered failures — so a file
warmed here is a cache HIT when the browser asks, and a file that cannot be thumbnailed is not
attempted twice.
*/
await buildThumbnail(
path,
userDataDir,
kind ?? 'image',
WARM_SIZE,
claim?.thumbnail
? async () => {
const res = await sidecarFetch(
`${claim.thumbnail}?path=${encodeURIComponent(path)}&size=${WARM_SIZE}`,
payload.email,
);
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
}
: undefined,
).catch(() => {
// Already logged by the builder where it matters. A warm that fails costs nothing: the request
// path will try again and answer honestly.
});
}
};
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, targets.length) }, worker));
}
/** Called once from start-up. */
export function registerThumbnailWarmer(): void {
on('files:uploaded', warm);
on('archive:extracted', warm);
on('files:placed', warm);
}