7.1 KiB
Agent notes — directus-extension-generate-image-previews
Common confusion points for agents working here. Read before touching the warmer.
What this is
A Directus bundle extension that pre-generates image transform ("preview") cache files so the admin UI never pays the first-request sharp cost.
Bundle entries: an api hook (files.upload), an api endpoint (/image-previews/*), and an app
module (image-previews admin page: progress, storage, config, diagnostics, API reference).
Skills
- Project skill:
.opencode/skills/directus-image-previews/SKILL.md— invariants, presets/formats semantics, backfill/scheduler behavior, debugging, verification gate, deploy checklist. Read it before changing generation logic. - Global skill:
directus-extensions— reusable Directus extension playbook (bundles, context APIs, storage, asset transforms, scheduling, sandboxing, deployment, links). Use it for new extensions.
Tested against Directus 11.17.4 (self-hosted, PM2 cluster, local disk storage). The manifest targets
^11.0.0; the extension uses non-public @directus/api internals at runtime, so re-verify on each
Directus minor upgrade.
Directus transform invariants (source-verified, tag v11.17.4) — DO NOT REGRESS
-
Cached transform filename =
<basename(filename_disk)>__<object-hash(resolved transforms)>.<ext>, written beside the original in the storage root (./uploads, no.cachesubdir). -
Hash =
object-hash@3defaults: sha1/hex,unorderedArrays:false(array order matters),unorderedObjects:true. Hashes the RESOLVED transform array fromresolvePreset, NOT the raw query/preset object. -
resolvePresetorder is fixed: copytransformationParams.transforms[]→ pushtoFormat(ifformat/quality) → pushresize(+extracton focal cover). Raw params?width=&height=&fit=&format=resolve to[toFormat, resize]; a preset resolves to[resize, toFormat]→ different hash for a visually identical result. -
{ key }alone is a NO-OP.resolvePresetnever readskey. Always pass the full preset object. -
format:'auto'is the killer trap. All sixsystem-*presets areformat:'auto'.getFormatwith noacceptFormat→ JPEG→jpg, PNG→png; a browserAccept: image/avif→avif. DifferenttoFormatarg → different hash → guaranteed miss. -
The write IS awaited. Not draining the returned stream leaks an FD but never truncates. Still: destroy/unpipe the returned stream to avoid FD leaks.
-
AssetsService.getAssetruns fine as sudo (new AssetsService({ schema })) with no HTTP request context — this is the basis of our in-process strategy.
Preset registry semantics (controller parity)
The controller builds, inline in api/src/controllers/assets.ts:
res.locals['shortcuts'] = [...SYSTEM_ASSET_ALLOW_LIST, ...(directus_settings.storage_asset_presets ?? [])]
and resolves ?key= with .find(t => t.key === key) → first match wins (a system key shadows a
custom preset of the same key). There is no reusable helper, and GET /settings exposes only
custom presets. Our registry (src/lib/presets.ts) must mirror this order exactly.
What the admin actually requests (verbatim from app source)
| Surface | URL |
|---|---|
| Files grid | ?key=system-medium-cover&v=<modified_on> (cards, crop) |
| Image field | ?key=system-large-cover / system-large-contain |
| Image display | ?key=system-small-cover |
No explicit width/height/format. format:auto is implicit. v= is ignored for hashing.
Chosen strategy — IN-PROCESS (no token, no HTTP, no rate limiter)
const assets = new services.AssetsService({ schema }); // sudo
const { stream } = await assets.getAsset(fileId, {
transformationParams: presetObject, // FULL object, not { key }
acceptFormat: 'avif', // or 'webp'
});
(stream as Readable).destroy?.();
acceptFormat reproduces what a browser's Accept header does → byte-identical cache filename with
no HTTP/token. Warm both avif and webp for format:'auto' presets. For fixed-format presets,
omit acceptFormat and let the preset's own format drive.
HTTP-replay (GET /assets/{id}?key=…) is the documented fallback only (needs a token + rate-limit
pacing).
Config + state
- Persisted settings + progress state are small JSON files under
<STORAGE_LOCAL_ROOT>/.image-previews(default/directus/uploads/.image-previews; overrideIMAGE_PREVIEWS_STATE_DIR). Do NOT default to/directus/extensions/...— that path is a root-owned volume and the API runs asnode→ EACCES. state.jsonl= append-only completed{file_id,preset_key,format}→ resume.failures.jsonl= append-only failure events; replay derives the latest state per(file_id,preset_key,format). Compacted underrun.lock(unresolved + last 1,000 recovered).- All state is file-backed (PM2 cluster, 8 workers — never in-memory).
Nightly scheduling
- The hook registers
schedule('*/15 * * * *')— in-process, no external cron/token. Every tick it checks enabled + maintenance window + no active run, then starts/resumes the backfill. - The backfill pauses when the window closes (
run.paused = true) and resumes next night. scheduleSynchronizedJobmakes the cron cluster-safe; therun.lockfile is a second guard.Run nowin the admin module ignores the window (ignoreWindow: true).
Gotchas learned the hard way
- Directus coerces env values:
"true"arrives as booleantrue,"1"as number. Env parsing must acceptunknownand stringify (seeasStringinsettings.ts). A string-only.toLowerCase()call silently killed the upload hook. - Docker bind mounts: never
rm -rfthe bind-mounted extension directory — it breaks the mount and Directus removes the extension without re-adding it. Sync contents in place; restart the dev container. - Dimension vs file size:
ASSETS_TRANSFORM_IMAGE_MAX_DIMENSION(default 6000) is a pixel dimension guard in Directus, not a file-size limit. The extension imposes no limit; raise the Directus env var to process larger images. - Unsupported preset formats (e.g.
bmp) fail at generation; surface the error in/opsand the module's Recent operations. - The dev stack (
dev/compose.dev.yml) is the only place to test; never load this into a production instance while iterating (production may haveEXTENSIONS_AUTO_RELOAD=true).
Conventions
- Build via
@directus/extensions-sdk(npm run build/dev/link); bundle emitsdist/app.js+dist/api.js. - Deploy by copying
dist/+package.jsoninto the Directus extensions dir (see README); a staging helper lives indev/deploy-staging.sh. - Tests:
node:testviatsx(npm test). Typecheck:npm run typecheck. - Do not add comments unless asked. Follow the sibling extensions' style.
- Never commit without explicit request.