@rangojs/router 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +2 -1
  2. package/dist/types/cache/document-cache.d.ts +3 -1
  3. package/dist/types/cache/types.d.ts +4 -3
  4. package/dist/types/route-definition/helpers-types.d.ts +6 -6
  5. package/dist/types/router/match-handlers.d.ts +2 -3
  6. package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
  7. package/dist/types/router/transition-when.d.ts +13 -0
  8. package/dist/types/rsc/shell-serve.d.ts +2 -0
  9. package/dist/types/rsc/transition-gate.d.ts +10 -14
  10. package/dist/types/server/request-context.d.ts +5 -1
  11. package/dist/types/testing/e2e/index.d.ts +1 -1
  12. package/dist/types/testing/index.d.ts +2 -2
  13. package/dist/types/testing/run-transition-when.d.ts +6 -5
  14. package/dist/types/testing/shell-status.d.ts +23 -3
  15. package/dist/types/types/segments.d.ts +27 -22
  16. package/dist/types/urls/path-helper-types.d.ts +4 -3
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +20 -21
  19. package/skills/cache-guide/SKILL.md +6 -3
  20. package/skills/catalog.json +7 -1
  21. package/skills/deployment-caching/SKILL.md +176 -0
  22. package/skills/document-cache/SKILL.md +30 -3
  23. package/skills/ppr/SKILL.md +57 -25
  24. package/skills/prerender/SKILL.md +15 -8
  25. package/skills/rango/SKILL.md +20 -17
  26. package/skills/testing/SKILL.md +1 -1
  27. package/skills/testing/cache-prerender.md +5 -1
  28. package/skills/vercel/SKILL.md +22 -1
  29. package/skills/view-transitions/SKILL.md +12 -8
  30. package/src/cache/cache-scope.ts +6 -1
  31. package/src/cache/document-cache.ts +4 -2
  32. package/src/cache/types.ts +4 -3
  33. package/src/route-definition/helpers-types.ts +6 -6
  34. package/src/router/match-handlers.ts +44 -6
  35. package/src/router/match-middleware/cache-lookup.ts +10 -3
  36. package/src/router/segment-resolution/view-transition-default.ts +9 -5
  37. package/src/router/transition-when.ts +76 -0
  38. package/src/rsc/progressive-enhancement.ts +9 -14
  39. package/src/rsc/rsc-rendering.ts +142 -31
  40. package/src/rsc/shell-serve.ts +3 -0
  41. package/src/rsc/transition-gate.ts +37 -40
  42. package/src/server/request-context.ts +6 -0
  43. package/src/testing/e2e/index.ts +5 -0
  44. package/src/testing/index.ts +9 -1
  45. package/src/testing/run-transition-when.ts +42 -9
  46. package/src/testing/shell-status.ts +92 -3
  47. package/src/types/segments.ts +27 -22
  48. package/src/urls/path-helper-types.ts +4 -3
@@ -0,0 +1,176 @@
1
+ ---
2
+ name: deployment-caching
3
+ description: "Choose the deployment cache boundary for a Rango app: in-function segment/prerender/PPR caches, store-backed whole-response caching, or an external CDN cache. Use when comparing Cloudflare, Vercel, and Node deployments; deciding whether Cache-Control can reduce origin work; or reasoning about middleware, live loaders, PPR, and CDN behavior."
4
+ argument-hint: [cloudflare|vercel|node]
5
+ ---
6
+
7
+ # Deployment caching boundaries
8
+
9
+ Start with one question: **does the request reach Rango before shared bytes are
10
+ served?** That boundary decides whether middleware runs, whether loaders stay
11
+ live, and which invalidation system owns the result.
12
+
13
+ ## The execution matrix
14
+
15
+ | Mechanism | Stored artifact | Function/worker runs on a hit? | Rango middleware on a hit? | What stays live? |
16
+ | --------------------------------- | ----------------------------------------------- | -----------------------------: | -----------------------------------------------------------: | ------------------------------------ |
17
+ | `"use cache"` | one function result | yes | yes | caller, handlers, loaders, rendering |
18
+ | `cache()` | serialized Flight segments | yes | yes | middleware, loaders, HTML rendering |
19
+ | `Prerender()` | build-time Flight segments in the server bundle | yes | yes | middleware, loaders, HTML rendering |
20
+ | `ppr` | HTML prelude + React postponed state | yes | **yes, before shell commit** | middleware, holes, hydration payload |
21
+ | `createDocumentCacheMiddleware()` | complete response in the app cache store | yes | outer middleware runs; the hit skips its downstream pipeline | nothing downstream |
22
+ | HTTP CDN cache (`s-maxage`) | complete HTTP response outside the app | **no** | **no** | nothing |
23
+
24
+ The first five rows enter the app. The first four preserve the complete request
25
+ model; a document-cache middleware hit intentionally short-circuits its
26
+ downstream pipeline. The last row is a platform cache: on a hit it serves bytes
27
+ without invoking Rango at all.
28
+
29
+ ## Rango PPR is in-function PPR
30
+
31
+ Rango's `ppr` path option does not make the HTML shell a public static asset.
32
+ The worker or function handles every document request:
33
+
34
+ ```text
35
+ request
36
+ -> global middleware
37
+ -> route middleware
38
+ -> shell lookup
39
+ -> flush stored prelude
40
+ -> run live loaders + Flight + React resume
41
+ ```
42
+
43
+ That ordering is the security contract. A redirect, 401, tenant decision,
44
+ `ctx.dynamic()`, cookie, or response header from middleware wins before a shell
45
+ byte is committed.
46
+
47
+ There are two shell producers:
48
+
49
+ - An ordinary `ppr` route captures after a runtime MISS.
50
+ - A route that combines `Prerender()` with the `ppr` path option captures during
51
+ `vite build`, so its first production request can already be a shell HIT.
52
+
53
+ Both producers feed the same in-function serve path. Build-time shells are
54
+ content-hashed modules in the server bundle, not CDN-served HTML files.
55
+
56
+ `ppr.ttl`, `ppr.swr`, and `ppr.tags` govern that shell entry. They do **not**
57
+ emit HTTP `Cache-Control` and do not configure a platform CDN.
58
+
59
+ ## Platform deployment shapes
60
+
61
+ ### Cloudflare
62
+
63
+ The Cloudflare preset runs the RSC app in a Worker. The request reaches the
64
+ Worker at the edge, middleware runs there, and PPR shell lookup/resume stays in
65
+ that Worker. `CFCacheStore` supplies the app cache families; client assets are
66
+ served separately as assets.
67
+
68
+ ### Vercel
69
+
70
+ The Vercel preset emits static client assets plus one streaming Node Function.
71
+ HTML, Flight, prerender payloads, and PPR shells are served from that function.
72
+ `VercelCacheStore` uses Runtime Cache inside the function; Runtime Cache is not
73
+ Vercel's CDN/ISR cache.
74
+
75
+ The preset deliberately emits no Vercel `.prerender-config.json`, response
76
+ `chain`, or CDN-stitched resume function. Vercel's open-source Build Output
77
+ parser accepts a generic `chain`, but the production CDN stitching protocol is
78
+ not a documented third-party Build Output contract. More importantly, a CDN
79
+ that emits the shell before invoking Rango cannot preserve the middleware
80
+ ordering above. Parser support does not solve that semantic mismatch.
81
+
82
+ ### Generic Node
83
+
84
+ The Node preset runs the same in-function model behind your server or reverse
85
+ proxy. A CDN in front may cache complete responses when you emit shared-cache
86
+ headers, but that CDN is outside Rango and follows the HTTP CDN row in the
87
+ matrix.
88
+
89
+ ## Cache-Control is the full-response mitigation
90
+
91
+ If a response is completely public and shared, HTTP caching can eliminate more
92
+ origin work than CDN-stitched PPR:
93
+
94
+ ```http
95
+ Cache-Control: public, s-maxage=300, stale-while-revalidate=3600
96
+ ```
97
+
98
+ On a CDN hit the function does not run and no shell or dynamic tail crosses the
99
+ origin boundary. The tradeoff is exact: the CDN stores the **completed** HTML
100
+ response, including loader output, resumed holes, and hydration payload. It
101
+ does not cache only the PPR prelude.
102
+
103
+ Use shared HTTP caching only when all of these are true:
104
+
105
+ - the complete response is identical for every request sharing the cache key;
106
+ - no authorization, redirect, rate-limit, tenant, or preview middleware must
107
+ run on every request;
108
+ - no loader or rendered value contains session, cart, account, experiment, or
109
+ other per-user data;
110
+ - replaying the response headers is safe, with no per-client `Set-Cookie`;
111
+ - TTL/SWR freshness for the whole response is acceptable.
112
+
113
+ Do not use `Vary: Cookie` as a general escape hatch. It creates a variant for
114
+ every cookie combination, destroys cache reuse, and makes the safety contract
115
+ hard to audit.
116
+
117
+ ### One header, two possible consumers
118
+
119
+ `createDocumentCacheMiddleware()` parses `Cache-Control: s-maxage` as policy for
120
+ the configured app store's response family. The deployment platform may also
121
+ interpret the same header and cache the response at its CDN.
122
+
123
+ These are independent caches:
124
+
125
+ ```text
126
+ CDN HIT
127
+ -> function never runs
128
+
129
+ CDN MISS
130
+ -> function
131
+ -> Rango document-cache middleware HIT or MISS
132
+ ```
133
+
134
+ Consequences:
135
+
136
+ - `skipPaths`, `isEnabled`, and `keyGenerator` only control the Rango
137
+ middleware. They cannot guard a response already served by the CDN.
138
+ - `x-document-cache-status` reports the Rango store only when the function
139
+ executes. A CDN may replay an old status header; use the platform's own cache
140
+ header or logs to identify CDN hits.
141
+ - Platform CDN invalidation and the app store's tags/TTL are separate systems.
142
+ Do not assume `updateTag()` purges an external CDN response.
143
+
144
+ Use a platform-targeted header such as `Vercel-CDN-Cache-Control` when you need
145
+ to keep CDN policy out of browser/downstream `Cache-Control`, but the complete
146
+ response and middleware-bypass rules are unchanged. Rango's document middleware
147
+ does not parse that platform-specific header; use it when the CDN, rather than
148
+ the app store, should own the complete response.
149
+
150
+ ## Decision guide
151
+
152
+ | Requirement | Choose |
153
+ | --------------------------------------------------- | ------------------------------------------ |
154
+ | Per-request auth or request shaping | in-function caching; never shared CDN HTML |
155
+ | Stable shell with cart/session/live prices | `ppr` with live holes |
156
+ | Build-known segments with live loaders | `Prerender()` |
157
+ | Fully public response, whole-page TTL is acceptable | HTTP `s-maxage` + SWR |
158
+ | Whole response reused inside the app store | `createDocumentCacheMiddleware()` |
159
+ | One query or component is expensive | `"use cache"` |
160
+ | One route subtree is expensive | `cache()` |
161
+
162
+ For a large dynamic app, CDN-stitched PPR would mainly improve shell first-byte
163
+ latency and avoid sending the prelude from the function. It would not remove the
164
+ per-request function invocation, live loaders, Flight payload, or React resume.
165
+ If the current in-function path is already fast, preserve middleware semantics
166
+ and apply full-response CDN caching only to the smaller set of routes that are
167
+ provably public and shared.
168
+
169
+ ## Related skills
170
+
171
+ - `/ppr` — shell capture, holes, middleware commit point, invalidation
172
+ - `/prerender` — build-time Flight segments and `Prerender + ppr`
173
+ - `/document-cache` — store-backed complete-response middleware
174
+ - `/vercel` — Vercel Build Output preset and Runtime Cache wiring
175
+ - `/cloudflare` — Worker deployment and bindings
176
+ - `/cache-guide` — function, loader, and segment cache selection
@@ -1,12 +1,18 @@
1
1
  ---
2
2
  name: document-cache
3
- description: Cache the whole HTTP response at the edge with Cache-Control headers. Use when caching an entire page or response at a CDN edge, setting Cache-Control headers, or cutting origin hits for public pages not for caching a single segment or function.
3
+ description: Cache complete HTTP responses in Rango's configured app store with createDocumentCacheMiddleware, using Cache-Control s-maxage as policy. Use when reusing a whole HTML/RSC response, comparing the store-backed middleware with a platform CDN cache, or deciding whether full-response caching is safe.
4
4
  argument-hint: [setup]
5
5
  ---
6
6
 
7
- # Document Cache
7
+ # Store-backed Document Cache
8
8
 
9
- Caches complete HTTP responses (HTML/RSC) at the edge based on Cache-Control headers. Routes opt-in by setting `s-maxage`.
9
+ Caches complete HTTP responses (HTML/RSC) in the app-level cache store based on
10
+ `Cache-Control`. Routes opt in by setting `s-maxage`.
11
+
12
+ This middleware runs **inside** the worker/function. It is not itself a platform
13
+ CDN cache. With `CFCacheStore` the response family can use Cloudflare's edge/KV
14
+ tiers; with `VercelCacheStore` it uses Vercel Runtime Cache. The request still
15
+ reaches Rango before the middleware can return a store hit.
10
16
 
11
17
  ## Not this skill if…
12
18
 
@@ -15,6 +21,8 @@ Caches complete HTTP responses (HTML/RSC) at the edge based on Cache-Control hea
15
21
  is `cache()`: see `/caching`.
16
22
  - You want a cached HTML shell with per-request live holes — see `/ppr`.
17
23
  - You are unsure which cache layer you need — start at `/cache-guide`.
24
+ - You mean a platform CDN that serves a complete response without invoking the
25
+ app — see `/deployment-caching` first.
18
26
 
19
27
  ## Setup
20
28
 
@@ -55,6 +63,13 @@ Routes opt-in to document caching by setting a `Cache-Control` response header
55
63
  with `s-maxage`. The middleware caches responses whose `Cache-Control` includes
56
64
  `s-maxage`; `stale-while-revalidate` enables background revalidation (SWR).
57
65
 
66
+ The deployment platform may independently interpret the same `s-maxage` header
67
+ and cache the completed response at its CDN. A CDN hit bypasses the function,
68
+ all Rango middleware, handlers, and loaders. Therefore these headers are safe
69
+ only when the **complete** response is public and identical for every request
70
+ sharing the cache key. The middleware's `skipPaths`, `isEnabled`, and
71
+ `keyGenerator` cannot protect a response once an outer CDN serves it.
72
+
58
73
  ```typescript
59
74
  // Cache full page for 5 min, serve stale for 1 hour
60
75
  function BlogIndexHandler(ctx) {
@@ -116,6 +131,11 @@ Request → Check Cache
116
131
  background (SWR)
117
132
  ```
118
133
 
134
+ This diagram starts after the request reaches the Rango middleware. A store hit
135
+ short-circuits the middleware's downstream pipeline; global middleware that
136
+ wraps it can still run. Route middleware, handlers, and loaders below it do not.
137
+ An external CDN hit is different: the function never runs at all.
138
+
119
139
  ## Cache Status Header
120
140
 
121
141
  Response includes `x-document-cache-status`:
@@ -124,6 +144,10 @@ Response includes `x-document-cache-status`:
124
144
  - `STALE` - Served stale, revalidating in background
125
145
  - `MISS` - Cache miss, response was generated fresh
126
146
 
147
+ This header reports the Rango store-backed middleware, not the platform CDN. A
148
+ CDN may replay a previously cached status header, so use the platform's cache
149
+ header or logs to identify an actual CDN hit.
150
+
127
151
  ## Cache Key Generation
128
152
 
129
153
  Default keys differentiate:
@@ -211,3 +235,6 @@ function BlogPost(ctx) {
211
235
  | Key includes | URL + segment hash | Route params |
212
236
 
213
237
  Use document cache for mostly-static pages. Use segment cache when different parts of a page have different cache requirements.
238
+
239
+ See `/deployment-caching` for the full in-function versus CDN execution matrix,
240
+ middleware implications, and the shared-response safety checklist.
@@ -7,16 +7,21 @@ argument-hint: "[setup]"
7
7
  # PPR Shell Caching
8
8
 
9
9
  Caches the rendered HTML **shell** of a page route (React `prerender` prelude
10
- bytes plus `postponed` state) and, on a later request, flushes those bytes
11
- before any render work happens, then resumes fizz for just the live holes. The
12
- browser sees one ordinary streamed document; loaders stay fresh on every
13
- request. This is the second render axis the default axis-1 path is untouched,
14
- and every ineligible request falls open to it.
10
+ bytes plus `postponed` state) and, on a later request, flushes those bytes after
11
+ route classification and the complete middleware chain, but before downstream
12
+ tail rendering. It then resumes fizz for just the live holes. The browser sees
13
+ one ordinary streamed document; loaders stay fresh on every request. This is
14
+ the second render axis the default axis-1 path is untouched, and every
15
+ ineligible request falls open to it.
15
16
 
16
17
  Compare `/document-cache`, which freezes the WHOLE response including loader
17
18
  output. Shell caching is for pages that mix a stable shell with live data: the
18
19
  shell is shared per host+URL, the holes are per request.
19
20
 
21
+ This is in-function PPR on every deployment. The worker/function serves the
22
+ prelude; it is not a CDN static file. See `/deployment-caching` before combining
23
+ PPR with HTTP shared-cache headers.
24
+
20
25
  ## Not this skill if…
21
26
 
22
27
  - You want the WHOLE response frozen, loader output included — see
@@ -188,21 +193,31 @@ and loader-container pins are NOT replayed on this path, so loader reads stay
188
193
  live. A route's own `cache()` scope still resolves its normal store, key, TTL,
189
194
  SWR, tags, and condition; only the implicit document scope sees the replay
190
195
  overlay, and fresh segment writes there stay request-local rather than polluting
191
- the canonical `doc:` namespace. Intercepts, handler-live holes,
192
- `transition({ when })`, an active nonce, and an absent/corrupt segment snapshot
193
- fall open to the ordinary partial path when encountered by the fresh shell
194
- capture. A transition already replayed from an explicit cache tier remains
195
- frozen by that tier's normal semantics.
196
-
197
- Only fresh shells replay. Production may use either a runtime entry or the
198
- local build manifest; development uses runtime entries only, because probing
199
- `/__rsc_shell` would block the foreground navigation on an on-demand capture.
200
- A passive stale read does not claim SWR ownership because partial requests
201
- cannot recapture the HTML shell. Custom `SegmentCacheStore` implementations
202
- must set `supportsPassiveShellReads: true` and honor
203
- `getShell(key, { claimRevalidation: false })` to opt into navigation replay.
204
- There is still no Flight resume API; this is segment replay followed by normal
205
- Flight streaming, not reuse of the HTML `prelude`/`postponed` bytes.
196
+ the canonical `doc:` namespace. `transition({ when })` is evaluated from the
197
+ matched manifest before route handlers on every PPR match, so it can vary by
198
+ URL/params/action or middleware context without disabling replay; handler-set
199
+ context is unavailable by design. Intercepts, handler-live holes, an active
200
+ nonce, and an absent/corrupt segment snapshot fall open to the ordinary partial
201
+ path when encountered by the shell capture. A transition already replayed from
202
+ an explicit cache tier remains frozen by that tier's normal semantics.
203
+
204
+ Fresh and stale-within-SWR runtime shells replay. The stale read is passive: it
205
+ uses `getShell(key, { claimRevalidation: false })`, does not claim SWR ownership,
206
+ and cannot recapture HTML. A later document request owns the background
207
+ recapture; hard-expired entries fall open. Production may also use a fresh local
208
+ build manifest; development stays runtime-only because probing `/__rsc_shell`
209
+ would block navigation on an on-demand capture. Custom `SegmentCacheStore`
210
+ implementations must set `supportsPassiveShellReads: true` and honor the
211
+ non-claiming read option.
212
+
213
+ Partial responses expose the actual decision as `x-rango-ppr-replay`:
214
+ `HIT; freshness=fresh|stale` or `BYPASS; reason=<bounded-token>`. With
215
+ performance metrics enabled, the same decision appears as
216
+ `ppr-navigation-replay` in `Server-Timing`. `HIT` means matching consumed the
217
+ seeded segment record after it decoded successfully, not merely that a snapshot
218
+ existed. An explicit `cache()` scope that supplies the match cannot produce a
219
+ false HIT. There is still no Flight resume API; this is segment replay followed
220
+ by normal Flight streaming, not reuse of the HTML `prelude`/`postponed` bytes.
206
221
 
207
222
  ### Capture-generation invalidation
208
223
 
@@ -252,7 +267,12 @@ sees the header directly; only an explicit Flight request shape lacks it:
252
267
  curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-shell
253
268
  ```
254
269
 
255
- - First document GET: `MISS`, plus a background capture.
270
+ - Runtime-captured route: first document GET is `MISS`, plus a background
271
+ capture; a later request becomes a `HIT`.
272
+ - `Prerender + ppr` route: the shell is produced during `vite build`, so the
273
+ first production document request can already be a `HIT`. In dev, producer B
274
+ runs on demand and can return a `HIT`; if capture outlasts the bounded
275
+ foreground wait, the request falls open to `MISS` and runtime capture.
256
276
  - Production (workerd/node): the SECOND request is a `HIT`.
257
277
  - Dev: expect a few extra MISSes — cold module transforms abort the capture
258
278
  window (per-attempt breadcrumbs: start the server with
@@ -284,6 +304,8 @@ Import from `@rangojs/router/testing` (Vitest) or `@rangojs/router/testing/e2e`
284
304
  | Helper | Use for |
285
305
  | ------------------------------------------------- | ------------------------------------------------------------ |
286
306
  | `assertShellStatus(res, "HIT" \| "MISS")` | Document Response from a real RSC serve / e2e `page.request` |
307
+ | `assertPprReplayStatus(res, expected)` | Partial response fresh/stale replay or bounded bypass |
308
+ | `parsePprReplayStatus(res)` | Read structured replay/bypass status or null |
287
309
  | `shellCacheKey(url)` | Production store key for `store.getShell` / custom stores |
288
310
  | `MemorySegmentCacheStore` + `getShell`/`putShell` | Custom store contract / tag eviction (no faked HIT) |
289
311
 
@@ -639,11 +661,18 @@ path(
639
661
  | `swr` | — | stale window: serve the stale shell + background recapture |
640
662
  | `tags` | — | operational tags UNIONED with the tags the capture render auto-collects — see "Invalidation" below |
641
663
  | `maxSnapshotBytes` | 8 MiB | cap on the entry's capture data snapshot; over it the snapshot is skipped (shell still stored, warned once per key) so the entry stays under store limits |
664
+ | `captureTimeout` | 15000ms | capture settle budget; a timed-out capture is refused rather than storing a partial shell |
642
665
 
643
666
  The shell store is always the app-level `createRouter({ cache })` store; the
644
667
  default key is `${host}${pathname}${sortedSearch}:shell` (host-scoped so
645
668
  multi-tenant shells never collide).
646
669
 
670
+ These options control the in-function shell entry only. They do not emit HTTP
671
+ `Cache-Control`. Adding `s-maxage` separately allows a platform CDN to cache the
672
+ completed response, including the live-hole output, and CDN hits bypass Rango
673
+ middleware entirely. Only do that for a fully public response whose complete
674
+ output is shared; see `/deployment-caching`.
675
+
647
676
  ## Invalidation: tags vs revalidate()
648
677
 
649
678
  `updateTag()`/`revalidateTag()` is the ONLY lever that changes the frozen shell
@@ -676,8 +705,9 @@ evicted by tag at all — move always-fresh data under a `loading()` hole.
676
705
  - **A bake-lane container that must be fresh per document GET**: it is
677
706
  snapshot-pinned for the shell's lifetime by design. Use the live lane
678
707
  (`loading()`) or a nested promise instead.
679
- - **A bake-lane loader slower than the capture guard (~5s)**: the capture
680
- cannot hold for it eternal MISS with the once-per-key warning.
708
+ - **A bake-lane loader slower than `ppr.captureTimeout` (15s by default)**: the
709
+ capture is refused rather than storing a partial shell. Increase the route's
710
+ budget only when the deployment can keep the background/build work alive.
681
711
  - **Per-user value in shell material**: baked into the shared shell —
682
712
  deterministically, not by race (handler promises deep-settle at the ring-3
683
713
  write on cached chains; awaited/resolved values bake everywhere). Put
@@ -728,8 +758,10 @@ cache"` value baked into the shell is PINNED at capture (the capture data
728
758
  prelude and detonate hydration. Wrap it in `cache()`/`"use cache"` (then it is
729
759
  pinned) or move it under a hole (`loading()`, or a pending-promise
730
760
  `<Suspense>` region).
731
- - **Stacking with `/document-cache`**: pick one per route the document cache
732
- would cache the composite.
761
+ - **Stacking with `/document-cache` or HTTP CDN caching**: both cache the
762
+ completed composite, including live-hole output, so PPR becomes redundant on
763
+ a hit. A platform CDN also bypasses every Rango middleware. Restrict this to
764
+ fully public, shared responses; see `/deployment-caching`.
733
765
  - **Dev + HMR**: works, but edits produce stale shells until TTL/recapture.
734
766
  - **Dev cold-start cadence**: expect `MISS -> (in-place retry) -> HIT`. A
735
767
  refused capture is negatively cached with an exponential window (1s doubling
@@ -326,18 +326,25 @@ This only applies when `__PRERENDER_DEV_URL` is set by the plugin.
326
326
 
327
327
  ## Storage Layout
328
328
 
329
- Pre-rendered Flight payloads are stored in the build output:
329
+ Pre-rendered Flight payloads are stored as content-hashed modules behind a lazy
330
+ manifest in the RSC server bundle. They are not public `.rsc` files:
330
331
 
331
332
  ```
332
- dist/static/__<hash>/
333
- prerender/
334
- blog.post/
335
- d4e5f6a7.flight # hash of { slug: "hello-world" }
336
- b8c9d0e1.flight # hash of { slug: "getting-started" }
337
- about/
338
- _.flight # static route, no params
333
+ dist/rsc/
334
+ __prerender-manifest.js
335
+ assets/
336
+ __pr-<content-hash>.js
337
+
338
+ # When a Prerender route also declares ppr:
339
+ __shell-manifest.js
340
+ assets/
341
+ __ps-<content-hash>.js
339
342
  ```
340
343
 
344
+ The worker/function handles every request and reads these modules as build-time
345
+ cache entries. See `/deployment-caching` for how this differs from a platform
346
+ CDN static or prerender output.
347
+
341
348
  ## Concurrency
342
349
 
343
350
  Prerender handlers can specify how many param sets render in parallel:
@@ -110,6 +110,7 @@ stated, greppable contract.
110
110
  | pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
111
111
  | feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
112
112
  | cache the HTML shell, keep loaders live | `ppr` path option | /ppr |
113
+ | choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
113
114
  | stream SSE / upgrade a WebSocket | `path.stream()` / `path.any()` | /streams-and-websockets |
114
115
 
115
116
  ## Invariants
@@ -161,8 +162,8 @@ Same words, different jobs — this is the most common source of the
161
162
  | Next.js `export const revalidate = N` | **Axis 1** (cache) | Same word, opposite meaning. Next's `revalidate` is time-based cache expiry; Rango's `revalidate()` is **axis 2**. Use `cache({ ttl })` for the Next behavior. |
162
163
  | Next.js `revalidateTag` / `updateTag` | **Axis 1** (cache) | Cache busting by tag. Tag via `cache({ tags })` / `cacheTag(...tags)`; invalidate with `updateTag(...tags)` (awaitable, read-your-own-writes) or `revalidateTag(...tags)` (background, non-blocking). Built-in stores index by tag. No `revalidatePath` (path-based busting); use tags. |
163
164
  | React Router / Remix `shouldRevalidate` | **Axis 2** | This is the correct mental model for Rango's `revalidate()`. |
164
- | HTTP `Cache-Control` / ISR | **Axis 1** | Edge/document layer see `/document-cache`. Separate from both `cache()` and `revalidate()`. |
165
- | Next.js PPR (partial prerendering) | HTML shell layer | Same idea, different wiring: the opt-in `ppr` path option captures at runtime (no build-time default); holes are render-defined — `loading()` subtrees plus pending promises under a consumer's own `<Suspense>`. See `/ppr`. |
165
+ | HTTP `Cache-Control` / ISR | Deployment layer | Complete-response deployment layer. A CDN hit bypasses Rango entirely; the store-backed middleware does not. See `/deployment-caching` and `/document-cache`. |
166
+ | Next.js PPR (partial prerendering) | HTML shell layer | Same React primitive, different transport: Rango serves shells in-function after middleware. Ordinary `ppr` captures at runtime; `Prerender + ppr` captures at build. See `/ppr`, `/prerender`, and `/deployment-caching`. |
166
167
  | Remix/RR `loader` | live data | Like Rango loaders, fresh per request — but Rango loaders run in parallel and stream (latency overlaps first paint), and can opt into caching on demand. |
167
168
 
168
169
  See `/cache-guide` for the axis-1 decision guide, `/loader` and `/route` for
@@ -257,17 +258,18 @@ Grouped by concern — read when you need to…
257
258
 
258
259
  **Data & caching** — fetch, mutate, and cache:
259
260
 
260
- | Skill | Description |
261
- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262
- | `/loader` | Data loaders with `createLoader()` and `revalidate()` |
263
- | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
264
- | `/caching` | Segment caching with memory or KV stores |
265
- | `/use-cache` | Function-level caching with `"use cache"` directive |
266
- | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
267
- | `/document-cache` | Edge caching with Cache-Control headers |
268
- | `/ppr` | PPR shell caching: cached shell served instantly, live holes resumed a hole is a `loading()` subtree OR a pending promise under `<Suspense>` (no loader needed) |
269
- | `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
270
- | `/shell-manifest` | Replayed handles as cache metadata read by live loaders (frozen shell, batched live holes) |
261
+ | Skill | Description |
262
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
263
+ | `/loader` | Data loaders with `createLoader()` and `revalidate()` |
264
+ | `/server-actions` | Mutations with `"use server"`, useActionState, validation, revalidation |
265
+ | `/caching` | Segment caching with memory or KV stores |
266
+ | `/use-cache` | Function-level caching with `"use cache"` directive |
267
+ | `/cache-guide` | When to use `cache()` vs `"use cache"` — differences and decision guide |
268
+ | `/document-cache` | Store-backed complete-response middleware using Cache-Control policy |
269
+ | `/deployment-caching` | Choose between in-function caches, store-backed responses, and an external CDN cache |
270
+ | `/ppr` | PPR shell caching: cached shell served instantly, live holes resumed — a hole is a `loading()` subtree OR a pending promise under `<Suspense>` (no loader needed) |
271
+ | `/prerender` | Pre-render route segments at build time (Passthrough live fallback) |
272
+ | `/shell-manifest` | Replayed handles as cache metadata read by live loaders (frozen shell, batched live holes) |
271
273
 
272
274
  **Client & presentation** — build the client-side UX:
273
275
 
@@ -295,10 +297,11 @@ Grouped by concern — read when you need to…
295
297
 
296
298
  **Deployment**:
297
299
 
298
- | Skill | Description |
299
- | ------------- | ---------------------------------------------------------------------------------------------------------------- |
300
- | `/cloudflare` | Deploy to Cloudflare Workers with the Vite plugin, typed D1/KV bindings, migrations, secrets, and preview parity |
301
- | `/vercel` | Deploy to Vercel Functions (`preset: "vercel"`), Runtime Cache, and `createVercelTracing` |
300
+ | Skill | Description |
301
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------- |
302
+ | `/cloudflare` | Deploy to Cloudflare Workers with the Vite plugin, typed D1/KV bindings, migrations, secrets, and preview parity |
303
+ | `/vercel` | Deploy to Vercel Functions (`preset: "vercel"`), Runtime Cache, and `createVercelTracing` |
304
+ | `/deployment-caching` | Compare deployment cache boundaries, middleware execution, PPR transport, and HTTP CDN caching |
302
305
 
303
306
  **Testing**:
304
307
 
@@ -88,7 +88,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
88
88
  | a loader's cookie / header / redirect output (auth-loader pattern) | unit (node) | [`runLoaderResult`](./loader.md) | `@rangojs/router/testing` |
89
89
  | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
90
90
  | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
91
- | a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | `runTransitionWhen` (`{ kept, whenContext }`) | `@rangojs/router/testing` |
91
+ | a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | `runTransitionWhen` (`{ kept, whenContext }`; pass `{ ppr: true }` for pre-handler timing) | `@rangojs/router/testing` |
92
92
  | a handle's `collect`/accumulator, or a seeded handle read | unit | [`collectHandle` / seeded `handles`](./handles.md) | `@rangojs/router/testing` |
93
93
  | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
94
94
  | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
@@ -113,7 +113,7 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
113
113
 
114
114
  `events` accumulates across requests, so the FIRST matching segment for a `routeKey` wins — slice or recreate the sink between requests for the same route.
115
115
 
116
- ## PPR shell (`x-rango-shell`)
116
+ ## PPR shell and navigation replay
117
117
 
118
118
  **DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — shell is a second render axis.
119
119
 
@@ -123,6 +123,9 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
123
123
  | `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
124
124
  | `shellCacheKey(url)` | same | Production shell store key (`host+pathname+sorted search+:shell`) for `store.getShell` / custom stores |
125
125
  | `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
126
+ | `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
127
+ | `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
128
+ | `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
126
129
 
127
130
  ### What unit can prove vs e2e
128
131
 
@@ -131,6 +134,7 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
131
134
  | **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
132
135
  | **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked) |
133
136
  | **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
137
+ | **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
134
138
 
135
139
  `dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve.
136
140
 
@@ -8,6 +8,25 @@ argument-hint:
8
8
 
9
9
  The `vercel` preset builds like the `node` preset (Vercel runs Node Functions, not Workers): rango owns the RSC entry, folds `process.env.NODE_ENV` for the SSR/RSC build, and after `vite build` assembles a `.vercel/output` directory (Build Output API v3) from `dist/` — a single streaming Node Function plus the static client assets.
10
10
 
11
+ ## Deployment boundary
12
+
13
+ Only client JS, CSS, and public assets are emitted under
14
+ `.vercel/output/static`. HTML, Flight, prerender payloads, and PPR shells are
15
+ served from the streaming Node Function. `VercelCacheStore` is an in-function
16
+ Runtime Cache backend; it is separate from Vercel's CDN/ISR cache.
17
+
18
+ The preset does not emit `.prerender-config.json`, a response `chain`, or a
19
+ CDN-stitched PPR resume function. Rango PPR intentionally runs the whole global
20
+ and route middleware chain before committing shell bytes. A CDN-first shell
21
+ cannot preserve that contract because the resume function is invoked after the
22
+ shell starts streaming.
23
+
24
+ For fully public responses, HTTP `s-maxage`/`stale-while-revalidate` can cache
25
+ the completed response at Vercel's CDN and avoid the function on a hit. That is
26
+ whole-response caching: it freezes loader output and bypasses all Rango
27
+ middleware. Use `/deployment-caching` for the execution matrix and safety
28
+ checklist before adding shared-cache headers.
29
+
11
30
  ## Setup
12
31
 
13
32
  ```bash
@@ -48,7 +67,9 @@ rango({
48
67
 
49
68
  ## Runtime Cache
50
69
 
51
- `VercelCacheStore` wraps the Vercel Runtime Cache. Locally (no `process.env.VERCEL`) fall back to an in-memory store so dev/preview work without the platform:
70
+ `VercelCacheStore` wraps the Vercel Runtime Cache for segment, item, response,
71
+ and PPR shell families. Locally (no `process.env.VERCEL`) fall back to an
72
+ in-memory store so dev/preview work without the platform:
52
73
 
53
74
  ```typescript
54
75
  import {