Repository navigation
feat(router): on-demand (ISR-style) prerender - #640
Conversation
Proposes ISR-style prerender refresh from a running app: a writable durable overlay in front of the bundled prerender manifest, a router.prerender() trigger callable from fetch, cron, queues, workflows, webhooks, and server actions, and an explicit per-route onDemand opt-in on Prerender(). Key decisions pinned in the doc: - Requestless producer: triggers supply env/ctx capabilities, never request state; cache-taint-style guards map personalization to a skipped result. - Refresh always renders and replaces; onlyIfStale is the cron-sweep opt-in. - TTL is soft staleAt metadata, never hard store expiry; a stale overlay hit still serves (the overlay is always newer than the build artifact). - V1 invalidation is mark-stale, not delete (deleting would re-expose older bundled-manifest content); tombstones deferred. - The retained producer is never invoked by the request pipeline; a miss behaves exactly like today's pr + miss. - Versioned stored-entry envelope with buildId scoping and verify-on-read params (DJB2 collision guard). - Producer threads the #587 throwOnError flag so a failed refresh cannot bake a frozen error page.
9dea9d9 to
21d2ac7
Compare
Addressed an in-depth code review (11 confirmed findings)Amended into the single feature commit. Fixes: Blockers
Below-cap: NaN/negative Tests: +regressions for each (malformed value, ttl/concurrency guards, batch-safe tags) and a userland dogfood through a real Deferred (documented in the design doc + Full gate re-run green: typecheck 13/13, |
Add router.prerender() to refresh prerendered route payloads from a running app
(fetch, cron, queue, webhook, server action), backed by a writable durable
overlay in front of the bundled prerender manifest.
- prerender router option (env-resolved per request, same union shape as cache)
- Prerender(..., { onDemand }) opt-in -> od trie flag + producer-code retention
- router.prerender / .many / .invalidateTags with an inspectable PrerenderResult
- requestless producer + personalization guard (reads map to skipped-personalized)
- serve-path overlay read (overlay -> bundled manifest -> miss) + SWR scheduling
- versioned stored-entry envelope, build-scoped keys, verify-on-read collision guard
- in-memory store (@rangojs/router/prerender) + Cloudflare KV adapter
(@rangojs/router/prerender/cloudflare); testing fake on @rangojs/router/testing
- dev + production e2e in the node test-app and cloudflare-basic
21d2ac7 to
84010cc
Compare
Re-review follow-ups (amended)Addressed the 3 remaining items from the re-review:
Gate re-run green across all packages (router 4599 + cloudflare-basic 46+10 + mini + demo, typecheck 13/13, lint 0/0, format). |
commit: |
- gate retained onDemand producers off live production requests
(gateOnDemandProducer in path-helper): an overlay+manifest miss on a
plain Prerender(..., { onDemand }) route keeps the pr-miss 404 contract
instead of running the producer in-request with the guard unarmed
- fail the build when a manifest-od route's producer is not statically
detected by the bundle scan (onDemandHandlerIds cross-check in
postprocessBundle); detect quoted onDemand keys
- surface only personalization errors from producer Flight serialization
(boundary-rendering routes can refresh again; matches build-time bake)
- trigger: stop many({ throwOnError }) batches on first failure; map
prerender-factory throws to a no-store result; dev-warn on no-op
invalidateTags; document the onRevalidate stampede footgun
- serve path: skip guaranteed-miss overlay reads on intercept navigations
- dev-warn when a route sets both ppr and onDemand (ppr is inert)
- docs: client staleness window after refresh (amplified by default
viewport prefetch), PPR shell-lane exclusion, KV read cost, disjoint
tag namespaces
- tests: 10 new units (many-abort, factory throw, retention cross-check,
quoted keys); dev+prod e2e in test-app and cloudflare-basic for the
plain-od 404 gate (proven red-before-green), frozen payload with fresh
loaders, onlyIfStale + tag invalidation (first KV end-to-end), swr
onRevalidate scheduling; bundle retention assertions in both apps
Replace the textual onDemand scan in extractHandlerExportsFromChunk with a lookup in state.onDemandHandlerIds — the $$id set discovery derives from the evaluated routes module, the same source that sets the runtime od trie flag. Retention and serve now share one derivation and cannot disagree, so: - any truthy onDemand spelling works (literal, spread, imported const); the static-literal restriction is removed from JSDoc, types, and docs - the value regex, quoted-key handling, and the postprocessBundle retention cross-check are deleted (divergence is impossible by construction) - an id absent from any chunk keeps retain-by-default (bundle bytes, never a broken refresh) Contract pin: the test-app SWR fixture now passes its options as a shared const (Prerender(gen, fn, SWR_OD_OPTIONS)) — the exact spelling the old regex silently evicted — and its full production e2e flow (refresh, stale serve, swr scheduling) passes. Unit tests rewritten map-driven; cross-check suite removed with the mechanism.
…regenerate cloudflare-basic urls.gen.ts The requestless producer context had no _versions, so cache stores built inside a refresh fell back to the whole-build pair and logged the outside-a-request warning when routers have versions of their own.
…he data version
A server action on a plain Prerender(..., { onDemand }) page skipped the
overlay: an overlay-only param 404'd on the re-render in production and a
baked one served the older build payload. The overlay is now read on actions
(Passthrough routes still re-render live), and an overlay hit on an action
re-sends the route's own segments, so router.prerender() inside the action
shows the entry it just stored.
The overlay key now carries the router's data version instead of the document
version, so a client-only deploy keeps refreshed pages. The trigger and the
request handler resolve it through one call (resolvePrerenderVersion), the
trigger per call.
Also: onRevalidate is scheduled once per stale key per isolate while one is in
flight; the trigger and the producer gate share isDevEnvironment(); dev warns
when tags are marked stale with no onRevalidate configured; ppr + onDemand
throws at route definition instead of a dev-only warning.
Tests: dev + production e2e in test-app and cloudflare-basic (action on an
overlay-only page; router.prerender() inside an action); an rsc dogfood
(router.prerender() then serveShellRequest) pinning serve, loaders fresh,
skipped-personalized, one key version on both sides, getCacheVersions()
inside a refresh, and onRevalidate dedup.
Pre-release renames, no deprecations:
- PrerenderConfig is { store, ttl?, onRevalidate? }: `swr` is gone (the
presence of onRevalidate is the stale-while-revalidate opt-in) and
`defaultTtl` is `ttl` (a route's onDemand.ttl overrides it).
- router.prerender.invalidateTags() is router.prerender.markStale(), and the
optional store method is markStale(): it marks entries stale and keeps
serving them, unlike updateTag()/revalidateTag(), which purge.
- PrerenderKey.buildId, the stored entry's meta.buildId and the internal
ResolvedPrerender.buildId are `version`: the owning router's cache version,
opaque to app code. The serialized key is
prerender:{routerId}:{version}:{routeName}:{paramHash}.
- onDemand.tags accepts string[] as well as (target) => string[].
- @rangojs/router/prerender no longer exports ResolvedPrerender (internal)
or PrerenderRuntimeBase (markStale takes { env, ctx? } inline).
- The store contract JSDoc tells store authors to key off key.version and
never call getCacheVersions(): set() runs outside the producer's request.
Fixtures and e2e follow the renames; the test-app fixture comment on the
typed object target now gives the real reason it does not typecheck there.
…oses and verifies the envelope Each store re-ran the envelope's verify-on-read (version and the params collision guard) inside its own get(), and composeStoredEntry / isStoredEntryValidFor were not exported, so a third-party store had to reimplement the collision guard by hand or silently skip it. The contract is now get(key) -> PrerenderStoredEntry | null, set(key, stored), optional delete(key) and markStale(tags). The trigger composes the envelope; the serve path and the trigger's onlyIfStale read verify every store's answer (shape, key version, params). A store may still lower staleAt from its own tag markers, as the KV store does. PrerenderLookupMeta and PrerenderSetOptions leave the public surface; serializePrerenderKey stays. The memory store's markStale never moves an earlier staleAt later. New rsc dogfood case: a naive store that answers every key with the last write does not serve one param set's entry under another's URL (red without the serve-path verification).
…re map The skills said nothing about on-demand prerender, and the prerender skill's "Tag invalidation does not reach prerendered segments" section pointed only at the runtime cache. Now: - skills/prerender: an "On-demand refresh (ISR)" section with the opt-in, the store config, the trigger, a serve table (fresh / stale / build entry / miss for Prerender and Passthrough, dev and production, plus action re-renders) and the rules: soft ttl, per-isolate onRevalidate dedup, separate tag namespace that updateTag()/revalidateTag() never reach, markStale only marks, requestless producer with the live env, params outside getParams(), notFound() keeps the old entry, intercept variants not refreshed, client prefetch staleness, per-version keys, custom stores, testing. - skills/cloudflare: the KV store, scheduled sweep and queue consumer. - skills/deployment-caching: the on-demand row, Workers read cost, no Vercel store in v1. - skills/migrate-nextjs: revalidatePath / revalidateTag / time-based ISR mapped to router.prerender() / markStale() / onDemand.ttl. - design doc, prerender-api-design and feature map follow the API renames, the get/set store contract, the data-version key and action re-renders. tools/check-docs-api.mjs now finds non-generic interface declarations, which a `type` import in a skill names (WritablePrerenderStore).
- onRevalidate dedup entries age out after IN_FLIGHT_LEADER_MAX_WAIT_MS, are skipped under ctx.build, and are cleared by resetShellTestState() - the request handler resolves the prerender key version per request, like the trigger does per refresh (once per .many() batch) - ppr + onDemand throws before the forRoute early return - shared store helpers lowerStoredEntryStaleAt and readVerifiedStoredEntry; KV get drops duplicate shape checks and skips marker reads for stale entries - trigger tags go through normalizeTagList (trim, drop empties, de-duplicate) - remove the unused PrerenderKey.intercept slot and :i suffix - dev warnings fire once per trigger; passthrough probe computed once per request; history-narrating comments cut
…cast
PrerenderTargetObject carries a type-only brand and PrerenderTarget accepts it,
so onRevalidate: (target, env) => router.prerender(target, { env }) typechecks
on a router with named routes, queue JSON round trip included. A wrong route
name or params in a hand-written target is still a type error; pinned in
tests/cloudflare-basic/src/prerender-target-types.ts.
Bind env and ctx once, then call the runner per target. Per-call options
(onlyIfStale, throwOnError, concurrency) stay per call; the runtime type
now holds only { env, ctx? }. onRevalidate is unchanged.
The documented one-liner binds the runner with it:
onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target).
# Conflicts: # packages/rangojs-router/src/testing/serve-shell-request.ts
A build bakes an on-demand route only for the params getParams() lists, so a refresh-only page is served from its prerender store alone; baking every URL (the #1061 emulation) ran the producer per request and hid the overlay.
* docs(router): add on-demand prerender design doc Proposes ISR-style prerender refresh from a running app: a writable durable overlay in front of the bundled prerender manifest, a router.prerender() trigger callable from fetch, cron, queues, workflows, webhooks, and server actions, and an explicit per-route onDemand opt-in on Prerender(). Key decisions pinned in the doc: - Requestless producer: triggers supply env/ctx capabilities, never request state; cache-taint-style guards map personalization to a skipped result. - Refresh always renders and replaces; onlyIfStale is the cron-sweep opt-in. - TTL is soft staleAt metadata, never hard store expiry; a stale overlay hit still serves (the overlay is always newer than the build artifact). - V1 invalidation is mark-stale, not delete (deleting would re-expose older bundled-manifest content); tombstones deferred. - The retained producer is never invoked by the request pipeline; a miss behaves exactly like today's pr + miss. - Versioned stored-entry envelope with buildId scoping and verify-on-read params (DJB2 collision guard). - Producer threads the #587 throwOnError flag so a failed refresh cannot bake a frozen error page. * feat(router): on-demand (ISR-style) prerender Add router.prerender() to refresh prerendered route payloads from a running app (fetch, cron, queue, webhook, server action), backed by a writable durable overlay in front of the bundled prerender manifest. - prerender router option (env-resolved per request, same union shape as cache) - Prerender(..., { onDemand }) opt-in -> od trie flag + producer-code retention - router.prerender / .many / .invalidateTags with an inspectable PrerenderResult - requestless producer + personalization guard (reads map to skipped-personalized) - serve-path overlay read (overlay -> bundled manifest -> miss) + SWR scheduling - versioned stored-entry envelope, build-scoped keys, verify-on-read collision guard - in-memory store (@rangojs/router/prerender) + Cloudflare KV adapter (@rangojs/router/prerender/cloudflare); testing fake on @rangojs/router/testing - dev + production e2e in the node test-app and cloudflare-basic * test(router): accept synthetic handler ids in bundle guard * fix(router): harden on-demand prerender per review findings - gate retained onDemand producers off live production requests (gateOnDemandProducer in path-helper): an overlay+manifest miss on a plain Prerender(..., { onDemand }) route keeps the pr-miss 404 contract instead of running the producer in-request with the guard unarmed - fail the build when a manifest-od route's producer is not statically detected by the bundle scan (onDemandHandlerIds cross-check in postprocessBundle); detect quoted onDemand keys - surface only personalization errors from producer Flight serialization (boundary-rendering routes can refresh again; matches build-time bake) - trigger: stop many({ throwOnError }) batches on first failure; map prerender-factory throws to a no-store result; dev-warn on no-op invalidateTags; document the onRevalidate stampede footgun - serve path: skip guaranteed-miss overlay reads on intercept navigations - dev-warn when a route sets both ppr and onDemand (ppr is inert) - docs: client staleness window after refresh (amplified by default viewport prefetch), PPR shell-lane exclusion, KV read cost, disjoint tag namespaces - tests: 10 new units (many-abort, factory throw, retention cross-check, quoted keys); dev+prod e2e in test-app and cloudflare-basic for the plain-od 404 gate (proven red-before-green), frozen payload with fresh loaders, onlyIfStale + tag invalidation (first KV end-to-end), swr onRevalidate scheduling; bundle retention assertions in both apps * feat(router): drive onDemand producer retention from the route manifest Replace the textual onDemand scan in extractHandlerExportsFromChunk with a lookup in state.onDemandHandlerIds — the $$id set discovery derives from the evaluated routes module, the same source that sets the runtime od trie flag. Retention and serve now share one derivation and cannot disagree, so: - any truthy onDemand spelling works (literal, spread, imported const); the static-literal restriction is removed from JSDoc, types, and docs - the value regex, quoted-key handling, and the postprocessBundle retention cross-check are deleted (divergence is impossible by construction) - an id absent from any chunk keeps retain-by-default (bundle bytes, never a broken refresh) Contract pin: the test-app SWR fixture now passes its options as a shared const (Prerender(gen, fn, SWR_OD_OPTIONS)) — the exact spelling the old regex silently evicted — and its full production e2e flow (refresh, stale serve, swr scheduling) passes. Unit tests rewritten map-driven; cross-check suite removed with the mechanism. * fix(router): on-demand producer carries the router's cache versions; regenerate cloudflare-basic urls.gen.ts The requestless producer context had no _versions, so cache stores built inside a refresh fell back to the whole-build pair and logged the outside-a-request warning when routers have versions of their own. * fix(router): on-demand overlay serves action re-renders and keys on the data version A server action on a plain Prerender(..., { onDemand }) page skipped the overlay: an overlay-only param 404'd on the re-render in production and a baked one served the older build payload. The overlay is now read on actions (Passthrough routes still re-render live), and an overlay hit on an action re-sends the route's own segments, so router.prerender() inside the action shows the entry it just stored. The overlay key now carries the router's data version instead of the document version, so a client-only deploy keeps refreshed pages. The trigger and the request handler resolve it through one call (resolvePrerenderVersion), the trigger per call. Also: onRevalidate is scheduled once per stale key per isolate while one is in flight; the trigger and the producer gate share isDevEnvironment(); dev warns when tags are marked stale with no onRevalidate configured; ppr + onDemand throws at route definition instead of a dev-only warning. Tests: dev + production e2e in test-app and cloudflare-basic (action on an overlay-only page; router.prerender() inside an action); an rsc dogfood (router.prerender() then serveShellRequest) pinning serve, loaders fresh, skipped-personalized, one key version on both sides, getCacheVersions() inside a refresh, and onRevalidate dedup. * refactor(router)!: settle the on-demand prerender API names Pre-release renames, no deprecations: - PrerenderConfig is { store, ttl?, onRevalidate? }: `swr` is gone (the presence of onRevalidate is the stale-while-revalidate opt-in) and `defaultTtl` is `ttl` (a route's onDemand.ttl overrides it). - router.prerender.invalidateTags() is router.prerender.markStale(), and the optional store method is markStale(): it marks entries stale and keeps serving them, unlike updateTag()/revalidateTag(), which purge. - PrerenderKey.buildId, the stored entry's meta.buildId and the internal ResolvedPrerender.buildId are `version`: the owning router's cache version, opaque to app code. The serialized key is prerender:{routerId}:{version}:{routeName}:{paramHash}. - onDemand.tags accepts string[] as well as (target) => string[]. - @rangojs/router/prerender no longer exports ResolvedPrerender (internal) or PrerenderRuntimeBase (markStale takes { env, ctx? } inline). - The store contract JSDoc tells store authors to key off key.version and never call getCacheVersions(): set() runs outside the producer's request. Fixtures and e2e follow the renames; the test-app fixture comment on the typed object target now gives the real reason it does not typecheck there. * refactor(router): prerender stores are plain get/set; the router composes and verifies the envelope Each store re-ran the envelope's verify-on-read (version and the params collision guard) inside its own get(), and composeStoredEntry / isStoredEntryValidFor were not exported, so a third-party store had to reimplement the collision guard by hand or silently skip it. The contract is now get(key) -> PrerenderStoredEntry | null, set(key, stored), optional delete(key) and markStale(tags). The trigger composes the envelope; the serve path and the trigger's onlyIfStale read verify every store's answer (shape, key version, params). A store may still lower staleAt from its own tag markers, as the KV store does. PrerenderLookupMeta and PrerenderSetOptions leave the public surface; serializePrerenderKey stays. The memory store's markStale never moves an earlier staleAt later. New rsc dogfood case: a naive store that answers every key with the last write does not serve one param set's entry under another's URL (red without the serve-path verification). * docs(router): on-demand prerender in the skills, design doc and feature map The skills said nothing about on-demand prerender, and the prerender skill's "Tag invalidation does not reach prerendered segments" section pointed only at the runtime cache. Now: - skills/prerender: an "On-demand refresh (ISR)" section with the opt-in, the store config, the trigger, a serve table (fresh / stale / build entry / miss for Prerender and Passthrough, dev and production, plus action re-renders) and the rules: soft ttl, per-isolate onRevalidate dedup, separate tag namespace that updateTag()/revalidateTag() never reach, markStale only marks, requestless producer with the live env, params outside getParams(), notFound() keeps the old entry, intercept variants not refreshed, client prefetch staleness, per-version keys, custom stores, testing. - skills/cloudflare: the KV store, scheduled sweep and queue consumer. - skills/deployment-caching: the on-demand row, Workers read cost, no Vercel store in v1. - skills/migrate-nextjs: revalidatePath / revalidateTag / time-based ISR mapped to router.prerender() / markStale() / onDemand.ttl. - design doc, prerender-api-design and feature map follow the API renames, the get/set store contract, the data-version key and action re-renders. tools/check-docs-api.mjs now finds non-generic interface declarations, which a `type` import in a skill names (WritablePrerenderStore). * refactor(router): on-demand prerender cleanup pass - onRevalidate dedup entries age out after IN_FLIGHT_LEADER_MAX_WAIT_MS, are skipped under ctx.build, and are cleared by resetShellTestState() - the request handler resolves the prerender key version per request, like the trigger does per refresh (once per .many() batch) - ppr + onDemand throws before the forRoute early return - shared store helpers lowerStoredEntryStaleAt and readVerifiedStoredEntry; KV get drops duplicate shape checks and skips marker reads for stale entries - trigger tags go through normalizeTagList (trim, drop empties, de-duplicate) - remove the unused PrerenderKey.intercept slot and :i suffix - dev warnings fire once per trigger; passthrough probe computed once per request; history-narrating comments cut * feat(router): onRevalidate target feeds router.prerender() without a cast PrerenderTargetObject carries a type-only brand and PrerenderTarget accepts it, so onRevalidate: (target, env) => router.prerender(target, { env }) typechecks on a router with named routes, queue JSON round trip included. A wrong route name or params in a hand-written target is still a type error; pinned in tests/cloudflare-basic/src/prerender-target-types.ts. * refactor(router)!: router.prerender({ env, ctx }) returns a runner Bind env and ctx once, then call the runner per target. Per-call options (onlyIfStale, throwOnError, concurrency) stay per call; the runtime type now holds only { env, ctx? }. onRevalidate is unchanged. * feat(router): onRevalidate receives the stale request's ctx The documented one-liner binds the runner with it: onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target). * fix(testing): serveShellRequest bakes no artifact for an on-demand route A build bakes an on-demand route only for the params getParams() lists, so a refresh-only page is served from its prerender store alone; baking every URL (the #1061 emulation) ran the producer per request and hid the overlay. * docs(router): design router.prerender() for every route, with red tests Design write-up for #1062, stacked on #640: router.prerender() keeps the requestless render for Prerender(..., { onDemand }) routes and warms every other route with a cookie-free GET through router.fetch's handler, where each runtime cache read misses and each write replaces the entry under the visitor's key. Covers the path table, the hook point per cache layer, the synthetic request and its in-process mark, the SegmentCacheStore.scope declaration, the result shape, answers to the open questions, the test plan, the build estimate, and the points where the decided design needs a correction. Red tests, no implementation: - src/testing/__tests__/prerender-warm.rsc-test.tsx (userland, through serveShellRequest and the public runner): 10 tests, all red. - src/cache/__tests__/store-scope.test.ts (scope of each shipped store): 5 tests, all red. The unit gate fails on these by design until the feature is built. * feat(cache): stores declare where their entries can be read SegmentCacheStore gains an optional `scope: "global" | "regional" | "local"` (CacheStoreScope): where an entry one request writes can be read by a later one. router.prerender() will warm a route only into a store shared beyond the place the call runs. - CFCacheStore: "global" with KV, "local" without (purge mode included). - VercelCacheStore: "regional" (the Runtime Cache is per region). - MemorySegmentCacheStore: "local". - A store that declares nothing is treated as "local". cache/store-scope.ts resolveWarmStoreScope is the gate's reading of a declaration. One exception lives there: under the Vite dev server a MemorySegmentCacheStore counts as "global", because that one process serves every request, so warming can be tried locally. Production reads the class's own declaration. Turns the 5 store-scope tests written with the design green. * feat(router): a warm request reads every runtime cache as a miss and forces its shell capture The request-side half of router.prerender() for every route (#1062): what a request does once it is marked as a warm. Nothing marks one yet; the runner that does follows. The mark is the Request object itself (a WeakMap in prerender/warm-request.ts, never a header, parameter or cookie a client could send). The handler reads it, puts the record on the request context (RequestContext._prerenderWarm) and uses the cache config the runner gated instead of resolving it again. Replace mode, one predicate (isWarmReplace) read where each layer already reads the request context, each taking its existing miss branch: - route cache(): CacheScope.lookupRouteDetailed, after enabled/condition() - loader cache(): executeLoaderData's getItem - "use cache": registerCachedFunction's lookup; the call also leads its own execution instead of joining an in-flight one - the ppr shell: shellServePlan reads neither the stored nor the build shell - the document cache and a response route's cache Every write goes through the unchanged write path and replaces the entry; nothing is evicted first. The layers count their writes into the record, and guardIdentityRead notes the surface it refuses. Guard split. A warm's capture is forced (ShellCaptureDescriptor.force): it bypasses the per-key in-flight dedup, the refused-capture backoff and skip-stored, which exist to stop a herd of visitors repeating work. It reads its caches normally (the predicate excludes _shellCaptureRun), so it replays what the warm just wrote, and waits longer on the write barrier. Every correctness guard decides as for a visitor: identity reads, the tag-invalidation write gates, render errors, size limits, cache(false) and condition(), the document cache's refusals, queue capacity. ShellCaptureDebugEvent gains `refusal` (ShellCaptureRefusal), set at each refuse exit. The handler also captures the async context each request enters with (captureRequestEntryContext, RequestContext._runAtRequestEntry): a warm called from inside a request is dispatched through it, outside the caller's AsyncLocalStorage scopes. Per request, because workerd refuses a snapshot outside the request that created it. * feat(router): router.prerender() warms any route that is not on-demand router.prerender() answered skipped-not-on-demand for every route that is not Prerender(..., { onDemand }). It now dispatches by route, after the match (#1062): - an on-demand route takes #640's requestless render into the prerender store, then one warm request on top of the new entry, when the app cache store is shared and an origin resolves (result.caches); - every other route is warmed: a cookie-free GET (accept: text/html only) of the URL a visitor requests, through router.fetch, marked in process, in replace mode. The runner drains the response, waits for the request's background work (the cache writes, the shell capture) and reports what was written. Store gate: a warm runs only when the app store's scope is "global" or "regional". A local or undeclared store returns skipped-store-not-shared before anything renders, and warns once per router in dev. Origin: the target's own, else `origin` on the binding (router.prerender({ env, ctx, origin })), else the origin of the request the runner is called from, else skipped-no-origin. The on-demand render never needs one. Search params: #640's rejection moved after the route match. A warm target keeps them (cache keys carry them); an on-demand target still rejects them. A hash or a reserved param (_rsc*, __no_cache) is unsupported on a warm. onlyIfStale on a warm is fill mode: the request reads its caches normally, so only what is cold or stale is rewritten, and zero writes is already-fresh. Result: every result past the match carries path: "on-demand" | "warm"; the warm path adds warmed, skipped-store-not-shared, skipped-no-origin, shell-not-stored and skipped-uncached, and `caches` (writes by store family, the shell's outcome and refusal, the document cache's outcome). skipped-not-on-demand is removed: #640 is unreleased. `key` and `tags` are on the on-demand variant only, so reading them narrows on `path`. A warm starts in a later millisecond than the call: a store refuses a shell whose capture started in the millisecond of an invalidation of one of its tags, which made `await updateTag(tag)` then a warm fail intermittently. cf-cache-store-warm-refresh.test.ts pins that sequence across two CFCacheStore instances sharing one fake KV: without an invalidation another colo keeps its own Cache API copy; after updateTag and the warm's write a colo with no copy reads the warmed entry from KV, and a colo holding the old copy stops serving it (it renders for itself: get() does not fall through to KV on an invalidated L1 hit). * feat(testing): router.prerender() warms under serveShellRequest A warm renders through router.fetch, whose handler is built with no loadSSRModule and reaches for import.meta.viteRsc.loadModule, which the rsc Vitest project cannot run. serveShellRequest already stubs exactly that one module for its own handlers. rsc/ssr-module-loader.ts holds the SSR module loader a handler created without loadSSRModule falls back to in tests; testing/serve-shell-request.ts installs its stub there when it loads. It is a leaf with no runtime imports because serve-shell-request.ts cannot import rsc/handler.ts at module scope (the handler binds build-only virtual modules). Everything else on a warm's path is production code: router.fetch, createRSCHandler, the match pipeline, the capture. Userland tests, through router.prerender() and serveShellRequest: - prerender-warm.rsc-test.tsx: the 10 tests written with the design, green with no change to a test body (the header comment no longer says they fail). - prerender-warm-layers.rsc-test.tsx (22): the same replace contract for a loader's own cache(), "use cache", the document cache and a response route; the key a warm writes under (search params, cache.searchParams, the host); the origin; a warm called from a route handler for a route no visitor has requested (a 404 without the request-entry context); updateTag then prerender from one handler; onlyIfStale; each refusal. * test(e2e): warming a route in a browser, dev and production, in both apps Six shared bodies in tests/shared-e2e (PrerenderWarmFixture), run by packages/rangojs-router/e2e/prerender-warm.test.ts (test-app, Node) and tests/cloudflare-basic/e2e/prerender-warm.test.ts (CFCacheStore over KV), each with a dev and a (production) describe: - warming a ppr route makes the next document request x-rango-shell: HIT, and it hydrates clean; - a warm replaces a stored shell: the next document shows the newer render; - a warm replaces a route cache() record a visitor's request wrote; - a route that reads cookies() reports skipped-personalized and stays a MISS; - a route handler calling router.prerender({ env }) with no origin warms on the request's origin, an on-demand and a plain target in one batch; - onlyIfStale leaves a warmed shell alone (already-fresh). Fixtures: /warm/shell, /warm/cached, /warm/personal, a trigger route and a generation bump in each app. Each test owns a ?probe=, and so its cache keys. The test-app's cache store is a memory store, refused in production. Its router.tsx now declares it shared (a subclass with scope "global": one process serves the app), which is what lets the production describe warm. Only the warm gate reads the declaration. Generated route files: router.named-routes.gen.ts in both apps, and urls.gen.ts in both, regenerated with `rango generate` (cloudflare-basic statically, the test-app with --runtime). Besides the five new routes each, the two urls.gen.ts files pick up routes earlier changes had not regenerated (7 in cloudflare-basic, 113 in the test-app). Red on #640's source (test-app, dev): all six fail, the plain targets with {"ok":false,"status":"skipped-unsupported-target"}. * docs(router): router.prerender() for every route - skills/prerender: "Warm any route before traffic": what one call does by route, which stores are shared, the origin, the anonymous request, the result statuses and `caches`, refreshing changed content with updateTag then prerender, the limits (router.fetch only, document records only, one location at a time), and how to test it. The on-demand section drops skipped-not-on-demand and says an on-demand route is also warmed. - skills/caching: filling a cache before traffic, and `scope` on a custom store. skills/cloudflare: "Warm routes on Workers" (KV required, an origin for scheduled/queue, what another colo does after updateTag and a warm, entries under 60 s). skills/vercel and skills/deployment-caching: the regional Runtime Cache. skills/ppr: capturing a shell before the first visitor. skills/testing: warming under serveShellRequest. skills/rango: the task row; catalog.json regenerated. - docs/design/prerender-every-route.md: marked built. The analysis is kept; where the build differs the text says what was built, the seven decisions on the design's corrections are recorded, and "What changed between the design and the build" lists every difference (the request-entry context, the later-millisecond start, the status order, `fresh`, two more refusal values, the SSR loader leaf, the edge-only suite not built). - docs/design/ondemand-prerender.md: the result shape and the refresh diagram no longer show skipped-not-on-demand. - docs/internal/feature-map.md, feature-file-map.md, docs/README.md: the new capability, files and exports. - CHANGELOG: "Added: router.prerender() warms any route before traffic". The refresh pattern is documented as the two-colo test shows it: after updateTag and a warm no location serves the old content, a location with no copy of its own reads the warmed entry, and on CFCacheStore a colo still holding the invalidated copy renders once for itself unless the store uses tagPurge. * refactor(router): cleanup pass on router.prerender() for every route Bound the later-millisecond wait, drain the warm response without keeping it, keep ordinary requests free of warm-only work, and settle one notion of dev, one result composer and one way to record each fact on the warm record. Pins the forced capture's ordering with a unit test and documents that a warm also overwrites a route's or loader's own cache({ store }).
CHANGELOG: ## Unreleased -> ## 0.22.0 (2026-10-09), plus the entries #640 merged without (on-demand prerender; cache({ tags }) drops duplicate tags). package.json 0.22.0.
Problem
Prerendering was build-time only. Once you shipped, the only way to refresh a
prerendered page's shared payload was to rebuild and redeploy the whole worker.
A CMS edit, a price change, or a webhook could not update a single product page
without a full deploy, and a running app had no way to say "re-render
/products/42now and serve that to everyone." Consumers who needed this reachedfor the runtime
cache()layer, but that keys on host/search/custom generatorsand its
updateTag()/revalidateTag()silently no-op from a queue or cron(they require an ALS request context), which are exactly the triggers ISR
refresh runs from.
What a consumer writes differently
Opt a route in (any truthy spelling works: literal, spread, or imported const):
tagsalso accepts a plainstring[].onDemandand thepprpath optioncannot be combined: the route definition throws.
Configure a store (same union shape as
cache):Trigger a refresh from anywhere:
concurrencydefaults to 1; a large list belongs in a queue, one message perbatch, because one invocation runs under the platform's time and CPU limits.
Before vs. after (a CMS webhook)
API changed during review (before vs. after, same snippet shape)
PrerenderKey.buildIdand the stored entry'smeta.buildIdare nowversion.@rangojs/router/prerenderno longer exportsResolvedPrerender,PrerenderRuntimeBase,PrerenderLookupMetaorPrerenderSetOptions.End-to-end (Cloudflare scheduled sweep + queue)
Semantics
Trigger results
onDemand{ ok: false, status: "skipped-not-on-demand" }: a stray webhook can't freeze a routeprerenderstore configured / prerender factory throws{ ok: false, status: "no-store" }(factory error attached)?search/#hash(v1 path-only){ ok: false, status: "skipped-unsupported-target" }{ ok: false, status: "no-match" }{ ok: false, status: "skipped-personalized" }, old entry keptnotFound()included (#587 flag){ ok: false, status: "render-failed" }, old entry kept{ ok: false, status: "store-failed" }, old entry + manifest keptctx.passthrough(){ ok: false, status: "skipped-passthrough" }, live handler keeps serving{ ok: true, status: "rendered", key, tags, ttl }, overlay replacedonlyIfStale: trueand the verified entry is fresh{ ok: true, status: "already-fresh" }, no render (cron-sweep opt-in)markStale(tags)onRevalidateWhat a request is served
Prerender(..., { onDemand })Passthrough(def, live)onRevalidatescheduled if configured (once per key per isolate while in flight):iartifact (the overlay is not read; the store has no intercept key); a refresh-only param 404s in productionUnchanged on purpose: plain (non-on-demand)
Prerenderroutes, including theiraction re-renders (the bundled entry, client copy kept); Passthrough action
re-renders (live);
updateTag()/revalidateTag()(never reach the prerenderstore).
Code-change summary
New (pure/type-only where possible, no RSC deps on the store contracts):
src/prerender/writable-store.ts:WritablePrerenderStore(plain get/set,optional
delete/markStale), the versionedPrerenderStoredEntryenvelope,PrerenderKey(main variant only, no intercept slot),serializePrerenderKey,and the router-side
composeStoredEntry/isStoredEntryValidFor/isStoredEntryStaleplus the sharedlowerStoredEntryStaleAtandreadVerifiedStoredEntryhelpers the stores and the router use.src/prerender/on-demand.ts:PrerenderConfig,PrerenderResult, typedPrerenderTarget, the brandedPrerenderTargetObject,PrerenderRuntime({ env, ctx? }),PrerenderRunOptions,PrerenderManyOptions,PrerenderRunner,PrerenderFn,OnDemandOption, and theinternal
ResolvedPrerender.src/prerender/memory-prerender-store.ts: in-memory store (dev default / testfake / single-process node).
src/prerender/cloudflare.ts:createKVPrerenderStore(
@rangojs/router/prerender/cloudflare).src/prerender/create-prerender-trigger.ts: the pure, injected-deps triggerfactory:
(runtime) => runnerwithrefresh/many/markStale,PrerenderError.src/prerender/producer-guard.ts:PrerenderPersonalizationError+assertNotInsidePrerenderProducer.src/prerender/index.ts:@rangojs/router/prerenderpublic entry.Edited:
router-options.ts/router-interfaces.ts:prerenderoption +router.prerenderbinder,_prerenderConfig.router.ts: build + attach the trigger. The key version comes fromresolvePrerenderVersion(routerId, version)per call; the trigger sharesisDevEnvironment()with the producer gate.server/build-version-table.ts:resolvePrerenderVersion, the one call bothsides use: the router's data version, or
createRouter({ version }).rsc/handler.ts: resolve the store per request ontoRequestContext._prerender, keyed withresolvePrerenderVersion(router.id, router.version)resolved per request (the trigger resolves it per refresh, onceper
.many()batch).router/match-middleware/cache-lookup.ts: overlay read before the bundledmanifest in
tryPrerenderLookup, verified by the router; gate widened topr || od, actions included for plain on-demand routes;yieldFromStore'sreplaceableflag re-sends route segments on an action overlay hit;scheduleOverlayRevalidationdedupsonRevalidateper isolate (an entry olderthan
IN_FLIGHT_LEADER_MAX_WAIT_MScounts as free; skipped underctx.build).The key block:
router/prerender-match.ts+router/handler-context.ts:onDemandflagarms the personalization guard, sets
ctx.onDemand, returns the route'sonDemand config, and carries the router's cache versions (
_versions).server/request-context.ts(_prerender,_onDemandProducer) +server/cookie-store.ts(guard wired into standalonecookies()/headers()and the client-cache directives).
urls/path-helper.ts: theisOnDemandstamp,gateOnDemandProducer(noin-request producer render in production), and the
ppr+onDemandthrow.prerender.ts(PrerenderOptions.onDemand,BuildContext.onDemand),server/context.ts(EntryData.isOnDemand), theodtrie pipeline(
generate-manifest.ts->route-trie.ts->trie-matching.ts/find-match.ts/pattern-matching.ts-> serve gate; regex-fallbackonDemandRouteKeysintypes/route-entry.ts+router.ts;discover-routers.ts).vite/utils/bundle-analysis.ts+vite/router-discovery.ts+vite/discovery/state.ts: producer retention driven by the discovery-derived$$idset, the same source as the runtimeodflag.testing/index.ts+package.jsonexports.tools/check-docs-api.mjs: recognizes non-generic interface declarations.Why these locations: the
prerenderoption mirrorscache(per-requestfactory resolution, never at
createRouter()time); the trieodflag mirrorspr/pt; the overlay read reusesyieldFromStore(loaders resolve fresh,unchanged); retention reuses the eviction-skip mechanism (the passthrough
precedent). Verification sits in the router, not the store, so a third-party
store cannot skip the collision guard. The key version sits next to
resolveRouterVersions, the module that already answers "which versions doesthis router use".
Test plan
src/prerender/__tests__/: trigger (target resolution, everystatus,
onlyIfStaleincl. a read failure and an entry that failsverification, throwOnError, many/concurrency, markStale incl. the
no-
onRevalidatedev warning, statictagsarrays), envelope(compose, verify: shape, version, params, missing tags/storedAt), memory
store (plain round-trip, no miss memoization, markStale never moves an
earlier
staleAtlater), KV store (tag-marker mark-stale, no KV expiry,corrupt value, malformed values returned for the router to reject).
src/testing/__tests__/prerender-dogfood.test.ts: eligibility andconfig paths through a real
createRouter();ppr+onDemandthrowsat route definition.
src/testing/__tests__/on-demand-prerender.rsc-test.tsx(
router.prerender({ env })(...)thenserveShellRequest(), real Flight serializer,production request handler). Pins: the stored payload serves while the
loader's value changes;
skipped-personalizedoncookies(); the routerverifies a store's answer (a naive store does not serve one param set's
entry under another URL); one key version on both sides with
createRouter({ version })and with the build's per-router data version,surviving a client-only deploy and dropped by a server deploy;
getCacheVersions()inside a refresh returns the router's versions withno outside-a-request warning;
onRevalidatescheduled once per stale keywhile one is in flight, again after it settles.
with the router's data version from the build:AssertionError: expected 'h-app' to be 'd-app'schedules onRevalidate once per stale key while one is in flight:AssertionError: expected "vi.fn()" to be called 1 times, but got 2 timesa cache key built inside a refresh carries the router's versions(producer_versionsremoved):expected '1:"/Users/…' to contain 'versions:d-app/h-app', plus the[rango] A cache key was built outside a requestwarningthe router verifies a store's answer(serve-path verification removed):expected '1:"/Users/…' not to contain 'a:stamp-1'(KV store): live -> trigger -> overlay serve, loaders fresh on an overlay
hit, build-baked param still served, personalized producer skipped,
plain-route 404 until refreshed (production) / dev fall-through,
onlyIfStale+markStale(KV tag markers end to end), stale overlayschedules
onRevalidate, and two new cases per app per mode:server action on an overlay-only page re-renders from the overlayrouter.prerender() inside a server action: the re-render shows the refreshed entry--retries=0):od-action-noop-statusstayedidle(the re-render 404'd)od-action-refresh-statusstayedidleExpected: not "2026-10-04T13:07:31.429Z" / Received: "2026-10-04T13:07:31.429Z"(client kept its copy)gp-action-noop-statusnotnoop:…gp-action-refresh-statusnotrendered:…Expected: not "2026-10-04T13:08:45.198Z:3f1w2i4w" / Received: samethe dev prerender endpoint renders any param, and the store-hit path
keeps the client's segments, so the bug is production-only for that case.
(both apps); client router chunk 47489 B gzip (limit 48128 B).
test:unit:all(router 7646 passed / 5 skipped /6 todo; router RSC 523 passed / 2 expected fail; cloudflare-basic 69 + 17;
mini 25 + 3; vite-rsc-demo 25 + 1; shared-e2e 8), lint, format,
check:e2e-bucketing,check:e2e-parity --strict,check:docs-api,check:bundle-guards.--no-deps,--retries=0:prerender on-demand ondemand shell ppr cache-version semantic-matrix(428 tests, 22 files): 427 passed, 1 failed:shell-capture-timeout (dev) › budget shorter than the settlement sequence refuseswithapiRequestContext.get: read ECONNRESETon its own server;re-run alone, the file passed 4/4.
prerender on-demand ondemand ppr cache-version(317 tests, 18 files): 313 passed, 1 skipped, 3 failed.ppr-shell caching (production) › updateTag evicts the build-time shell(
Expected: "HIT" / Received: "MISS") fails the same way onorigin/main(5b7d02a) with
ppr-shell+prerender-passthrough+ppr-capture-timeout, dev + production (157 passed, 1 failed), and passeswhen run alone in both trees: the dev and production servers share the
miniflare KV, and the dev run of the same test writes the tag marker. The
other two,
ppr capture timeout (dev) › budget shorter…(ECONNRESET) andprerender passthrough (dev) › cross-route navigation in dev mode, passedon re-run (and in a 4-file branch re-run: 175 passed, 1 failed, the
ppr-shell case).
Independent review
An API review after
mainwas merged in found seven must-fix and sevenshould-fix items; all fourteen were applied in this PR:
overlay-only param in production, the older build payload on a baked one).
Fixed; an action overlay hit also re-sends the route's segments so a refresh
inside the action is visible.
every refreshed page. It now keys on the data version through one shared
resolution;
buildIdis renamedversion.PrerenderConfigbecame{ store, ttl?, onRevalidate? }(swrremoved,defaultTtlrenamed);invalidateTagsbecamemarkStale; the public entrydropped internal types.
with the serve table and the rules.
@rangojs/router/testing. Thereview asked for it through
dispatch();dispatchrenders response routesonly (it throws on a component route) and the overlay is read in the RSC
match pipeline, so it cannot serve an on-demand page.
serveShellRequestalready runs the production handler, which resolves the
prerenderconfig,so the dogfood uses it and no primitive changed.
onRevalidateisdeduped per isolate; dev warns on
markStalewith noonRevalidate; thetrigger and the producer gate share
isDevEnvironment();ppr+onDemandthrows;
tagsaccepts arrays; the producer_versionswiring has a test;the test-app fixture comment on the typed target was corrected.
Two review premises did not hold and are noted rather than worked around:
dispatch()(above), and the fixture comment: the typed{ route, params }target still does not typecheck against the test-app router, for a different
reason than the comment gave (its accumulated route map has a string index
signature, so the mapped target union collapses to
neverparams;router.reverse()infers the name and is unaffected). Also,cache()and"use cache"do not consult a store inside a refresh at all (the producercontext carries no
_cacheStore), so the_versionstest pinsgetCacheVersions(), the call every versioned store makes per key.Notes / call-outs
ctx.envinside a producer render is the live trigger binding, not theshared build
buildEnv. A handler relying on build-only bindings behavesdifferently under refresh.
getParams()never returned. Validate ids fromwebhooks before refreshing.
keeps its copy for
prefetchCacheTTL(300 s default) and the browser HTTPcache (
prefetchCacheControl).the entry (no marker memo in v1).
createRSCHandler({ version })does not move the overlay key: both sides keyon
createRouter({ version })or the build's data version, so writes andreads cannot disagree.
Follow-ups (to be filed)
serves the build
:iartifact, 404 on a refresh-only param).createRSCHandler({ version })andcreateRouter({ version })disagree.
.many(),render-failed,skipped-passthrough.signature (test-app). A generic-name signature like
reverse's would fix it.Review pass
Applied (commit 1, cleanup; commit 2, typing):
onRevalidatededup cannot pin a key: entries carry a start time and count asfree past
IN_FLIGHT_LEADER_MAX_WAIT_MS(the runtime cache's leader cap, movedto
cache-policy.tsso cache-lookup does not importcache-runtime);scheduling is skipped under
ctx.build;resetShellTestState()clears the map.Eviction unit-tested.
resolvePrerenderVersionper request.The
serveShellRequesthandler cache now keys on the document version only (thehandler still binds that one), so a data-only change reuses the handler. The new
a long-lived handler follows a data version changetest fails with the oldper-handler resolution.
ppr+onDemandthrows before theforRouteearly return.lowerStoredEntryStaleAtandreadVerifiedStoredEntry; the KVgetdrops the shape re-checks, tolerates a missingmeta, and skips markerreads for an already-stale entry.
normalizeTagList(now exported, and de-duplicating,which
cache({ tags })also gets); non-string tags are filtered first.:ibranch are removed fromPrerenderKey;main-variant keys serialize as before (asserted).
computed once per request;
.many()resolves the version once; history-narrating comments cut.
PrerenderTargetObjectcarries a type-only brand andPrerenderTargetacceptsit, so
onRevalidate: (target, env, ctx) => router.prerender({ env, ctx })(target)typechecks with no cast, queue JSON round trip included; a wrong route name or
params is still an error. Pinned in
tests/cloudflare-basic/src/prerender-target-types.ts(@ts-expect-errornegatives); casts removed from the skills and design doc.
Not applied (decided): moving the dedup into the trigger (it would stop deduping
queue sends); replacing
yieldFromStore'sreplaceableflag with a sharedpredicate; a per-isolate memo for KV tag markers (tracked in #1060); switching
the trigger's dev flag back to
__PRERENDER_DEV_URL(theisDevEnvironment()unification was approved).
Second run by the reviewer on the final commit (after
pnpm build-router): gate green (router 7658 unit, 524 RSC with 2 expected failures; typecheck, lint, format, bucketing, parity --strict, docs-api 279 identifiers, bundle guards); e2e--no-depsover every prerender / on-demand file (HMR excluded): router app 84 passed in dev and 108 in production, cloudflare-basic 36 passed (1 existing skip) in dev and 107 in production; semantic matrix 25 dev, 28 production.How this PR was finished: step 1 merged 214 commits of
maininto the July branch and resolved 17 conflicts keeping both sides; step 2 was an independent overall API review (verdict: sound, finish it; the work list and the six maintainer decisions are in "Independent review" above); step 3 applied it; a code-quality review of step 3 followed ("Review pass"). Follow-ups placed after the merge are tracked in #1060.Changed on the maintainer's request after the review pass:
router.prerendernow binds the runtime once and returns a runner; per-call options stay per call.
The runtime type holds only
{ env, ctx? };onlyIfStale/throwOnError(and
concurrencyon.many()) moved to the runner's per-call options.Binding does no work; the config factory, version and manifest still resolve per
call,
.many()keeps its bounded pool, input order,concurrencydefault 1 andone version per batch, and the
markStaledev warnings still fire once perrouter.
onRevalidatenow also receives the stale request'sctxas a thirdargument (absent where none exists), so the documented one-liner binds the runner
with it; pinned in
prerender-target-types.tsand the dogfood'stoHaveBeenCalledWith.Merged with
mainafter #1061 (the #1057 fix: aPrerender+pprshell HIT restores its loader pushes). One textual conflict,resetShellTestState()insrc/testing/serve-shell-request.ts, kept both resets. One interaction: #1061 madeserveShellRequestbake a build-time artifact for everyPrerenderroute on its first request; for an on-demand route that ran the producer per request and hid the overlay, which a build does not do (it bakes an on-demand route only for the paramsgetParams()lists).serveShellRequestnow bakes nothing for an on-demand route (matched.od), so a refresh-only page serves from its prerender store alone, as in production. No overlap in the serve path:pprandonDemandcannot combine, so ahandlesrecord (#1061) never meets an overlay hit.Run by the reviewer on the merged commit (after
pnpm build-router): gate green (router 7676 unit, 529 RSC with 2 expected failures; typecheck, lint, format, bucketing, parity --strict, docs-api 279 identifiers, bundle guards); e2e--no-depsover every prerender / on-demand file plusshell-push-ownership/ppr-push-ownershipand the semantic matrix: router app 119 passed in dev and 146 in production, cloudflare-basic 46 passed (1 existing skip) in dev and 117 in production.