directus-extension-generate.../AGENTS.md

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

  1. Cached transform filename = <basename(filename_disk)>__<object-hash(resolved transforms)>.<ext>, written beside the original in the storage root (./uploads, no .cache subdir).

  2. Hash = object-hash@3 defaults: sha1/hex, unorderedArrays:false (array order matters), unorderedObjects:true. Hashes the RESOLVED transform array from resolvePreset, NOT the raw query/preset object.

  3. resolvePreset order is fixed: copy transformationParams.transforms[] → push toFormat (if format/quality) → push resize (+extract on 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.

  4. { key } alone is a NO-OP. resolvePreset never reads key. Always pass the full preset object.

  5. format:'auto' is the killer trap. All six system-* presets are format:'auto'. getFormat with no acceptFormat → JPEG→jpg, PNG→png; a browser Accept: image/avif → avif. Different toFormat arg → different hash → guaranteed miss.

  6. 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.

  7. AssetsService.getAsset runs 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; override IMAGE_PREVIEWS_STATE_DIR). Do NOT default to /directus/extensions/... — that path is a root-owned volume and the API runs as node → 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 under run.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.
  • scheduleSynchronizedJob makes the cron cluster-safe; the run.lock file is a second guard.
  • Run now in the admin module ignores the window (ignoreWindow: true).

Gotchas learned the hard way

  • Directus coerces env values: "true" arrives as boolean true, "1" as number. Env parsing must accept unknown and stringify (see asString in settings.ts). A string-only .toLowerCase() call silently killed the upload hook.
  • Docker bind mounts: never rm -rf the 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 /ops and 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 have EXTENSIONS_AUTO_RELOAD=true).

Conventions

  • Build via @directus/extensions-sdk (npm run build / dev / link); bundle emits dist/app.js + dist/api.js.
  • Deploy by copying dist/ + package.json into the Directus extensions dir (see README); a staging helper lives in dev/deploy-staging.sh.
  • Tests: node:test via tsx (npm test). Typecheck: npm run typecheck.
  • Do not add comments unless asked. Follow the sibling extensions' style.
  • Never commit without explicit request.