@rangojs/router 0.17.0 → 0.18.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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -99,7 +99,11 @@ export interface SegmentCacheStore<TEnv = unknown> {
99
99
  */
100
100
  get(key: string): Promise<CacheGetResult | null | CacheReadError>;
101
101
  /**
102
- * Store entry data with TTL
102
+ * Store entry data with TTL. Resolve once a later `get(key)` from the same
103
+ * location observes the entry: the route cache() write runs in a background
104
+ * task, and a PPR shell capture awaits that task before it reads the entry
105
+ * back (#957). A store that resolves earlier makes the capture re-render
106
+ * the page instead of replaying the entry.
103
107
  * @param key - Cache key
104
108
  * @param data - Serialized entry data
105
109
  * @param ttl - Time-to-live in seconds
@@ -156,8 +160,14 @@ export interface SegmentCacheStore<TEnv = unknown> {
156
160
  * upgrade — the reactVersion field on the entry gates that at read time).
157
161
  * @param key - Cache key
158
162
  * @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
163
+ * @param ttlSeconds - Time-to-live in seconds: the route's `ppr.ttl`
164
+ * (undefined: the store's default), capped to the route cache() entry
165
+ * the capture replayed or wrote. A capped window arrives in whole
166
+ * seconds, rounded up, with ttl + swr at least 1; its ttl can be 0 (a
167
+ * shell stale from the start, captured from a record inside its swr
168
+ * window).
169
+ * @param swrSeconds - Optional stale-while-revalidate window in seconds,
170
+ * under the same cap
161
171
  * @param tags - Optional cache tags for invalidation (participates in
162
172
  * invalidateTags via the same tag machinery as the item family)
163
173
  * @returns `invalidated` when a generation marker rejected the write,
@@ -169,6 +179,40 @@ export interface SegmentCacheStore<TEnv = unknown> {
169
179
  * without acknowledgements.
170
180
  */
171
181
  putShell?(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | "uncacheable" | void>;
182
+ /**
183
+ * @internal The document serve path's shell read, for built-in stores. The
184
+ * serve path prefers it over getShell: it resolves with the entry and the
185
+ * raw (decoded) prelude, the snapshot on its own promise, and the read's
186
+ * `debugPerformance` stats. CFCacheStore's layout keeps the snapshot behind
187
+ * the prelude, so its HIT can commit before the snapshot is read;
188
+ * VercelCacheStore reads the whole entry and decodes the prelude once per
189
+ * memoized shell (issue #941, docs/design/shell-entry-layout.md). Not part
190
+ * of the custom-store contract.
191
+ */
192
+ readShellDocument?(key: string, options?: ShellDocumentReadOptions): Promise<ShellDocumentRead | null>;
193
+ /**
194
+ * @internal How long (ms) this store's isolate memos (shell, tag markers)
195
+ * can serve a read that predates a tag invalidation made elsewhere. After
196
+ * updateTag()/revalidateTag(), the response's fresh-reads cookie lasts the
197
+ * longest of these, and the same user's requests skip the memos meanwhile.
198
+ * Absent or 0: nothing to cover.
199
+ */
200
+ readonly freshReadsWindowMs?: number;
201
+ /**
202
+ * @internal Drop this isolate's memoized copy of a shell (shell-memo.ts) so
203
+ * the next read goes to the store. A HIT whose doc record failed to decode
204
+ * calls it after replacing the entry with a tombstone: the memoized copy
205
+ * holds the same record. Built-in stores only; not part of the custom-store
206
+ * contract.
207
+ */
208
+ dropShellMemo?(key: string): void;
209
+ /**
210
+ * @internal The largest shell entry (prelude, postponed state, and snapshot
211
+ * bytes) this store can hold in one value. The capture refuses a bigger
212
+ * entry instead of letting the write fail inside waitUntil. Absent: the
213
+ * capture applies DEFAULT_SHELL_ENTRY_MAX_BYTES (Cloudflare KV's 25 MiB).
214
+ */
215
+ readonly maxShellEntryBytes?: number;
172
216
  /**
173
217
  * Declares the shell family present-but-inert: getShell/putShell exist but
174
218
  * no-op (a custom store whose backing tier is conditionally unavailable).
@@ -210,6 +254,20 @@ export interface SegmentCacheStore<TEnv = unknown> {
210
254
  * APIs delegate to. Receives ALL of one invalidation call's tags at once so
211
255
  * stores can batch their work (e.g. a single CDN purge request rather than
212
256
  * one per tag). Stores that do not support tags simply omit this method.
257
+ *
258
+ * Both verbs call it synchronously, inside the request that invalidates
259
+ * (#973); revalidateTag() then hands the returned promise to waitUntil
260
+ * without awaiting it. Read-your-own-writes for that request comes from
261
+ * what the store does before its first await: record the tags as
262
+ * invalidated in state its reads consult for the rest of the request (the
263
+ * built-in stores key a map by the request's root context and the store,
264
+ * so contexts derived from the request share it, and compare each hit's
265
+ * tags and write time against it), then start the
266
+ * durable write. Such a mask must only ever turn hits into misses, and only
267
+ * in that request: a durable write that later fails then costs extra
268
+ * misses, never a stale read. A store that records nothing before its
269
+ * first await still works; the request that ran revalidateTag() can read
270
+ * entries the invalidation covers until the durable write lands.
213
271
  * @param tags - The cache tags to invalidate
214
272
  */
215
273
  invalidateTags?(tags: string[]): Promise<void>;
@@ -295,12 +353,13 @@ export interface ShellCacheEntry {
295
353
  */
296
354
  buildVersion?: string;
297
355
  /**
298
- * The initialTheme the CAPTURE render was built with (the derived context's
299
- * reqCtx.theme). The resume tail must render ThemeProvider with the SAME
356
+ * The initialTheme the CAPTURE render was built with: the no-cookie default
357
+ * (payloadInitialTheme, rsc/full-payload.ts), never the capturing visitor's
358
+ * theme (#971). The resume tail must render ThemeProvider with the SAME
300
359
  * initialTheme the frozen prelude was rendered with: React resume requires the
301
360
  * tree above the holes to match the prerendered tree, and initialTheme is
302
361
  * per-request METADATA, not part of the cached segments — a visitor whose
303
- * theme differs from the capturer's would otherwise produce a divergent resume
362
+ * theme differs from the capture's would otherwise produce a divergent resume
304
363
  * tree (broken stitching/hydration). The visitor's real theme is applied
305
364
  * pre-paint by the FOUC script and re-synced from the cookie post-mount by
306
365
  * ThemeProvider.
@@ -321,17 +380,21 @@ export interface ShellCacheEntry {
321
380
  */
322
381
  snapshot?: ShellSnapshotRecord[];
323
382
  /**
324
- * The key of the CANONICAL document segment record inside `snapshot` — the
325
- * one navigation replay can actually consume (resolved under the implicit
326
- * doc namespace at capture; see CacheScope.cacheRoute). Replay eligibility
327
- * requires this exact record: the snapshot also carries incidentally
328
- * recorded explicit-tier records (RecordingShellStore passthroughs) whose
329
- * keys a partial lookup can never resolve, and counting those declared
330
- * entries "replayable" that always missed (`snapshot-miss` flip-flop).
331
- * Absent on entries captured before the field existed OR when the capture
332
- * recorded no doc record (cache(false)/condition-false routes, prerender
333
- * short-circuit) — both read as `no-segment-snapshot`; recapture heals the
334
- * former.
383
+ * Records the capture dropped from `snapshot` because no reader of this
384
+ * entry consumes them, by family (`item:4/response:1`). Diagnostic only: the
385
+ * HIT tail timing reports it next to the kept records. See
386
+ * docs/design/shell-entry-layout.md ("Record only what a HIT reads").
387
+ */
388
+ prunedRecords?: string;
389
+ /**
390
+ * The key of the document segment record inside `snapshot`: the handler
391
+ * layer every HIT replays (a document HIT tail looks the record up by this
392
+ * key, so a store keyGenerator or a build-time capture host cannot send it
393
+ * elsewhere) and the record navigation replay consumes (resolved under the
394
+ * implicit doc namespace at capture; see CacheScope.cacheRoute). A capture
395
+ * that ran handlers always stores it; only a prerender-served entry (the
396
+ * prerender store supplies the handler layer) has none. A document entry
397
+ * without it is served as a MISS.
335
398
  */
336
399
  docKey?: string;
337
400
  /**
@@ -340,26 +403,6 @@ export interface ShellCacheEntry {
340
403
  * the partial request's headers and middleware state are not document state.
341
404
  */
342
405
  navigationOnly?: true;
343
- /**
344
- * True when the capture's HANDLER layer declared per-request liveness: a
345
- * handle pushed OUTSIDE a DSL loader scope carried a nested thenable (the
346
- * capture mask turns it into a never-filling hole), such a push was still
347
- * pending when the entry was written, or a handler-invoked loader
348
- * (ctx.use(loader) from a handler body — the consumption lane, #672)
349
- * executed during the capture. The serve tail then must NOT take the
350
- * handler-free fast path (the implicit doc-cache hit): only a handler
351
- * re-run can mint that hole's live promise or refresh that consumed value.
352
- * DSL-loader pushes never set this — loaders re-run fresh on every HIT, so
353
- * their holes always fill.
354
- */
355
- handlerLiveHoles?: boolean;
356
- /**
357
- * Legacy/fallback marker for a transition({ when }) predicate that was not
358
- * evaluated by the PPR pre-handler gate. Such an entry stays conservatively
359
- * ineligible for handler-free replay. New PPR matches evaluate known segment
360
- * predicates before the pipeline and do not set this marker.
361
- */
362
- transitionWhen?: true;
363
406
  /** Capture-generation start time; tag invalidations at or after it win. */
364
407
  createdAt: number;
365
408
  }
@@ -374,6 +417,117 @@ export type DocumentShellCacheEntry = ShellCacheEntry & {
374
417
  prelude: string;
375
418
  postponed: string | null;
376
419
  };
420
+ /** @internal Options of {@link SegmentCacheStore.readShellDocument}. */
421
+ export interface ShellDocumentReadOptions {
422
+ /**
423
+ * Tag names the route declares for its shell (`ppr.tags`), known before the
424
+ * entry is read: their marker reads can start alongside the entry read.
425
+ */
426
+ tagHints?: readonly string[];
427
+ }
428
+ /**
429
+ * @internal Why a prelude-first read's snapshot is missing although the entry
430
+ * stored one: `unavailable` (the read timed out or failed; the entry may be
431
+ * sound) or `corrupt` (the bytes arrived and do not parse; the store evicted
432
+ * the entry).
433
+ */
434
+ export type ShellSnapshotFailure = "unavailable" | "corrupt";
435
+ /**
436
+ * @internal Result of {@link SegmentCacheStore.readShellDocument}.
437
+ */
438
+ export interface ShellDocumentRead {
439
+ /** The entry without its prelude and snapshot (delivered separately). */
440
+ entry: ShellCacheEntry;
441
+ /** Raw prelude bytes (empty for a navigationOnly entry). */
442
+ prelude: Uint8Array;
443
+ shouldRevalidate?: boolean;
444
+ /**
445
+ * The capture snapshot, read after the prelude. Resolves undefined when the
446
+ * entry has none or it could not be read (`snapshotFailure` says which); a
447
+ * document HIT then cannot replay its doc record and degrades
448
+ * (rsc-rendering.ts serveShellHit).
449
+ * Never rejects.
450
+ */
451
+ snapshot: Promise<ShellSnapshotRecord[] | undefined>;
452
+ /**
453
+ * Why `snapshot` resolved undefined although the entry stored one, or
454
+ * undefined when it did not fail. A document HIT that cannot replay its
455
+ * doc record replaces the entry only when it is broken, not when its read
456
+ * was merely slow (rsc-rendering.ts serveShellHit). Never rejects.
457
+ */
458
+ snapshotFailure?: Promise<ShellSnapshotFailure | undefined>;
459
+ /**
460
+ * Where the read's time and bytes went, for the `debugPerformance` metrics
461
+ * (rsc-rendering.ts) and the store's own debug trace. Present only when one
462
+ * of them is on for the request.
463
+ */
464
+ stats?: ShellReadStats;
465
+ }
466
+ /**
467
+ * @internal Sub-steps of a prelude-first shell read. Times are ms. workerd
468
+ * advances its clock only on I/O, so a CPU-only field
469
+ * (`snapshot.parseMs`) reads 0 on a deployed worker: the byte counts are the
470
+ * cost signal there.
471
+ */
472
+ export interface ShellReadStats {
473
+ /**
474
+ * The tier that answered: `memo` (the store's shell memo), `l1`/`kv`
475
+ * (CFCacheStore), or `store` (a single-tier store, VercelCacheStore).
476
+ */
477
+ tier: "memo" | "l1" | "kv" | "store";
478
+ /** The per-isolate memo's outcome for this read and its size after it. */
479
+ memo?: {
480
+ hit: boolean;
481
+ bytes: number;
482
+ };
483
+ /**
484
+ * A KV read after an L1 attempt: how long the L1 attempt took and why it
485
+ * missed. The KV fields below then restart from the KV read.
486
+ */
487
+ l1MissMs?: number;
488
+ l1MissReason?: string;
489
+ /** Cache API match (L1) or KV get (KV) until the body was available. */
490
+ matchMs?: number;
491
+ /** Frame head read (I/O) and parse. */
492
+ headMs?: number;
493
+ /** Prelude bytes read. */
494
+ preludeMs?: number;
495
+ /** Tag-marker read, start to resolve (runs alongside the prelude read). */
496
+ markerMs?: number;
497
+ /** How long the read waited for the marker after the prelude was read. */
498
+ markerWaitMs?: number;
499
+ /**
500
+ * The marker check was awaited after the entry read (VercelCacheStore), not
501
+ * alongside the prelude read; hinted reads still start with the entry read.
502
+ */
503
+ markerSerial?: true;
504
+ /**
505
+ * How the isolate marker memo answered the shell's tags (the most
506
+ * store-bound outcome across them): `fresh`, `stale` (served, refreshing in
507
+ * the background), `read` (from the store), `bypass` (fresh-reads cookie).
508
+ */
509
+ markerMemo?: "fresh" | "stale" | "read" | "bypass";
510
+ /** Tags whose marker reads were started before the entry's head (hints). */
511
+ markerHinted?: readonly string[];
512
+ /** When the hinted marker reads started (performance.now()). */
513
+ markerHintStartedAt?: number;
514
+ /** How many of the shell's tags were hinted. */
515
+ markerHintHits?: number;
516
+ /** How long the hinted marker reads ran before the entry named its tags. */
517
+ markerLeadMs?: number;
518
+ /** The request carried the fresh-reads cookie: no isolate memo was used. */
519
+ freshReads?: true;
520
+ headBytes?: number;
521
+ preludeBytes?: number;
522
+ /** Number of the shell's tags the marker read covered. */
523
+ tags?: number;
524
+ /** Filled after the commit, once the snapshot has been read. */
525
+ snapshot?: {
526
+ readMs: number;
527
+ parseMs: number;
528
+ bytes: number;
529
+ };
530
+ }
377
531
  /**
378
532
  * The families a shell snapshot pins. The item/segment/response families are
379
533
  * cache-store reads/writes (recorded by RecordingShellStore); the loader family
@@ -398,12 +552,20 @@ export interface ShellSnapshotLoaderValue {
398
552
  * container carries hole markers, so a HIT must gate the overlay on the
399
553
  * fresh run (only the loader body can mint the live nested promises);
400
554
  * 0 = fully pinned, so a HIT resolves the payload promise immediately from
401
- * the pin while the fresh run proceeds ungated (side effects and cache
402
- * read-through writes preserved; its values were discarded either way —
403
- * recorded paths win wholesale). Absent on pre-bit snapshots, which keep
404
- * the gated path.
555
+ * the pin and does not run the loader body (unless `runs`). Absent on
556
+ * pre-bit snapshots, which keep the gated path.
405
557
  */
406
558
  holes?: 0 | 1;
559
+ /**
560
+ * 1 when the capture saw a loader push it could not record (a deferred
561
+ * push, one with masked nested promises, or one made outside any loader
562
+ * body): a HIT then still runs the loader body in the background
563
+ * (pin-first) so those pushes reach the page. 0: a hole-free record is
564
+ * served from the pin alone and its body does not run on a HIT. Absent (a
565
+ * snapshot written before the bit, which lacks the loader-owned pushes)
566
+ * reads as 1, so the body supplies them.
567
+ */
568
+ runs?: 0 | 1;
407
569
  }
408
570
  /** A serialized cached Response for the response family of a shell snapshot. */
409
571
  export interface ShellSnapshotResponseValue {
@@ -527,8 +689,10 @@ export type SegmentHandleData = Record<string, unknown[]>;
527
689
  /**
528
690
  * segmentId -> handleName -> the owning loader id of each recorded value, by
529
691
  * index (null: not owned). Only a PPR shell capture writes owners: the
530
- * settled pushes of an `ssr: false` loader's own body, which the record keeps
692
+ * settled pushes of the loader bodies it ran (an `ssr: false` loader's own,
693
+ * the loaders it awaits, and its own cache() replays), which the record keeps
531
694
  * because the prelude rendered them. restoreHandles replays an owned value
532
- * through HandleStore.pushReplayed, so that loader's live re-run replaces it.
695
+ * through HandleStore.pushReplayed, so a loader that does run on the HIT
696
+ * replaces it with its live push.
533
697
  */
534
698
  export type HandleOwners = Record<string, Record<string, (string | null)[]>>;
@@ -5,9 +5,10 @@
5
5
  * `@vercel/functions`). It is the production store analogue to CFCacheStore, but
6
6
  * far smaller: Vercel's Runtime Cache already IS a distributed, tag-aware cache
7
7
  * (regional storage, global tag-expire within ~300ms), so none of CFCacheStore's
8
- * L1/L2 tiering, KV tag-marker comparison, marker memoization, or per-tier
9
- * timeout budgets are needed here - the platform does that work. What this store
10
- * adds on top of the raw primitive is the parts Vercel does NOT give us:
8
+ * L1/L2 tiering or per-tier timeout budgets are needed here - the platform does
9
+ * that work. Tag markers exist only for the shell family (`tm`, see
10
+ * isTagsInvalidatedSince), and only PPR shell reads memoize them. What this
11
+ * store adds on top of the raw primitive is the parts Vercel does NOT give us:
11
12
  *
12
13
  * 1. Stale-while-revalidate. `getCache` has no stale-but-serve: a TTL'd entry
13
14
  * simply becomes a miss. We store our own {staleAt, expiresAt} envelope and
@@ -30,8 +31,9 @@
30
31
  * `getCache(...)` result, which satisfies the shape structurally. That keeps the
31
32
  * router free of a hard Vercel dependency.
32
33
  */
33
- import type { SegmentCacheStore, CachedEntryData, CacheDefaults, CacheGetResult, CacheItemResult, CacheItemOptions, ShellCacheEntry, CacheReadError } from "../types.js";
34
- import type { RequestContext } from "../../server/request-context.js";
34
+ import type { SegmentCacheStore, CachedEntryData, CacheDefaults, CacheGetResult, CacheItemResult, CacheItemOptions, ShellCacheEntry, ShellDocumentRead, ShellDocumentReadOptions, CacheReadError } from "../types.js";
35
+ import { type RequestContext } from "../../server/request-context.js";
36
+ import { type StoreMemoOptions } from "../shell-memo.js";
35
37
  /**
36
38
  * Minimal structural shape of the Vercel Runtime Cache returned by `getCache()`
37
39
  * from `@vercel/functions`. Declared locally so @rangojs/router carries no hard
@@ -69,7 +71,7 @@ export declare const VERCEL_MAX_TAGS_PER_ITEM: number;
69
71
  /** Max tag length in UTF-8 bytes accepted by Vercel; longer tags are skipped. */
70
72
  export declare const VERCEL_MAX_TAG_BYTES: number;
71
73
  /** Read-path outcome for the debug sink. */
72
- export type VercelCacheReadOutcome = "miss" | "fresh" | "stale-revalidate" | "expired" | "corrupt" | "error";
74
+ export type VercelCacheReadOutcome = "miss" | "memo-hit" | "fresh" | "stale-revalidate" | "expired" | "corrupt" | "error";
73
75
  /** Diagnostic event emitted on every read when `debug` is set. */
74
76
  export interface VercelCacheReadDebugEvent {
75
77
  op: "get" | "getItem" | "getResponse" | "getShell";
@@ -100,10 +102,13 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
100
102
  */
101
103
  cache: VercelRuntimeCache;
102
104
  /**
103
- * `waitUntil` from `@vercel/functions`. Used only to run the stale-read lock
104
- * write (herd dampening) off the response path - the router already
105
- * backgrounds the actual writes. When omitted, the lock write runs detached
106
- * (fire-and-forget) instead.
105
+ * `waitUntil` from `@vercel/functions`. Keeps two background reads/writes
106
+ * alive past the response: the stale-read lock write (herd dampening; the
107
+ * router already backgrounds the actual writes) and the PPR shell reads'
108
+ * tag-marker memo refreshes (`memo.markerMaxStaleMs`). When omitted, both
109
+ * run detached (fire-and-forget), and the platform may end a refresh when
110
+ * the response ends; the next read past the stale window then reads the
111
+ * marker itself.
107
112
  */
108
113
  waitUntil?: (promise: Promise<unknown>) => void;
109
114
  /**
@@ -136,6 +141,26 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
136
141
  * receives the structured VercelCacheReadDebugEvent.
137
142
  */
138
143
  debug?: VercelCacheDebug;
144
+ /**
145
+ * Per-process memos ({@link StoreMemoOptions}), kept per `cache` handle:
146
+ * create the `getCache()` handle once per process, not per request, or the
147
+ * memos never hit. `shellMs` (default 2000) and `shellMaxBytes` (default
148
+ * 16 MiB) size the PPR shell memo; `markerFreshMs` (default 300) and
149
+ * `markerMaxStaleMs` (default 2000) the memo of the tag markers
150
+ * `invalidateTags()` writes, which PPR shell reads use
151
+ * (stale-while-revalidate). The process that invalidates writes the new
152
+ * marker into its memo; another process in the region rejects a memoized
153
+ * shell once its marker memo sees it (up to `markerMaxStaleMs`). The
154
+ * markers are a regional `cache.set` (only `expireTag` is global), so
155
+ * another region serves a shell it memoized before the invalidation until
156
+ * its window passes, where without the memo `expireTag` removes it within
157
+ * about 300 ms; a platform `expireTag` issued outside rango is likewise
158
+ * seen once the window passes. The response of a request that ran
159
+ * `updateTag()` / `revalidateTag()` sets the fresh-reads cookie, and the
160
+ * same user's requests carrying it skip both memos. `{ shellMs: 0 }` turns
161
+ * the shell memo off, `{ markerFreshMs: 0 }` the marker memo.
162
+ */
163
+ memo?: StoreMemoOptions;
139
164
  }
140
165
  /**
141
166
  * Vercel Runtime Cache-backed segment cache store.
@@ -170,12 +195,18 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
170
195
  * import { getCache, waitUntil } from "@vercel/functions";
171
196
  * import { VercelCacheStore } from "@rangojs/router/cache";
172
197
  *
198
+ * // One handle per process: getCache() resolves the platform cache on every
199
+ * // call, and the PPR shell and tag-marker memos are kept per handle.
200
+ * // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
201
+ * // would be undefined here) so cross-deploy busting via `version` works.
202
+ * const runtimeCache = getCache({
203
+ * namespace: process.env.VERCEL_DEPLOYMENT_ID,
204
+ * });
205
+ *
173
206
  * export const router = createRouter({
174
207
  * cache: () => ({
175
208
  * store: new VercelCacheStore({
176
- * // `process.env` (not `import.meta.env`, which needs a VITE_ prefix and
177
- * // would be undefined here) so cross-deploy busting via `version` works.
178
- * cache: getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID }),
209
+ * cache: runtimeCache,
179
210
  * waitUntil,
180
211
  * defaults: { ttl: 60, swr: 300 },
181
212
  * }),
@@ -191,6 +222,16 @@ export declare class VercelCacheStore<TEnv = unknown> implements SegmentCacheSto
191
222
  private readonly waitUntil?;
192
223
  private readonly version?;
193
224
  private readonly maxItemBytes;
225
+ private readonly memo;
226
+ private readonly handleMemos;
227
+ /** @internal SegmentCacheStore.freshReadsWindowMs */
228
+ readonly freshReadsWindowMs: number;
229
+ /**
230
+ * @internal SegmentCacheStore.maxShellEntryBytes: the item cap, less the
231
+ * base64 expansion of the prelude the shell envelope stores (the capture
232
+ * measures raw prelude bytes).
233
+ */
234
+ readonly maxShellEntryBytes: number;
194
235
  private readonly name?;
195
236
  private readonly debug?;
196
237
  constructor(options: VercelCacheStoreOptions<TEnv>);
@@ -210,6 +251,46 @@ export declare class VercelCacheStore<TEnv = unknown> implements SegmentCacheSto
210
251
  entry: ShellCacheEntry;
211
252
  shouldRevalidate?: boolean;
212
253
  } | null>;
254
+ /**
255
+ * @internal The document serve path's read (SegmentCacheStore
256
+ * .readShellDocument): the same read as getShell, with the prelude decoded
257
+ * here, once per memoized shell rather than once per HIT, and the read's
258
+ * `debugPerformance` stats. The runtime cache returns a parsed value, so
259
+ * there is no prelude-first read and the tag-marker read follows the entry
260
+ * read (`markerSerial`).
261
+ */
262
+ readShellDocument(key: string, options?: ShellDocumentReadOptions): Promise<ShellDocumentRead | null>;
263
+ /** @internal SegmentCacheStore.dropShellMemo */
264
+ dropShellMemo(key: string): void;
265
+ /**
266
+ * getShell's read, shared with readShellDocument: the shell memo first
267
+ * (shell-memo.ts), then the runtime cache. A memoized shell still runs the
268
+ * tag-marker check; `memoized` is the memo slot's value when the read came
269
+ * from, or went into, the memo (readShellDocument keeps the decoded prelude
270
+ * there).
271
+ */
272
+ private readShell;
273
+ /**
274
+ * The shell's tag-marker check, timed into `stats`. Markers a prefetch
275
+ * already started (hinted tags) are awaited, not re-read; the check itself
276
+ * always uses the entry's own tags.
277
+ */
278
+ private shellTagsInvalidated;
279
+ /**
280
+ * Start the tm-marker reads of the tags this shell key carried last time
281
+ * (and the route's static tags) alongside the entry read.
282
+ */
283
+ private prefetchShellMarkers;
284
+ /**
285
+ * A tag's latest invalidatedAt (or null). With `isolateMemo` (PPR shell
286
+ * reads only) through the per-process marker memo (isolate-tag-memo.ts):
287
+ * fresh values are used as is, stale ones are used and refreshed in the
288
+ * background, older ones block on the runtime-cache read. A request
289
+ * carrying the fresh-reads cookie skips the memo. A failed read rejects and
290
+ * is never memoized. The shell write gate and build shells
291
+ * (isTagsInvalidatedSince) read the runtime cache every time.
292
+ */
293
+ private readTagMarker;
213
294
  putShell(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
214
295
  /**
215
296
  * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince).
@@ -218,7 +299,24 @@ export declare class VercelCacheStore<TEnv = unknown> implements SegmentCacheSto
218
299
  * generation start wins; read errors fail open like the CF marker check.
219
300
  */
220
301
  isTagsInvalidatedSince(tags: string[], sinceMs: number): Promise<boolean>;
302
+ private tagsInvalidatedSince;
221
303
  invalidateTags(tags: string[]): Promise<void>;
304
+ /**
305
+ * The request-local half of invalidateTags(), run before its first await
306
+ * (#973): see requestInvalidations. revalidateTag() waits for nothing
307
+ * after it.
308
+ */
309
+ private maskTagsForRequest;
310
+ /**
311
+ * Whether this request invalidated one of `tags` through this store at or
312
+ * after `taggedAt`. `>=`, as the marker check: an entry written in the
313
+ * invalidation's millisecond may hold a value computed before it, so it
314
+ * misses (a false miss, never a stale read). An entry without a stamp was
315
+ * written before the stamp existed and counts as older.
316
+ */
317
+ private maskedForRequest;
318
+ /** A data read's mask check: reports the miss and returns true when masked. */
319
+ private maskedMiss;
222
320
  private toStoreKey;
223
321
  private write;
224
322
  /**
@@ -4,9 +4,9 @@
4
4
  * Runs in the RSC realm of the build's temp server, AFTER all bundles are
5
5
  * written (the prelude embeds built client asset URLs — bootstrap module,
6
6
  * chunk preloads — that only exist post-client-build). The capture core is
7
- * producer A's, verbatim: deriveShellCaptureContext (mask funnel, liveness,
8
- * snapshot recording, implicit doc-cache scope) + captureAndStoreShell (gates,
9
- * quiesce, tags union, putShell barrier). Build capture first replays global
7
+ * producer A's, verbatim: deriveShellCaptureContext (push funnel, snapshot
8
+ * recording, implicit doc-cache scope) + settleCaptureRecord (the record-first
9
+ * step) + captureAndStoreShell (gates, quiesce, tags union, putShell barrier). Build capture first replays global
10
10
  * and route middleware with a synthetic build request context
11
11
  * (`ctx.build === true`, inert `ctx.waitUntil()`); middleware can seed vars or
12
12
  * call `ctx.dynamic()` to skip this URL. The sink is an entry collector instead
@@ -95,10 +95,17 @@ export interface BuildShellCaptureResult {
95
95
  tags?: string[];
96
96
  /** On route-mismatch: what this router's match actually landed on. */
97
97
  matchedRouteName?: string;
98
+ /**
99
+ * On no-shell: the attempt ran out of ppr.captureTimeout. It is not
100
+ * retried: its match may still be running, and the retry would start the
101
+ * same work beside it (runtime twin: shell-capture.ts runShellCapture).
102
+ */
103
+ timedOut?: true;
98
104
  }
99
105
  /**
100
106
  * Capture the PPR shell for one prerendered URL at build time. Retries once
101
107
  * in place on `no-shell` (the first attempt warms the temp server's SSR/Flight
102
- * transform graph, mirroring producer A's cold-start retry — same delay).
108
+ * transform graph, mirroring producer A's cold-start retry — same delay),
109
+ * unless the attempt ran out of ppr.captureTimeout (`timedOut`).
103
110
  */
104
111
  export declare function captureShellForBuild(opts: BuildShellCaptureOptions): Promise<BuildShellCaptureResult>;
@@ -163,6 +163,14 @@ export declare function observePhase<T>(spec: PhaseSpec, fn: (span: TraceSpan) =
163
163
  * nothing to record here and short-circuits.
164
164
  */
165
165
  export declare function observeHandler<C, R>(id: string, handler: (ctx: C) => R, ctx: C): R;
166
+ /**
167
+ * observeHandler, inside segment `segmentId`'s tag scope, so the tags the
168
+ * handler records reach the route's cache() record (#957, cache-tag.ts
169
+ * runInSegmentTagScope). `spanId` is observeHandler's `id`. A request that
170
+ * cannot write a record takes observeHandler's path as is, with the same one
171
+ * request-context read: no scope, no closure.
172
+ */
173
+ export declare function observeSegmentHandler<C, R>(segmentId: string, spanId: string, handler: (ctx: C) => R, ctx: C): R;
166
174
  /**
167
175
  * Emit one discrete telemetry event (the event-shaped counterpart to
168
176
  * observePhase). Resolves the sink from the active router context and stamps the
@@ -48,7 +48,12 @@ export interface MiddlewareContext<TEnv = any, TParams = Record<string, string |
48
48
  header(name: string, value: string): void;
49
49
  routeName?: DefaultRouteName;
50
50
  debugPerformance(): void;
51
- theme?: Theme;
51
+ /**
52
+ * The visitor's theme (cookie or default); undefined without theme config.
53
+ * Guarded like `cookies()`: a read inside a `"use cache"` function throws
54
+ * (#971).
55
+ */
56
+ readonly theme?: Theme;
52
57
  setTheme?: (theme: Theme) => void;
53
58
  setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
54
59
  /** Global route names only; see {@link GlobalReverseFunction}. */
@@ -17,15 +17,39 @@
17
17
  * On stale hit (SWR): returns stale data, schedules background revalidation.
18
18
  * On miss: executes loader, schedules non-blocking cache write.
19
19
  *
20
+ * Identity (#972): without a key() or a store keyGenerator the entry is
21
+ * shared across users, so a fill whose execution recorded a request-identity
22
+ * read fails and is not stored, whoever started that execution
23
+ * (server/context.ts recordLoaderIdentityRead).
24
+ *
20
25
  * Handle pushes (`ctx.use(Handle)(...)` in the loader body) are a side effect
21
26
  * the HIT must reproduce: the MISS records the pushes of the body and of the
22
27
  * loaders it awaits via ctx.use into the entry's `handles` blob (the
23
28
  * "use cache" capture/encode), and every HIT — stale included — replays
24
29
  * them, each loader's pushes at most once per request (a dependency another
25
30
  * reader already ran keeps its live pushes). See replayLoaderHandles.
31
+ *
32
+ * Tags too (#964): the entry stores its cache() tags plus every tag its
33
+ * execution recorded, those of the loader values it read included
34
+ * (cache-tag.ts "Recorded-tag sets"), so updateTag() of any of them drops it.
35
+ * Every HIT records the stored tags through the loader's tag owner
36
+ * (recordLoaderTags), so a route cache() record, PPR shell or document built
37
+ * over the HIT carries them, and a loader that reads this one takes them on.
38
+ * Once the binding started, a loader body's ctx.use of it gets the same
39
+ * dataPromise as a handler read (createLoaderExecutor's useLoader); a stale
40
+ * refresh does not (setupLoaderAccess _runLoaderIsolated).
26
41
  */
27
- import { type LoaderEntry } from "../../server/context.js";
42
+ import { type EntryData, type LoaderEntry } from "../../server/context.js";
28
43
  import type { HandlerContext } from "../../types.js";
44
+ /**
45
+ * Whether a request over the matched chain `entries` resolves a loader bound
46
+ * with its own cache(): on a chain entry, or on its parallel slots, orphan
47
+ * layouts or intercepts. The match arms the per-execution loader tag sets on
48
+ * it (cache-tag.ts armLoaderTagSets), before any loader starts, so a
49
+ * dependency the handler or another binding starts first still records.
50
+ * Memoized per entry.
51
+ */
52
+ export declare function bindsLoaderCache(entries: readonly EntryData[]): boolean;
29
53
  /**
30
54
  * Resolve loader data with optional caching.
31
55
  *
@@ -46,18 +70,23 @@ import type { HandlerContext } from "../../types.js";
46
70
  * never run under a shell capture), so the lane is decided here, per LOADER,
47
71
  * never by the entry's loading():
48
72
  *
49
- * - BAKE lane: `loader(Def, { ssr: false })` (awaitBeforeFlush) with a
50
- * `bakeSegmentKey` from the caller. The loader EXECUTES at capture (the
73
+ * - BAKE lane: `loader(Def, { ssr: false })` (LoaderEntry `bake`: the
74
+ * caller passes a `bakeSegmentKey` for it on document and navigation
75
+ * evaluations alike; a capture, a document evaluation, also has
76
+ * awaitBeforeFlush). The loader EXECUTES at capture (the
51
77
  * flag's "data in the HTML before first flush" maps to the frozen prelude)
52
78
  * and its settled non-promise data bakes into the shell. Promises nested in
53
- * plain objects/arrays are masked (mask-nested.ts) and stay live holes at
54
- * the consumer's own Suspense — per-request material must be promise-shaped
55
- * (the #692 cross-session scar). The masked container registers on
56
- * `_shellCaptureLoaderRecords`: it holds the capture gate (bounded by
57
- * `ppr.captureTimeout`) and pins into the snapshot's loader family, and on a
58
- * HIT the recorded container is overlaid onto the fresh run (below) so the
59
- * payload matches the frozen prelude byte-for-byte. cookies()/headers() in
60
- * its body (nested promise bodies included) trip the capture guard.
79
+ * plain objects, arrays and JSX props are masked (mask-nested.ts) and stay
80
+ * live holes at the consumer's own Suspense — per-request material must be
81
+ * promise-shaped (the #692 cross-session scar). The masked container
82
+ * registers on `_shellCaptureLoaderRecords`: it holds the capture gate
83
+ * (bounded by `ppr.captureTimeout`) and pins into the snapshot's loader
84
+ * family, and on a HIT the recorded container is overlaid onto the fresh
85
+ * run (below) so the payload matches the frozen prelude outside the holes.
86
+ * A server-component element holding a promise is a hole as a whole, so its
87
+ * other props come from the fresh run (loader-snapshot.ts
88
+ * elideLoaderContainer). cookies()/headers() in its body (nested promise
89
+ * bodies included) trip the capture guard.
61
90
  * - LIVE lane: every other loader, whatever its entry's loading(). Never
62
91
  * executes at capture: the slot gets a never-resolving promise
63
92
  * (loader-mask.ts) and postpones at the reader's boundary — loading() or an