0033: Eager manifest emit for non-Vite consumers
Status: Accepted Date: 2026-05-11
Context
ADR 0032 added the emitTo plugin
option, which writes the generated route manifest to disk every time the
virtual purity:routes module is load()'d. Apps using the manifest
get tsc + IDE jump-to-definition for free.
That trigger only fires when some module in the Vite build graph
imports purity:routes. For consumers that bundle the manifest's
emitted file OUTSIDE Vite — Cloudflare Workers via wrangler, Deno
Deploy, custom Node entries that pre-build a single artefact, plain
tsc typecheck of a package that doesn't import the virtual ID — the
manifest never gets written because nothing inside Vite ever resolves
the virtual module.
The cf-workers SSR adapter example (examples/ssr-stream-cf-workers/)
makes this concrete: wrangler bundles src/worker.ts directly. Vite
isn't in the bundle pipeline at all. We want the workflow:
npm run gen-routes # vite build → emits src/.purity/routes.ts
wrangler deploy # bundles + ships the worker
…but with emitTo alone, npm run gen-routes runs Vite to completion
without ever loading purity:routes, so the file isn't written.
Decision
Wire the emit into the plugin's buildStart hook. When emitTo
is set, the plugin regenerates the on-disk file at the start of every
vite build / vite dev, regardless of whether anything imports
purity:routes in this run. Same code path as load(); same skip-if-
unchanged guard. The virtual-module load() continues to emit on its
own — both triggers are idempotent because both compare existing
content byte-for-byte before writing.
buildStart() {
if (!routesOpts || emitToAbs === null || routesAbsDir === null) return;
const source = generateManifestSource(this, routesAbsDir, routesExt);
emitManifestToDisk(emitToAbs, source, (msg) => this.warn?.(msg));
}
This makes the emit a side-effect of starting a Vite build, not a
side-effect of resolving the virtual module. Apps that already drive
the emit via the virtual module (canonical SSR demo) see no behavioral
change — buildStart writes the same source load() would, and the
no-op equality check means the second call after load() skips the
write.
Use cases unlocked
- Cloudflare Workers via wrangler.
vite buildemits the manifest; wrangler bundles the worker which imports the emitted file. No code path goes through the virtual module. - Deno Deploy / Bun / standalone
node. Same pattern — Vite is a build-time tool for the manifest, the deploy target consumes the emitted file directly. - Pure-
tsctypecheck.tsc --noEmitagainst an example folder picks up the emitted file as a real source. Useful in CI where the Vite build runs separately.
Explicit non-features
- No build-graph entry stub required. Earlier-iteration scratch
configurations used a
src/.routes-emit-entry.tsstub that did nothing but trigger a Vite build of the virtual module. WithbuildStart, no stub is needed —vite build(against the worker entry, or any entry, or even an empty config) emits the manifest as a side effect. The single source of truth stays the plugin's own manifest generator. - No CLI sub-command. Rejected in ADR 0032 as drift-prone (two code
paths). Same reasoning applies here —
buildStartkeeps the emit inside the plugin. - No standalone
purity emitcommand. Users running pure-tscpipelines can still trigger the emit by runningvite buildin a prebuild step. Adding a separate CLI command would re-introduce the drift problem. - No emit on
configResolved.configResolvedfires for every Vite invocation includingvite --help,vite preview, plugin- config validation, etc.buildStartis the right scope — it fires exactly when a real build (or dev server) starts. - No multi-target emit. Same as ADR 0032: one file per plugin instance.
Consequences
Positive:
- Closes the manifest-for-non-Vite-consumers gap. The cf-workers SSR
adapter (and the parallel
ssr-stream-vercel-edge/,ssr-stream-deno/examples that ship next) can drive the emit with a one-linevite buildprebuild. - Backward-compatible. Apps already on ADR 0032's
emitTosee no behavioral change — same content, same no-op equality check, same warnings viathis.warn. - Single source of truth. Both
load()andbuildStartcall the samegenerateManifestSource+emitManifestToDiskhelpers.
Negative:
- Slight Vite startup cost (
~1ms) on every build/dev start whenemitTois configured. Same fs read + compare overhead ADR 0032 documented forload(). Skipped silently when the file already matches. - Two emit triggers (buildStart + load) mean a Vite build that also
loads
purity:routeswrites twice — once at start, once at load. Both writes hit the same equality guard so the second is a fast no-op; the cost is one extra fs read.
Neutral:
- No new plugin option. Existing
emitTosemantics are preserved; the change is internal (an extra trigger). - Tests in
packages/vite-plugin/tests/routes.test.tscover the new buildStart path: emit-without-resolveId/load, no-op whenemitTois omitted, no-op whenroutesis disabled, equality-guard on repeat invocation.
Alternatives considered
Emit on configResolved instead of buildStart. Rejected.
configResolved fires for every Vite invocation including --help
and config-validation calls — writing filesystem state from
configResolved would surprise users running plugin-introspection
commands. buildStart cleanly gates the side-effect on a real build
or dev server starting.
Add a force?: boolean option that always rewrites. Rejected for
this iteration. The skip-if-unchanged guard is the established
behavior; an unconditional rewrite is just touch and doesn't help
any documented use case.
Ship a separate @purityjs/cli command (npx purity emit).
Rejected for the same reason as ADR 0032: adds a parallel code path
that drifts from the plugin's. The plugin-driven emit stays in sync
because both paths use the same generator.
Auto-detect non-Vite consumers and only emit eagerly then.
Surprising default; non-Vite consumers aren't visible from the plugin
context anyway (the plugin only knows about the current Vite build).
Explicit opt-in via emitTo is the right interface — the only
question is when to fire, and buildStart is more aligned with user
intent than load() for non-Vite-consuming builds.