@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Capture data snapshot: recording + seeding stores for PPR shell parity.
3
+ *
4
+ * The scar tissue this fixes: a PPR HIT serves frozen prelude bytes, then a
5
+ * FULL FRESH Flight render for hydration. Any shell-baked (non-hole) content
6
+ * that drifts between capture time and hit time — a cache() segment with a
7
+ * shorter ttl than the shell, a tag-invalidated item — makes the fresh payload
8
+ * disagree with the prelude, so React throws a hydration text mismatch and
9
+ * regenerates the tree client-side (wiping the FOUC theme class, flashing
10
+ * content). See docs/design/ppr-shell-resume.md.
11
+ *
12
+ * The fix (Next.js resume-data-cache analog, adapted to Rango's cache rings):
13
+ * the CAPTURE render records every cache-store read-hit and write it performed
14
+ * (the {@link RecordingShellStore}); the record rides inside the ShellCacheEntry
15
+ * as its `snapshot`; on a HIT the tail render reads through a
16
+ * {@link SeededShellStore} overlay that serves those recorded values AS FRESH,
17
+ * so the shell region reproduces byte-identically while everything NOT recorded
18
+ * (the holes — masked loaders were never executed at capture, so their reads
19
+ * were never recorded) stays live.
20
+ *
21
+ * The invariant, verbatim: the snapshot is exactly the set of cache-store reads
22
+ * the capture render performed; replaying them on a HIT reproduces the shell
23
+ * content byte-identically; everything not recorded stays live.
24
+ */
25
+
26
+ import type {
27
+ SegmentCacheStore,
28
+ CacheGetResult,
29
+ CacheItemResult,
30
+ CacheItemOptions,
31
+ CachedEntryData,
32
+ ShellCacheEntry,
33
+ ShellSnapshotRecord,
34
+ ShellSnapshotItemValue,
35
+ ShellSnapshotResponseValue,
36
+ ShellSnapshotLoaderValue,
37
+ } from "./types.js";
38
+ import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
39
+ import { isPerClientSignalHeader } from "../browser/cookie-name.js";
40
+
41
+ /** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
42
+ function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
43
+ return `${family}\u0000${key}`;
44
+ }
45
+
46
+ /** Serialize a Response to the snapshot's stored shape (base64 body). */
47
+ async function serializeResponse(
48
+ response: Response,
49
+ ): Promise<ShellSnapshotResponseValue> {
50
+ const body = await response.clone().arrayBuffer();
51
+ const headers: [string, string][] = [];
52
+ response.headers.forEach((value, name) => {
53
+ // Mirror putResponse: per-client signal headers never enter a shared entry.
54
+ if (isPerClientSignalHeader(name)) return;
55
+ headers.push([name, value]);
56
+ });
57
+ return { status: response.status, headers, body: bufferToBase64(body) };
58
+ }
59
+
60
+ /** Rebuild a live Response from a snapshot's stored response shape. */
61
+ function deserializeResponse(value: ShellSnapshotResponseValue): Response {
62
+ return new Response(base64ToBuffer(value.body), {
63
+ status: value.status,
64
+ headers: new Headers(value.headers),
65
+ });
66
+ }
67
+
68
+ /**
69
+ * A store wrapper the CAPTURE render reads through. Every call passes through to
70
+ * the underlying store unchanged; for the item/segment/response families it also
71
+ * RECORDS, last-write-wins per (family, key):
72
+ * - read-hits (get/getItem/getResponse returning non-null) — the value that
73
+ * fed the shell,
74
+ * - writes (set/setItem/putResponse) — the value a MISS computed and baked.
75
+ * The shell family (getShell/putShell) is never recorded (the snapshot rides
76
+ * inside a shell entry). Reads that MISS are not recorded (a miss produced no
77
+ * shell content; if the render then computed and wrote, that write is recorded).
78
+ *
79
+ * Deferred writes: cache writes run under waitUntil (fire-and-forget on Node,
80
+ * executionContext on workerd), so their setItem/set calls — hence their records
81
+ * — may land after the shell has quiesced. The capture collects those write
82
+ * promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
83
+ * draining, so a MISS-at-capture value is still pinned.
84
+ */
85
+ export class RecordingShellStore<
86
+ TEnv = unknown,
87
+ > implements SegmentCacheStore<TEnv> {
88
+ private readonly records = new Map<string, ShellSnapshotRecord>();
89
+ private readonly writes: Promise<unknown>[] = [];
90
+
91
+ constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
92
+
93
+ get defaults(): SegmentCacheStore<TEnv>["defaults"] {
94
+ return this.inner.defaults;
95
+ }
96
+ get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
97
+ return this.inner.keyGenerator;
98
+ }
99
+
100
+ private record(
101
+ family: ShellSnapshotRecord["family"],
102
+ key: string,
103
+ value: ShellSnapshotRecord["value"],
104
+ ): void {
105
+ this.records.set(recordKey(family, key), { family, key, value });
106
+ }
107
+
108
+ /** Track a deferred cache-write promise so the capture can await it pre-drain. */
109
+ trackWrite(p: Promise<unknown>): void {
110
+ this.writes.push(p);
111
+ }
112
+
113
+ /**
114
+ * Await the tracked deferred writes so their records are present before drain.
115
+ * Drains ITERATIVELY: a write task can schedule a NESTED write (the ring-3
116
+ * cacheRoute path schedules its actual store.set in a second waitUntil while the
117
+ * first is running), so each awaited batch may enqueue more. Loop until the
118
+ * queue empties or the deadline passes. Bounded: a pathologically slow write
119
+ * must never stall the capture task, so a key that does not settle in time is
120
+ * left unpinned (it drifts, the pre-snapshot behavior) rather than hanging.
121
+ */
122
+ async settleWrites(timeoutMs: number): Promise<void> {
123
+ const deadline = Date.now() + timeoutMs;
124
+ while (this.writes.length > 0) {
125
+ const remaining = deadline - Date.now();
126
+ if (remaining <= 0) return;
127
+ // Take the current batch; new writes scheduled while awaiting accumulate in
128
+ // this.writes and are drained on the next iteration.
129
+ const batch = this.writes.splice(0);
130
+ let timer: ReturnType<typeof setTimeout> | undefined;
131
+ const guard = new Promise<void>((resolve) => {
132
+ timer = setTimeout(resolve, remaining);
133
+ (timer as { unref?: () => void }).unref?.();
134
+ });
135
+ await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
136
+ if (timer) clearTimeout(timer);
137
+ }
138
+ }
139
+
140
+ /** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
141
+ drainSnapshot(): ShellSnapshotRecord[] | undefined {
142
+ return this.records.size > 0 ? [...this.records.values()] : undefined;
143
+ }
144
+
145
+ async get(key: string): Promise<CacheGetResult | null> {
146
+ const result = await this.inner.get(key);
147
+ if (result) this.record("segment", key, result.data);
148
+ return result;
149
+ }
150
+
151
+ async set(
152
+ key: string,
153
+ data: CachedEntryData,
154
+ ttl: number,
155
+ swr?: number,
156
+ ): Promise<void> {
157
+ this.record("segment", key, data);
158
+ return this.inner.set(key, data, ttl, swr);
159
+ }
160
+
161
+ async delete(key: string): Promise<boolean> {
162
+ return this.inner.delete(key);
163
+ }
164
+
165
+ async clear(): Promise<void> {
166
+ return this.inner.clear?.();
167
+ }
168
+
169
+ async getResponse(
170
+ key: string,
171
+ ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
172
+ if (!this.inner.getResponse) return null;
173
+ const result = await this.inner.getResponse(key);
174
+ if (result)
175
+ this.record("response", key, await serializeResponse(result.response));
176
+ return result;
177
+ }
178
+
179
+ async putResponse(
180
+ key: string,
181
+ response: Response,
182
+ ttl: number,
183
+ swr?: number,
184
+ tags?: string[],
185
+ ): Promise<void> {
186
+ if (!this.inner.putResponse) return;
187
+ this.record("response", key, await serializeResponse(response));
188
+ return this.inner.putResponse(key, response, ttl, swr, tags);
189
+ }
190
+
191
+ async getItem(key: string): Promise<CacheItemResult | null> {
192
+ if (!this.inner.getItem) return null;
193
+ const result = await this.inner.getItem(key);
194
+ if (result) {
195
+ const value: ShellSnapshotItemValue = {
196
+ value: result.value,
197
+ handles: result.handles,
198
+ tags: result.tags,
199
+ };
200
+ this.record("item", key, value);
201
+ }
202
+ return result;
203
+ }
204
+
205
+ async setItem(
206
+ key: string,
207
+ value: string,
208
+ options?: CacheItemOptions,
209
+ ): Promise<void> {
210
+ if (!this.inner.setItem) return;
211
+ const stored: ShellSnapshotItemValue = {
212
+ value,
213
+ handles: options?.handles,
214
+ tags: options?.tags,
215
+ };
216
+ this.record("item", key, stored);
217
+ return this.inner.setItem(key, value, options);
218
+ }
219
+
220
+ async getShell(
221
+ key: string,
222
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
223
+ return this.inner.getShell ? this.inner.getShell(key) : null;
224
+ }
225
+
226
+ async putShell(
227
+ key: string,
228
+ entry: ShellCacheEntry,
229
+ ttlSeconds?: number,
230
+ swrSeconds?: number,
231
+ tags?: string[],
232
+ ): Promise<void> {
233
+ return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
234
+ }
235
+
236
+ async invalidateTags(tags: string[]): Promise<void> {
237
+ return this.inner.invalidateTags?.(tags);
238
+ }
239
+ }
240
+
241
+ /** True iff `store` is a RecordingShellStore (duck-typed across module copies). */
242
+ export function getRecordingStore<TEnv>(
243
+ store: SegmentCacheStore<TEnv> | undefined,
244
+ ): RecordingShellStore<TEnv> | undefined {
245
+ return store instanceof RecordingShellStore ? store : undefined;
246
+ }
247
+
248
+ /**
249
+ * Materialize the loader-family seed from a shell snapshot for a HIT's tail
250
+ * render: Flight-deserialize each recorded (promise-elided) bake-lane
251
+ * container into a segment-key -> container Map, which serveShellHit assigns
252
+ * to the tail context's `_shellLoaderSeed` for the resolveLoaderData overlay.
253
+ * Lives here so every snapshot family is decoded in this module (the
254
+ * item/segment/response families via {@link SeededShellStore}); the loader
255
+ * family is not a store read, so it seeds the context instead of a store.
256
+ *
257
+ * Deserializations run in parallel; a record that fails to decode is skipped
258
+ * (that loader drifts — the pre-snapshot behavior — instead of failing the
259
+ * HIT). Returns undefined when the snapshot carries no loader records, without
260
+ * touching the Flight codec (kept lazy for cold paths and non-RSC configs).
261
+ */
262
+ export async function buildShellLoaderSeed(
263
+ snapshot: ShellSnapshotRecord[],
264
+ ): Promise<Map<string, unknown> | undefined> {
265
+ const loaderRecords: ShellSnapshotRecord[] = [];
266
+ for (const rec of snapshot) {
267
+ if (rec.family === "loader") loaderRecords.push(rec);
268
+ }
269
+ if (loaderRecords.length === 0) return undefined;
270
+
271
+ const { deserializeResult } = await import("./segment-codec.js");
272
+ const entries = await Promise.all(
273
+ loaderRecords.map(async (rec): Promise<[string, unknown] | null> => {
274
+ try {
275
+ return [
276
+ rec.key,
277
+ await deserializeResult(
278
+ (rec.value as ShellSnapshotLoaderValue).value,
279
+ ),
280
+ ];
281
+ } catch {
282
+ return null;
283
+ }
284
+ }),
285
+ );
286
+ const seed = new Map<string, unknown>();
287
+ for (const entry of entries) {
288
+ if (entry) seed.set(entry[0], entry[1]);
289
+ }
290
+ return seed.size > 0 ? seed : undefined;
291
+ }
292
+
293
+ /**
294
+ * A read-through overlay the HIT tail render reads through. For a key present in
295
+ * the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
296
+ * a pinned key must NOT kick SWR background revalidation) so the tail's payload
297
+ * matches the frozen prelude. Every other read falls through to the real store
298
+ * (the holes — masked loaders were never recorded — stay live). ALL writes pass
299
+ * through unchanged: a live hole's loader may legitimately write. The shell
300
+ * family always passes through.
301
+ */
302
+ export class SeededShellStore<
303
+ TEnv = unknown,
304
+ > implements SegmentCacheStore<TEnv> {
305
+ private readonly items = new Map<string, ShellSnapshotItemValue>();
306
+ private readonly segments = new Map<string, CachedEntryData>();
307
+ private readonly responses = new Map<string, ShellSnapshotResponseValue>();
308
+
309
+ constructor(
310
+ private readonly inner: SegmentCacheStore<TEnv>,
311
+ snapshot: ShellSnapshotRecord[],
312
+ ) {
313
+ for (const rec of snapshot) {
314
+ if (rec.family === "item") {
315
+ this.items.set(rec.key, rec.value as ShellSnapshotItemValue);
316
+ } else if (rec.family === "segment") {
317
+ this.segments.set(rec.key, rec.value as CachedEntryData);
318
+ } else if (rec.family === "response") {
319
+ this.responses.set(rec.key, rec.value as ShellSnapshotResponseValue);
320
+ }
321
+ // "loader" family records are not store reads — serveShellHit seeds them
322
+ // onto the tail context (_shellLoaderSeed) for the resolveLoaderData
323
+ // overlay instead.
324
+ }
325
+ }
326
+
327
+ get defaults(): SegmentCacheStore<TEnv>["defaults"] {
328
+ return this.inner.defaults;
329
+ }
330
+ get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
331
+ return this.inner.keyGenerator;
332
+ }
333
+
334
+ async get(key: string): Promise<CacheGetResult | null> {
335
+ const seeded = this.segments.get(key);
336
+ if (seeded) return { data: seeded, shouldRevalidate: false };
337
+ return this.inner.get(key);
338
+ }
339
+
340
+ async set(
341
+ key: string,
342
+ data: CachedEntryData,
343
+ ttl: number,
344
+ swr?: number,
345
+ ): Promise<void> {
346
+ return this.inner.set(key, data, ttl, swr);
347
+ }
348
+
349
+ async delete(key: string): Promise<boolean> {
350
+ return this.inner.delete(key);
351
+ }
352
+
353
+ async clear(): Promise<void> {
354
+ return this.inner.clear?.();
355
+ }
356
+
357
+ async getResponse(
358
+ key: string,
359
+ ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
360
+ const seeded = this.responses.get(key);
361
+ if (seeded) {
362
+ return { response: deserializeResponse(seeded), shouldRevalidate: false };
363
+ }
364
+ return this.inner.getResponse ? this.inner.getResponse(key) : null;
365
+ }
366
+
367
+ async putResponse(
368
+ key: string,
369
+ response: Response,
370
+ ttl: number,
371
+ swr?: number,
372
+ tags?: string[],
373
+ ): Promise<void> {
374
+ return this.inner.putResponse?.(key, response, ttl, swr, tags);
375
+ }
376
+
377
+ async getItem(key: string): Promise<CacheItemResult | null> {
378
+ const seeded = this.items.get(key);
379
+ if (seeded) {
380
+ return {
381
+ value: seeded.value,
382
+ handles: seeded.handles,
383
+ tags: seeded.tags,
384
+ shouldRevalidate: false,
385
+ };
386
+ }
387
+ return this.inner.getItem ? this.inner.getItem(key) : null;
388
+ }
389
+
390
+ async setItem(
391
+ key: string,
392
+ value: string,
393
+ options?: CacheItemOptions,
394
+ ): Promise<void> {
395
+ return this.inner.setItem?.(key, value, options);
396
+ }
397
+
398
+ async getShell(
399
+ key: string,
400
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
401
+ return this.inner.getShell ? this.inner.getShell(key) : null;
402
+ }
403
+
404
+ async putShell(
405
+ key: string,
406
+ entry: ShellCacheEntry,
407
+ ttlSeconds?: number,
408
+ swrSeconds?: number,
409
+ tags?: string[],
410
+ ): Promise<void> {
411
+ return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
412
+ }
413
+
414
+ async invalidateTags(tags: string[]): Promise<void> {
415
+ return this.inner.invalidateTags?.(tags);
416
+ }
417
+ }
@@ -136,6 +136,39 @@ export interface SegmentCacheStore<TEnv = unknown> {
136
136
  tags?: string[],
137
137
  ): Promise<void>;
138
138
 
139
+ /**
140
+ * Get a cached PPR shell entry by key.
141
+ * Returns the stored prelude/postponed pair (see ShellCacheEntry) and whether
142
+ * it should be revalidated (SWR). Used by the shell-cache middleware to serve
143
+ * a cached HTML shell and resume fizz for just the live holes.
144
+ *
145
+ * Optional: a store that does not implement the shell family disables the
146
+ * shell-cache middleware (it fails open to the normal HTML render path).
147
+ */
148
+ getShell?(
149
+ key: string,
150
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null>;
151
+
152
+ /**
153
+ * Store a PPR shell entry with TTL and optional SWR window.
154
+ * The prelude bytes and postponed state are version- and generation-coupled
155
+ * and travel together in a single entry (they must never mix across a React
156
+ * upgrade — the reactVersion field on the entry gates that at read time).
157
+ * @param key - Cache key
158
+ * @param entry - The shell prelude/postponed/version/createdAt bundle
159
+ * @param ttlSeconds - Time-to-live in seconds
160
+ * @param swrSeconds - Optional stale-while-revalidate window in seconds
161
+ * @param tags - Optional cache tags for invalidation (participates in
162
+ * invalidateTags via the same tag machinery as the item family)
163
+ */
164
+ putShell?(
165
+ key: string,
166
+ entry: ShellCacheEntry,
167
+ ttlSeconds?: number,
168
+ swrSeconds?: number,
169
+ tags?: string[],
170
+ ): Promise<void>;
171
+
139
172
  /**
140
173
  * Get a cached function result by key.
141
174
  * Returns the serialized value, optional handle data, and staleness flag.
@@ -186,6 +219,131 @@ export interface CacheItemResult {
186
219
  tags?: string[];
187
220
  }
188
221
 
222
+ /**
223
+ * A cached PPR (Partial Pre-rendering) shell entry.
224
+ *
225
+ * One entry carries BOTH artifacts a resume needs — the rendered HTML prelude
226
+ * and React's postponed state — because the pair is version- and
227
+ * generation-coupled and must never be mixed across a React upgrade or a build
228
+ * change. The reactVersion and buildVersion fields are the read-time gates that
229
+ * enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
230
+ * whose reactVersion differs from the running React, or whose buildVersion
231
+ * differs from the running build, as a miss (the postponed blob encodes hole
232
+ * positions against one exact tree; resuming it against a different React or a
233
+ * different app build tree-mismatches inside resume(), AFTER the 200 + prelude
234
+ * are committed — an unrecoverable broken serve).
235
+ */
236
+ export interface ShellCacheEntry {
237
+ /** Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing). */
238
+ prelude: string;
239
+ /**
240
+ * JSON.stringify of React's postponed state, or null when the shell settled
241
+ * with no holes (the DATA variant — served without a fizz resume).
242
+ */
243
+ postponed: string | null;
244
+ /** React.version captured at prerender time; the read-time invalidation gate. */
245
+ reactVersion: string;
246
+ /**
247
+ * Build version captured at prerender time (the RSC handler's `version` —
248
+ * the `@rangojs/router:version` build stamp by default, bumped per build and
249
+ * on dev RSC-module edits). The second read-time gate: a persistent shared
250
+ * store (KV/runtime-cache) survives deploys, and an app-code change that
251
+ * keeps the same React version would otherwise leave a stale-build
252
+ * prelude+postponed live under the same key. Optional only for entries
253
+ * stored before the field existed — those are treated as a miss and the
254
+ * recapture re-stamps them (pre-release, no compat shim).
255
+ */
256
+ buildVersion?: string;
257
+ /**
258
+ * The initialTheme the CAPTURE render was built with (the derived context's
259
+ * reqCtx.theme). The resume tail must render ThemeProvider with the SAME
260
+ * initialTheme the frozen prelude was rendered with: React resume requires the
261
+ * tree above the holes to match the prerendered tree, and initialTheme is
262
+ * per-request METADATA, not part of the cached segments — a visitor whose
263
+ * theme differs from the capturer's would otherwise produce a divergent resume
264
+ * tree (broken stitching/hydration). The visitor's real theme is applied
265
+ * pre-paint by the FOUC script and re-synced from the cookie post-mount by
266
+ * ThemeProvider.
267
+ */
268
+ initialTheme?: string;
269
+ /**
270
+ * The CAPTURE DATA SNAPSHOT: every cache-store read-hit and write the capture
271
+ * render performed, in stored/serialized form. Replaying these on a HIT (via
272
+ * the SeededShellStore overlay, for the tail render only) reproduces the
273
+ * shell's cached content byte-identically, so the freshly rendered hydration
274
+ * payload matches the frozen prelude even after the underlying cache entries
275
+ * have drifted (expired, been recomputed, or been tag-invalidated).
276
+ *
277
+ * Optional: an entry captured before this field existed simply has no
278
+ * snapshot and keeps the pre-snapshot behavior (the tail reads live, so any
279
+ * shell-baked cached value that drifted mismatches the prelude). Recapture
280
+ * heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
281
+ */
282
+ snapshot?: ShellSnapshotRecord[];
283
+ /** Epoch ms when the shell was captured. */
284
+ createdAt: number;
285
+ }
286
+
287
+ /**
288
+ * The families a shell snapshot pins. The item/segment/response families are
289
+ * cache-store reads/writes (recorded by RecordingShellStore); the loader family
290
+ * pins the settled CONTAINER of a bake-lane loader (a loader on an entry with
291
+ * no renderable loading(), executed during capture — see
292
+ * docs/design/loader-container-bake.md). Excludes the shell family itself
293
+ * (getShell/putShell) — the snapshot rides INSIDE a shell entry, so recording
294
+ * it would be self-referential.
295
+ */
296
+ export type ShellSnapshotFamily = "item" | "segment" | "response" | "loader";
297
+
298
+ /**
299
+ * The stored form of a loader-family snapshot value: the bake-lane loader's
300
+ * settled container, Flight-serialized AFTER eliding every still-pending nested
301
+ * promise to a hole marker (the marker paths are holes, not shell material; on
302
+ * a HIT the overlay re-slots the fresh run's promises there). Flight (not JSON)
303
+ * so typed values (Date/Map) survive the round trip.
304
+ */
305
+ export interface ShellSnapshotLoaderValue {
306
+ /** RSC-serialized elided container (see loader-snapshot.ts). */
307
+ value: string;
308
+ }
309
+
310
+ /** A serialized cached Response for the response family of a shell snapshot. */
311
+ export interface ShellSnapshotResponseValue {
312
+ status: number;
313
+ /** Client-facing header pairs (per-client signal headers excluded at record). */
314
+ headers: [string, string][];
315
+ /** base64-encoded response body (binary-safe, JSON-serializable). */
316
+ body: string;
317
+ }
318
+
319
+ /** The stored form of an item-family (use cache / loader cache) snapshot value. */
320
+ export interface ShellSnapshotItemValue {
321
+ /** RSC-serialized return value. */
322
+ value: string;
323
+ /** RSC-encoded handle data, if any. */
324
+ handles?: string;
325
+ /** The entry's cache tags. */
326
+ tags?: string[];
327
+ }
328
+
329
+ /**
330
+ * One recorded cache-store read-hit or write from the capture render. `value`
331
+ * carries the entry in its stored/serialized shape so it round-trips through a
332
+ * JSON-serializing store (KV, CF, Vercel) with the rest of the ShellCacheEntry:
333
+ * - `item` -> {@link ShellSnapshotItemValue}
334
+ * - `segment` -> {@link CachedEntryData} (already JSON-able)
335
+ * - `response`-> {@link ShellSnapshotResponseValue}
336
+ */
337
+ export interface ShellSnapshotRecord {
338
+ family: ShellSnapshotFamily;
339
+ key: string;
340
+ value:
341
+ | ShellSnapshotItemValue
342
+ | CachedEntryData
343
+ | ShellSnapshotResponseValue
344
+ | ShellSnapshotLoaderValue;
345
+ }
346
+
189
347
  /**
190
348
  * Options for setItem() for function-level caching ("use cache").
191
349
  */