@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
@@ -38,6 +38,8 @@ import type {
38
38
  CacheGetResult,
39
39
  CacheItemResult,
40
40
  CacheItemOptions,
41
+ ShellCacheEntry,
42
+ ShellSnapshotRecord,
41
43
  } from "../types.js";
42
44
  import type { RequestContext } from "../../server/request-context.js";
43
45
  import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
@@ -48,6 +50,16 @@ import {
48
50
  DEFAULT_FUNCTION_TTL,
49
51
  } from "../cache-policy.js";
50
52
  import { reportCacheError, reportingAsync } from "../cache-error.js";
53
+ import type { CacheErrorCategory } from "../cache-error.js";
54
+ // Reuse the CF store's binary-safe base64 helpers. bufferToBase64 caps each
55
+ // String.fromCharCode batch at 8192 and uses .apply (never a spread), so a large
56
+ // Response/PPR-shell body cannot blow the JS argument-count ceiling (~65k) and
57
+ // throw RangeError inside putResponse/putShell - which the outer try/catch would
58
+ // swallow as a cache-write degrade, silently never caching the entry. Output is
59
+ // byte-identical to a per-byte encoder (chunk size does not affect base64), so
60
+ // this is a robustness fix, not a format change. Do NOT reintroduce a local
61
+ // spread-based encoder or raise the chunk here; cf-base64.ts is import-pure.
62
+ import { bufferToBase64, base64ToBuffer } from "../cf/cf-base64.js";
51
63
 
52
64
  /**
53
65
  * Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
@@ -89,6 +101,19 @@ export const VERCEL_MAX_TAGS_PER_ITEM: number = 128;
89
101
  /** Max tag length in UTF-8 bytes accepted by Vercel; longer tags are skipped. */
90
102
  export const VERCEL_MAX_TAG_BYTES: number = 256;
91
103
 
104
+ /**
105
+ * URL metacharacters a tag must not contain. The official `@vercel/functions`
106
+ * client interpolates the tag straight into `revalidate?tags=${tag}` with NO
107
+ * URL-encoding (and also sends it in a request header), so a tag carrying one of
108
+ * these is stored intact on write but silently mangled server-side on
109
+ * invalidate: `&` starts a new query param, `?` a query, `#` truncates as a
110
+ * fragment, `%` is read as a (bad) percent-escape, `,` splits the tag list. The
111
+ * entry then never clears while `updateTag()`/`expireTag()` report success -
112
+ * the exact false-success the store's one deliberate throw exists to prevent.
113
+ * Such tags are dropped symmetrically on both the write and invalidate paths.
114
+ */
115
+ const VERCEL_UNSAFE_TAG_CHARS = /[,&#%?]/;
116
+
92
117
  /**
93
118
  * Herd-dampening window (ms). On a stale read the store pushes the entry's
94
119
  * staleAt forward by this much and re-writes it, so other readers in the same
@@ -99,10 +124,10 @@ export const VERCEL_MAX_TAG_BYTES: number = 256;
99
124
  */
100
125
  const REVALIDATION_LOCK_MS = 30_000;
101
126
 
102
- /** Family prefixes that keep the three value tiers from colliding in the single
103
- * Vercel keyspace. The router's own semantic prefixes (doc:/partial:/use-cache:)
104
- * become the suffix; `rg:` namespaces every Rango entry. */
105
- type CacheFamily = "s" | "i" | "r";
127
+ /** Family prefixes that keep the value tiers from colliding in the single Vercel
128
+ * keyspace. The router's own semantic prefixes (doc:/partial:/use-cache:) become
129
+ * the suffix; `rg:` namespaces every Rango entry. `h` is the PPR shell tier. */
130
+ type CacheFamily = "s" | "i" | "r" | "h";
106
131
 
107
132
  /** Stored envelope for a segment-tree entry (get/set). */
108
133
  interface VercelSegmentEnvelope {
@@ -140,10 +165,34 @@ interface VercelResponseEnvelope {
140
165
  s: number;
141
166
  /** expiresAt (ms since epoch). */
142
167
  e: number;
143
- /** Tags, preserved so a stale re-stamp keeps them. */
168
+ /** Tags, preserved so a background revalidation re-write keeps them. */
144
169
  t?: string[];
145
170
  }
146
171
 
172
+ /** Stored envelope for a PPR shell entry (getShell/putShell). */
173
+ interface VercelShellEnvelope {
174
+ /** base64-encoded prelude bytes. */
175
+ p: string;
176
+ /** postponed state JSON, or null (DATA variant). */
177
+ po: string | null;
178
+ /** React.version at capture. */
179
+ rv: string;
180
+ /** Build version at capture (ShellCacheEntry.buildVersion). */
181
+ bv?: string;
182
+ /** createdAt (ms since epoch). */
183
+ c: number;
184
+ /** staleAt (ms since epoch). */
185
+ s: number;
186
+ /** expiresAt (ms since epoch). */
187
+ e: number;
188
+ /** Tags, preserved so a background revalidation re-write keeps them. */
189
+ t?: string[];
190
+ /** initialTheme the capture render was built with (resume theme fidelity). */
191
+ i?: string;
192
+ /** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
193
+ sn?: ShellSnapshotRecord[];
194
+ }
195
+
147
196
  /** Read-path outcome for the debug sink. */
148
197
  export type VercelCacheReadOutcome =
149
198
  | "miss"
@@ -155,7 +204,7 @@ export type VercelCacheReadOutcome =
155
204
 
156
205
  /** Diagnostic event emitted on every read when `debug` is set. */
157
206
  export interface VercelCacheReadDebugEvent {
158
- op: "get" | "getItem" | "getResponse";
207
+ op: "get" | "getItem" | "getResponse" | "getShell";
159
208
  key: string;
160
209
  outcome: VercelCacheReadOutcome;
161
210
  staleAt?: number;
@@ -188,9 +237,9 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
188
237
  cache: VercelRuntimeCache;
189
238
 
190
239
  /**
191
- * `waitUntil` from `@vercel/functions`. Used only to run the stale-read
192
- * re-stamp (herd dampening) off the response path - the router already
193
- * backgrounds the actual writes. When omitted, the re-stamp runs detached
240
+ * `waitUntil` from `@vercel/functions`. Used only to run the stale-read lock
241
+ * write (herd dampening) off the response path - the router already
242
+ * backgrounds the actual writes. When omitted, the lock write runs detached
194
243
  * (fire-and-forget) instead.
195
244
  */
196
245
  waitUntil?: (promise: Promise<unknown>) => void;
@@ -239,25 +288,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
239
288
  return typeof value === "object" && value !== null;
240
289
  }
241
290
 
242
- /** Encode binary body bytes to base64 in chunks (avoids call-stack blowups). */
243
- function bufferToBase64(buffer: ArrayBuffer): string {
244
- const bytes = new Uint8Array(buffer);
245
- let binary = "";
246
- const CHUNK = 0x8000;
247
- for (let i = 0; i < bytes.length; i += CHUNK) {
248
- binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
249
- }
250
- return btoa(binary);
251
- }
252
-
253
- /** Decode a base64 body back into bytes. */
254
- function base64ToBuffer(b64: string): ArrayBuffer {
255
- const binary = atob(b64);
256
- const bytes = new Uint8Array(binary.length);
257
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
258
- return bytes.buffer;
259
- }
260
-
261
291
  /**
262
292
  * Vercel Runtime Cache-backed segment cache store.
263
293
  *
@@ -276,6 +306,16 @@ function base64ToBuffer(b64: string): ArrayBuffer {
276
306
  * that propagates expireTag rejections does get the strict guarantee, which is
277
307
  * what the unit suite exercises.)
278
308
  *
309
+ * Key hashing / family isolation. The store namespaces its three value tiers as
310
+ * `rg:{s|i|r}:{key}` so segment, `"use cache"`, and response entries occupy
311
+ * disjoint keyspaces. That separation is only as strong as the cache's
312
+ * `keyHashFunction`: `getCache`'s default is djb2, which folds every key to 32
313
+ * bits (8 hex chars), so at a large live-key count a collision can let one
314
+ * entry's body be served for another key (same-family) or be misread as corrupt
315
+ * (cross-family). For deployments with many cached keys, pass a wider hash so
316
+ * the family split is real isolation, e.g. `getCache({ keyHashFunction })` with
317
+ * a sha256-based function.
318
+ *
279
319
  * @example
280
320
  * ```ts
281
321
  * import { getCache, waitUntil } from "@vercel/functions";
@@ -284,7 +324,9 @@ function base64ToBuffer(b64: string): ArrayBuffer {
284
324
  * export const router = createRouter({
285
325
  * cache: () => ({
286
326
  * store: new VercelCacheStore({
287
- * cache: getCache({ namespace: import.meta.env.VERCEL_DEPLOYMENT_ID }),
327
+ * // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
328
+ * // would be undefined here) so cross-deploy busting via `version` works.
329
+ * cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
288
330
  * waitUntil,
289
331
  * defaults: { ttl: 60, swr: 300 },
290
332
  * }),
@@ -343,7 +385,7 @@ export class VercelCacheStore<
343
385
  this.emitDebug({ op: "get", key, outcome: "miss", readMs });
344
386
  return null;
345
387
  }
346
- const env = this.asSegmentEnvelope(raw);
388
+ const env = this.asSegmentEnvelope(this.decodeRaw(raw));
347
389
  if (!env) {
348
390
  reportCacheError(
349
391
  new Error("malformed segment envelope"),
@@ -370,35 +412,19 @@ export class VercelCacheStore<
370
412
  }
371
413
 
372
414
  const isStale = env.s > 0 && now > env.s;
373
- if (isStale) {
374
- this.markRevalidating(
375
- storeKey,
376
- env,
377
- env.d.tags,
378
- "[VercelCacheStore] get",
379
- );
380
- this.emitDebug({
381
- op: "get",
382
- key,
383
- outcome: "stale-revalidate",
384
- shouldRevalidate: true,
385
- staleAt: env.s,
386
- expiresAt: env.e,
387
- readMs,
388
- });
389
- return { data: env.d, shouldRevalidate: true };
390
- }
391
-
415
+ const shouldRevalidate = isStale
416
+ ? await this.claimRevalidation(storeKey, env.e, "[VercelCacheStore] get")
417
+ : false;
392
418
  this.emitDebug({
393
419
  op: "get",
394
420
  key,
395
- outcome: "fresh",
396
- shouldRevalidate: false,
421
+ outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
422
+ shouldRevalidate,
397
423
  staleAt: env.s,
398
424
  expiresAt: env.e,
399
425
  readMs,
400
426
  });
401
- return { data: env.d, shouldRevalidate: false };
427
+ return { data: env.d, shouldRevalidate };
402
428
  }
403
429
 
404
430
  async set(
@@ -410,12 +436,22 @@ export class VercelCacheStore<
410
436
  try {
411
437
  const swrWindow = resolveSwrWindow(swr, this.defaults);
412
438
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
413
- const env: VercelSegmentEnvelope = { d: data, s: staleAt, e: expiresAt };
439
+ // Embed the CLAMPED tags, like putResponse/setItem: the segment family
440
+ // carries its tags inside `d`, and a raw list would resurface dropped
441
+ // tags into recordRequestTags on every hit.
442
+ const safeTags = this.clampTagsForWrite(
443
+ data.tags,
444
+ "[VercelCacheStore] set",
445
+ );
446
+ const d: CachedEntryData = data.tags
447
+ ? { ...data, tags: safeTags.length > 0 ? safeTags : undefined }
448
+ : data;
449
+ const env: VercelSegmentEnvelope = { d, s: staleAt, e: expiresAt };
414
450
  await this.write(
415
451
  this.toStoreKey(key, "s"),
416
452
  env,
417
453
  ttl + swrWindow,
418
- data.tags,
454
+ safeTags,
419
455
  "[VercelCacheStore] set",
420
456
  );
421
457
  } catch (error) {
@@ -456,7 +492,7 @@ export class VercelCacheStore<
456
492
  this.emitDebug({ op: "getResponse", key, outcome: "miss", readMs });
457
493
  return null;
458
494
  }
459
- const env = this.asResponseEnvelope(raw);
495
+ const env = this.asResponseEnvelope(this.decodeRaw(raw));
460
496
  if (!env) {
461
497
  reportCacheError(
462
498
  new Error("malformed response envelope"),
@@ -482,11 +518,11 @@ export class VercelCacheStore<
482
518
  return null;
483
519
  }
484
520
 
485
- // Reconstruct the Response BEFORE marking revalidating. A corrupt body (e.g.
486
- // invalid base64 from base64ToBuffer, or bad header entries) would otherwise
487
- // throw out of getResponse — breaking the fail-open contract — and a stale
488
- // re-stamp would re-persist the bad value. Treat a reconstruction failure as
489
- // a corrupt entry: report, evict, and miss.
521
+ // Reconstruct the Response BEFORE claiming revalidation. A corrupt body
522
+ // (e.g. invalid base64 from base64ToBuffer, or bad header entries) would
523
+ // otherwise throw out of getResponse — breaking the fail-open contract — and
524
+ // a stale read would have written a lock for an entry it then evicts. Treat a
525
+ // reconstruction failure as a corrupt entry: report, evict, and miss.
490
526
  let response: Response;
491
527
  try {
492
528
  response = new Response(base64ToBuffer(env.b), {
@@ -505,24 +541,23 @@ export class VercelCacheStore<
505
541
  }
506
542
 
507
543
  const isStale = env.s > 0 && now > env.s;
508
- if (isStale) {
509
- this.markRevalidating(
510
- storeKey,
511
- env,
512
- env.t,
513
- "[VercelCacheStore] getResponse",
514
- );
515
- }
544
+ const shouldRevalidate = isStale
545
+ ? await this.claimRevalidation(
546
+ storeKey,
547
+ env.e,
548
+ "[VercelCacheStore] getResponse",
549
+ )
550
+ : false;
516
551
  this.emitDebug({
517
552
  op: "getResponse",
518
553
  key,
519
- outcome: isStale ? "stale-revalidate" : "fresh",
520
- shouldRevalidate: isStale,
554
+ outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
555
+ shouldRevalidate,
521
556
  staleAt: env.s,
522
557
  expiresAt: env.e,
523
558
  readMs,
524
559
  });
525
- return { response, shouldRevalidate: isStale };
560
+ return { response, shouldRevalidate };
526
561
  }
527
562
 
528
563
  async putResponse(
@@ -541,19 +576,28 @@ export class VercelCacheStore<
541
576
  });
542
577
  const swrWindow = resolveSwrWindow(swr, this.defaults);
543
578
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
579
+ // Embed the CLAMPED tags in the envelope, not the raw list: tags dropped
580
+ // on write (unsafe/over-cap) would otherwise resurface on a hit and be
581
+ // merged into an upstream document's tag set, letting it claim a tag this
582
+ // entry can't actually be invalidated by. write() re-clamps (idempotent
583
+ // and silent on an already-clean list).
584
+ const safeTags = this.clampTagsForWrite(
585
+ tags,
586
+ "[VercelCacheStore] putResponse",
587
+ );
544
588
  const env: VercelResponseEnvelope = {
545
589
  b: bufferToBase64(body),
546
590
  st: response.status,
547
591
  hd: headers,
548
592
  s: staleAt,
549
593
  e: expiresAt,
550
- t: tags,
594
+ t: safeTags.length > 0 ? safeTags : undefined,
551
595
  };
552
596
  await this.write(
553
597
  this.toStoreKey(key, "r"),
554
598
  env,
555
599
  ttl + swrWindow,
556
- tags,
600
+ safeTags,
557
601
  "[VercelCacheStore] putResponse",
558
602
  );
559
603
  } catch (error) {
@@ -580,7 +624,7 @@ export class VercelCacheStore<
580
624
  this.emitDebug({ op: "getItem", key, outcome: "miss", readMs });
581
625
  return null;
582
626
  }
583
- const env = this.asItemEnvelope(raw);
627
+ const env = this.asItemEnvelope(this.decodeRaw(raw));
584
628
  if (!env) {
585
629
  reportCacheError(
586
630
  new Error("malformed item envelope"),
@@ -595,19 +639,30 @@ export class VercelCacheStore<
595
639
  const now = Date.now();
596
640
  if (now > env.e) {
597
641
  void this.safeDelete(storeKey);
598
- this.emitDebug({ op: "getItem", key, outcome: "expired", readMs });
642
+ this.emitDebug({
643
+ op: "getItem",
644
+ key,
645
+ outcome: "expired",
646
+ staleAt: env.s,
647
+ expiresAt: env.e,
648
+ readMs,
649
+ });
599
650
  return null;
600
651
  }
601
652
 
602
653
  const isStale = env.s > 0 && now > env.s;
603
- if (isStale) {
604
- this.markRevalidating(storeKey, env, env.t, "[VercelCacheStore] getItem");
605
- }
654
+ const shouldRevalidate = isStale
655
+ ? await this.claimRevalidation(
656
+ storeKey,
657
+ env.e,
658
+ "[VercelCacheStore] getItem",
659
+ )
660
+ : false;
606
661
  this.emitDebug({
607
662
  op: "getItem",
608
663
  key,
609
- outcome: isStale ? "stale-revalidate" : "fresh",
610
- shouldRevalidate: isStale,
664
+ outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
665
+ shouldRevalidate,
611
666
  staleAt: env.s,
612
667
  expiresAt: env.e,
613
668
  readMs,
@@ -615,7 +670,7 @@ export class VercelCacheStore<
615
670
  return {
616
671
  value: env.v,
617
672
  handles: env.h,
618
- shouldRevalidate: isStale,
673
+ shouldRevalidate,
619
674
  tags: env.t,
620
675
  };
621
676
  }
@@ -629,18 +684,24 @@ export class VercelCacheStore<
629
684
  const ttl = resolveTtl(options?.ttl, this.defaults, DEFAULT_FUNCTION_TTL);
630
685
  const swrWindow = resolveSwrWindow(options?.swr, this.defaults);
631
686
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
687
+ // Store the clamped tags (see putResponse) so dropped tags don't resurface
688
+ // on a hit; write() re-clamps idempotently.
689
+ const safeTags = this.clampTagsForWrite(
690
+ options?.tags,
691
+ "[VercelCacheStore] setItem",
692
+ );
632
693
  const env: VercelItemEnvelope = {
633
694
  v: value,
634
695
  h: options?.handles,
635
696
  s: staleAt,
636
697
  e: expiresAt,
637
- t: options?.tags,
698
+ t: safeTags.length > 0 ? safeTags : undefined,
638
699
  };
639
700
  await this.write(
640
701
  this.toStoreKey(key, "i"),
641
702
  env,
642
703
  ttl + swrWindow,
643
- options?.tags,
704
+ safeTags,
644
705
  "[VercelCacheStore] setItem",
645
706
  );
646
707
  } catch (error) {
@@ -648,12 +709,138 @@ export class VercelCacheStore<
648
709
  }
649
710
  }
650
711
 
712
+ // --- Shell family (PPR shell resume - getShell/putShell) ---
713
+
714
+ async getShell(
715
+ key: string,
716
+ ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
717
+ const storeKey = this.toStoreKey(key, "h");
718
+ const started = Date.now();
719
+ let raw: unknown;
720
+ try {
721
+ raw = await this.cache.get(storeKey);
722
+ } catch (error) {
723
+ reportCacheError(error, "cache-read", "[VercelCacheStore] getShell");
724
+ this.emitDebug({ op: "getShell", key, outcome: "error" });
725
+ return null;
726
+ }
727
+ const readMs = Date.now() - started;
728
+
729
+ if (raw == null) {
730
+ this.emitDebug({ op: "getShell", key, outcome: "miss", readMs });
731
+ return null;
732
+ }
733
+ const env = this.asShellEnvelope(this.decodeRaw(raw));
734
+ if (!env) {
735
+ reportCacheError(
736
+ new Error("malformed shell envelope"),
737
+ "cache-corrupt",
738
+ "[VercelCacheStore] getShell",
739
+ );
740
+ void this.safeDelete(storeKey);
741
+ this.emitDebug({ op: "getShell", key, outcome: "corrupt", readMs });
742
+ return null;
743
+ }
744
+
745
+ const now = Date.now();
746
+ if (now > env.e) {
747
+ void this.safeDelete(storeKey);
748
+ this.emitDebug({
749
+ op: "getShell",
750
+ key,
751
+ outcome: "expired",
752
+ staleAt: env.s,
753
+ expiresAt: env.e,
754
+ readMs,
755
+ });
756
+ return null;
757
+ }
758
+
759
+ const isStale = env.s > 0 && now > env.s;
760
+ const shouldRevalidate = isStale
761
+ ? await this.claimRevalidation(
762
+ storeKey,
763
+ env.e,
764
+ "[VercelCacheStore] getShell",
765
+ )
766
+ : false;
767
+ this.emitDebug({
768
+ op: "getShell",
769
+ key,
770
+ outcome: shouldRevalidate ? "stale-revalidate" : "fresh",
771
+ shouldRevalidate,
772
+ staleAt: env.s,
773
+ expiresAt: env.e,
774
+ readMs,
775
+ });
776
+ return {
777
+ entry: {
778
+ prelude: env.p,
779
+ postponed: env.po,
780
+ reactVersion: env.rv,
781
+ buildVersion: env.bv,
782
+ initialTheme: env.i,
783
+ snapshot: env.sn,
784
+ createdAt: env.c,
785
+ },
786
+ shouldRevalidate,
787
+ };
788
+ }
789
+
790
+ async putShell(
791
+ key: string,
792
+ entry: ShellCacheEntry,
793
+ ttlSeconds?: number,
794
+ swrSeconds?: number,
795
+ tags?: string[],
796
+ ): Promise<void> {
797
+ try {
798
+ const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
799
+ const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
800
+ const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
801
+ // Store the CLAMPED tags (see putResponse/setItem) so dropped tags don't
802
+ // resurface on a hit; write() re-clamps idempotently.
803
+ const safeTags = this.clampTagsForWrite(
804
+ tags,
805
+ "[VercelCacheStore] putShell",
806
+ );
807
+ const env: VercelShellEnvelope = {
808
+ p: entry.prelude,
809
+ po: entry.postponed,
810
+ rv: entry.reactVersion,
811
+ bv: entry.buildVersion,
812
+ c: entry.createdAt,
813
+ s: staleAt,
814
+ e: expiresAt,
815
+ t: safeTags.length > 0 ? safeTags : undefined,
816
+ i: entry.initialTheme,
817
+ sn: entry.snapshot,
818
+ };
819
+ // write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
820
+ // oversized shell prelude is reported and skipped (fail-open to a full
821
+ // render), never silently no-op'd on the platform.
822
+ await this.write(
823
+ this.toStoreKey(key, "h"),
824
+ env,
825
+ ttl + swrWindow,
826
+ safeTags,
827
+ "[VercelCacheStore] putShell",
828
+ );
829
+ } catch (error) {
830
+ reportCacheError(error, "cache-write", "[VercelCacheStore] putShell");
831
+ }
832
+ }
833
+
651
834
  // --- Tags ---
652
835
 
653
836
  async invalidateTags(tags: string[]): Promise<void> {
654
837
  if (!tags || tags.length === 0) return;
655
838
  // No per-item cap here: an invalidation must reach every requested tag.
656
- const safe = this.validateTags(tags, "[VercelCacheStore] invalidateTags");
839
+ const safe = this.validateTags(
840
+ tags,
841
+ "[VercelCacheStore] invalidateTags",
842
+ "cache-invalidate",
843
+ );
657
844
  if (safe.length === 0) return;
658
845
  try {
659
846
  await this.cache.expireTag(safe);
@@ -675,6 +862,12 @@ export class VercelCacheStore<
675
862
 
676
863
  // --- Internals ---
677
864
 
865
+ // The `rg:{family}:` prefix keeps the three value tiers in disjoint
866
+ // keyspaces, but the isolation is only as strong as the cache's
867
+ // keyHashFunction: djb2 (getCache's default) folds this to 32 bits, so at a
868
+ // large live-key count a same-family collision can serve the wrong body and a
869
+ // cross-family one reads as corrupt. See the class doc for passing a wider
870
+ // hash (sha256) when many keys are live.
678
871
  private toStoreKey(key: string, family: CacheFamily): string {
679
872
  const versionPrefix = this.version ? `v/${this.version}/` : "";
680
873
  return `${versionPrefix}rg:${family}:${key}`;
@@ -687,72 +880,135 @@ export class VercelCacheStore<
687
880
  tags: string[] | undefined,
688
881
  label: string,
689
882
  ): Promise<void> {
690
- if (!this.withinSizeLimit(value, label)) return;
883
+ // Serialize the envelope exactly ONCE: the same string measures the entry
884
+ // against the size cap AND is what we hand the platform. The Vercel client
885
+ // JSON-serializes whatever value it is given, so passing the raw object here
886
+ // would walk and stringify the (potentially large) envelope a SECOND time.
887
+ // Entries written this way are JSON strings; the read path (decodeRaw)
888
+ // parses them back, and still accepts the legacy object shape written before
889
+ // this change.
890
+ let serialized: string | undefined;
891
+ try {
892
+ serialized = JSON.stringify(value);
893
+ } catch {
894
+ // Unserializable value: fall back to letting the platform serialize it
895
+ // (best-effort — matches the prior "cannot measure -> allow the write").
896
+ serialized = undefined;
897
+ }
898
+ if (serialized !== undefined && !this.withinSizeLimit(serialized, label)) {
899
+ return;
900
+ }
691
901
  const safeTags = this.clampTagsForWrite(tags, label);
692
902
  const options: { ttl: number; tags?: string[]; name?: string } = {
693
903
  ttl: Math.max(1, Math.ceil(totalTtlSeconds)),
694
904
  };
695
905
  if (safeTags.length > 0) options.tags = safeTags;
696
906
  if (this.name) options.name = this.name;
697
- await this.cache.set(storeKey, value, options);
907
+ await this.cache.set(storeKey, serialized ?? value, options);
698
908
  }
699
909
 
700
910
  /**
701
- * Stale-read herd dampening: push staleAt forward by REVALIDATION_LOCK_MS
702
- * (clamped to the hard expiry) and re-write the same envelope under the
703
- * remaining lifetime, so concurrent same-region readers see it as fresh while
704
- * one revalidates. Best-effort and non-blocking; never throws.
911
+ * Normalize a value read back from the cache. Entries written by this store
912
+ * are pre-serialized JSON strings (see write()); legacy entries (written
913
+ * before that change, or by a client that deserializes for us) come back as
914
+ * objects. Parse strings, pass objects through, and treat a non-JSON string
915
+ * as corrupt (returns undefined, so the caller reports + evicts).
705
916
  */
706
- private markRevalidating<E extends { s: number; e: number }>(
917
+ private decodeRaw(raw: unknown): unknown {
918
+ if (typeof raw !== "string") return raw;
919
+ try {
920
+ return JSON.parse(raw);
921
+ } catch {
922
+ return undefined;
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Stale-read herd dampening via a tiny companion lock key. On a stale read,
928
+ * check `{storeKey}:lock`; if absent, claim it (write a short-TTL marker) and
929
+ * return true so THIS reader triggers revalidation. If present, another
930
+ * same-region reader already claimed it, so return false and serve the stale
931
+ * entry as fresh without piling on. Best-effort and non-blocking; never throws.
932
+ *
933
+ * Only the STALE path pays the extra lock read; the fresh-hit path takes no
934
+ * lock round trip. Unlike the prior whole-envelope re-stamp, a stale read now
935
+ * writes only the tiny lock, never the (potentially large) payload.
936
+ *
937
+ * Non-atomic (getCache has no compare-and-set): two readers can both find no
938
+ * lock and both revalidate. The window is REVALIDATION_LOCK_MS wide, after
939
+ * which the lock expires and the entry is re-claimed (or a fresh write has
940
+ * superseded it). A read-compare would not close the race without CAS.
941
+ */
942
+ private async claimRevalidation(
707
943
  storeKey: string,
708
- env: E,
709
- tags: string[] | undefined,
944
+ expiresAt: number,
710
945
  label: string,
711
- ): void {
712
- const now = Date.now();
713
- const remainingSeconds = Math.ceil((env.e - now) / 1000);
714
- if (remainingSeconds <= 0) return;
715
- const locked: E = {
716
- ...env,
717
- s: Math.min(now + REVALIDATION_LOCK_MS, env.e),
718
- };
946
+ ): Promise<boolean> {
947
+ const lockKey = `${storeKey}:lock`;
948
+ let lock: unknown;
949
+ try {
950
+ lock = await this.cache.get(lockKey);
951
+ } catch {
952
+ // A lock-read failure must not break the read path: treat as unlocked.
953
+ lock = null;
954
+ }
955
+ if (lock != null) return false;
956
+
957
+ const remainingSeconds = Math.ceil((expiresAt - Date.now()) / 1000);
958
+ const lockTtl = Math.max(
959
+ 1,
960
+ Math.min(Math.ceil(REVALIDATION_LOCK_MS / 1000), remainingSeconds),
961
+ );
719
962
  const task = (): Promise<void> =>
720
- this.write(storeKey, locked, remainingSeconds, tags, label);
963
+ this.cache.set(lockKey, 1, { ttl: lockTtl });
721
964
  if (this.waitUntil) {
722
965
  this.waitUntil(reportingAsync(task, "cache-write", label));
723
966
  } else {
724
967
  void reportingAsync(task, "cache-write", label);
725
968
  }
969
+ return true;
726
970
  }
727
971
 
728
- private withinSizeLimit(value: unknown, label: string): boolean {
729
- try {
730
- const bytes = new TextEncoder().encode(JSON.stringify(value)).length;
731
- if (bytes >= this.maxItemBytes) {
732
- reportCacheError(
733
- new Error(
734
- `entry is ${bytes}B, at/above the ${this.maxItemBytes}B cap; not cached`,
735
- ),
736
- "cache-write",
737
- label,
738
- );
739
- return false;
740
- }
741
- } catch {
742
- // If the value cannot be measured, allow the write (best-effort).
972
+ /** Measure a pre-serialized entry against the per-item size cap (see write). */
973
+ private withinSizeLimit(serialized: string, label: string): boolean {
974
+ const bytes = new TextEncoder().encode(serialized).length;
975
+ if (bytes >= this.maxItemBytes) {
976
+ reportCacheError(
977
+ new Error(
978
+ `entry is ${bytes}B, at/above the ${this.maxItemBytes}B cap; not cached`,
979
+ ),
980
+ "cache-write",
981
+ label,
982
+ );
983
+ return false;
743
984
  }
744
985
  return true;
745
986
  }
746
987
 
747
- /** Drop tags Vercel rejects (commas, over-length). Used by invalidateTags. */
748
- private validateTags(tags: string[], label: string): string[] {
988
+ /**
989
+ * Drop tags Vercel cannot round-trip: over-length tags, and tags carrying a
990
+ * URL metacharacter the `@vercel/functions` client does not encode (see
991
+ * VERCEL_UNSAFE_TAG_CHARS). Runs on BOTH paths - the write path
992
+ * (clampTagsForWrite) and invalidateTags - so a tag that would silently fail
993
+ * invalidation is never stored in the first place. `category` reports the
994
+ * drop under the caller's real operation (write vs invalidate).
995
+ */
996
+ private validateTags(
997
+ tags: string[],
998
+ label: string,
999
+ category: CacheErrorCategory,
1000
+ ): string[] {
749
1001
  const encoder = new TextEncoder();
750
1002
  const out: string[] = [];
751
1003
  for (const tag of tags) {
752
- if (tag.includes(",")) {
1004
+ const unsafe = VERCEL_UNSAFE_TAG_CHARS.exec(tag);
1005
+ if (unsafe) {
753
1006
  reportCacheError(
754
- new Error(`tag "${tag}" contains a comma; skipped`),
755
- "cache-invalidate",
1007
+ new Error(
1008
+ `tag "${tag}" contains an unencodable URL character "${unsafe[0]}"; ` +
1009
+ `skipped (it would not round-trip through Vercel tag invalidation)`,
1010
+ ),
1011
+ category,
756
1012
  label,
757
1013
  );
758
1014
  continue;
@@ -760,7 +1016,7 @@ export class VercelCacheStore<
760
1016
  if (encoder.encode(tag).length > VERCEL_MAX_TAG_BYTES) {
761
1017
  reportCacheError(
762
1018
  new Error(`tag exceeds ${VERCEL_MAX_TAG_BYTES} bytes; skipped`),
763
- "cache-invalidate",
1019
+ category,
764
1020
  label,
765
1021
  );
766
1022
  continue;
@@ -776,7 +1032,7 @@ export class VercelCacheStore<
776
1032
  label: string,
777
1033
  ): string[] {
778
1034
  if (!tags || tags.length === 0) return [];
779
- const valid = this.validateTags(tags, label);
1035
+ const valid = this.validateTags(tags, label, "cache-write");
780
1036
  if (valid.length > VERCEL_MAX_TAGS_PER_ITEM) {
781
1037
  reportCacheError(
782
1038
  new Error(
@@ -837,6 +1093,27 @@ export class VercelCacheStore<
837
1093
  };
838
1094
  }
839
1095
 
1096
+ private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1097
+ if (!isRecord(raw)) return null;
1098
+ const { p, po, rv, bv, c, s, e, t, i, sn } = raw;
1099
+ if (typeof p !== "string" || typeof rv !== "string") return null;
1100
+ if (po !== null && typeof po !== "string") return null;
1101
+ if (typeof c !== "number") return null;
1102
+ if (typeof s !== "number" || typeof e !== "number") return null;
1103
+ return {
1104
+ p,
1105
+ po: po as string | null,
1106
+ rv,
1107
+ bv: typeof bv === "string" ? bv : undefined,
1108
+ c,
1109
+ s,
1110
+ e,
1111
+ t: Array.isArray(t) ? (t as string[]) : undefined,
1112
+ i: typeof i === "string" ? i : undefined,
1113
+ sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
1114
+ };
1115
+ }
1116
+
840
1117
  private asResponseEnvelope(raw: unknown): VercelResponseEnvelope | null {
841
1118
  if (!isRecord(raw)) return null;
842
1119
  const { b, st, hd, s, e, t } = raw;