@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
@@ -15,6 +15,7 @@ import type { ExecutionContext } from "../../types/request-scope.js";
15
15
  import type { CacheDefaults } from "../types.js";
16
16
  import type { RequestContext } from "../../server/request-context.js";
17
17
  import type { CloudflareZonePurgeOptions } from "./cf-zone-purge.js";
18
+ import type { StoreMemoOptions } from "../shell-memo.js";
18
19
 
19
20
  /**
20
21
  * Minimal Cloudflare KV Namespace interface.
@@ -340,9 +341,12 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
340
341
 
341
342
  /**
342
343
  * Latency budget (ms) for reading the BODY of a matched L1 entry
343
- * (response.json()). CF streams the cache body lazily, so the multi-second
344
- * tail can appear after `match` already resolved; this bounds it. On timeout
345
- * the read is treated as a miss and falls through to L2/KV or render.
344
+ * (response.json(); for a PPR shell, only its head and prelude — the
345
+ * snapshot behind them is read after the HIT commits, under
346
+ * {@link kvReadTimeoutMs}). CF streams the cache body lazily, so the
347
+ * multi-second tail can appear after `match` already resolved; this bounds
348
+ * it. On timeout the read is treated as a miss and falls through to L2/KV or
349
+ * render.
346
350
  *
347
351
  * Separate from {@link edgeLookupTimeoutMs} because a healthy body read
348
352
  * (fetch + JSON parse of a potentially large Flight payload) takes a little
@@ -356,7 +360,11 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
356
360
  * Latency budget (ms) for an L2 (KV) read. KV is the last cache tier before a
357
361
  * full render and is a global store (~50ms healthy, seconds when degraded);
358
362
  * this bounds it so a slow namespace cannot pin the request. On timeout the
359
- * read is treated as a miss (no L1 promote) and falls through to render.
363
+ * read is treated as a miss (no L1 promote) and falls through to render. For
364
+ * a PPR shell it covers opening the KV value and reading its head and
365
+ * prelude; separately, it bounds the shell's snapshot read (either tier),
366
+ * which runs off the commit path: a timeout there lets the tail run without
367
+ * the snapshot's pins.
360
368
  *
361
369
  * Defaults to {@link KV_READ_TIMEOUT_MS} (170) -- a few multiples above the
362
370
  * ~50ms healthy read, with headroom for legitimate tails (large payloads / far
@@ -367,6 +375,26 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
367
375
  */
368
376
  kvReadTimeoutMs?: number;
369
377
 
378
+ /**
379
+ * Per-isolate memos ({@link StoreMemoOptions}), one set per isolate and
380
+ * shared by every CFCacheStore in it. `shellMs` (default 2000) and
381
+ * `shellMaxBytes` (default 16 MiB) size the PPR shell memo;
382
+ * `markerFreshMs` (default 1000) and `markerMaxStaleMs` (default 10000)
383
+ * the tag-marker memo PPR shell reads use with KV bound (served
384
+ * stale-while-revalidate). A memo HIT still runs the tag-marker check; the
385
+ * isolate that invalidates drops its own shell copies (and keeps reads in
386
+ * flight from memoizing them again) and writes the new marker into its
387
+ * marker memo, and its own `putShell` replaces its copy. Another isolate
388
+ * rejects a memoized shell once its marker memo sees the invalidation (up
389
+ * to `markerMaxStaleMs`). Without KV, in purge mode, the purge does not
390
+ * reach other isolates' memos: they serve the purged shell until their
391
+ * window passes. The response of a request that ran `updateTag()` /
392
+ * `revalidateTag()` sets the fresh-reads cookie, and the same user's
393
+ * requests carrying it skip both memos. `{ shellMs: 0 }` turns the shell
394
+ * memo off, `{ markerFreshMs: 0 }` the marker memo.
395
+ */
396
+ memo?: StoreMemoOptions;
397
+
370
398
  /**
371
399
  * Emit a {@link CFCacheReadDebugEvent} per L1 read. `true` logs to console
372
400
  * (visible via `wrangler tail`); pass a function to capture events directly.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Prelude-first binary layout of a CFCacheStore PPR shell entry, shared by the
3
+ * Cache API body and the KV value (issue #941; docs/design/shell-entry-layout.md):
4
+ *
5
+ * "RSH1" | head length, 8 hex digits | head JSON | prelude bytes | snapshot JSON
6
+ *
7
+ * A document HIT needs the head (versions, freshness, tags, postponed) and the
8
+ * raw prelude before its first byte, and the capture snapshot only in the
9
+ * tail. Laying them out in that order lets the serve path read the head,
10
+ * start the tag-marker read, read exactly `pl` prelude bytes, and commit while
11
+ * the snapshot bytes are still arriving. One record keeps the prelude and the
12
+ * snapshot generation-coupled by construction: a Cache API entry and a KV
13
+ * value are written and read whole, so a split read can never pair one
14
+ * capture's prelude with another's pins.
15
+ *
16
+ * Every byte is UTF-8 text when the prelude is (React's HTML through
17
+ * TextEncoder always is), so the KV value is stored as a string (the
18
+ * KVNamespace contract's `put(key, string)`) and read back as the same bytes
19
+ * with `{ type: "stream" }`.
20
+ */
21
+
22
+ import type { ShellSnapshotRecord } from "../types.js";
23
+
24
+ const MAGIC = [0x52, 0x53, 0x48, 0x31]; // "RSH1"
25
+ /** Magic (4) + head length as 8 hex digits (8). */
26
+ const PREFIX_BYTES = 12;
27
+
28
+ const textEncoder = new TextEncoder();
29
+ const textDecoder = new TextDecoder();
30
+ const strictTextDecoder = new TextDecoder("utf-8", { fatal: true });
31
+
32
+ /** The frame head: every entry field except the prelude and the snapshot. */
33
+ export interface ShellFrameHead {
34
+ /** ShellCacheEntry.reactVersion */
35
+ rv: string;
36
+ /** ShellCacheEntry.buildVersion */
37
+ bv?: string;
38
+ /** Capture-generation start time (ms epoch), used by tag marker checks. */
39
+ c: number;
40
+ /** When the entry becomes stale (ms epoch). */
41
+ s: number;
42
+ /** When the entry hard-expires (ms epoch). */
43
+ e: number;
44
+ /** Cache tags (for distributed tag invalidation). */
45
+ t?: string[];
46
+ /** Timestamp when tags were attached (ms epoch). */
47
+ ta?: number;
48
+ /** ShellCacheEntry.initialTheme */
49
+ i?: string;
50
+ /** ShellCacheEntry.docKey; navigation replay needs it after any round trip. */
51
+ dk?: string;
52
+ /** ShellCacheEntry.prunedRecords (diagnostic). */
53
+ pr?: string;
54
+ /** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
55
+ no?: true;
56
+ /** ShellCacheEntry.postponed. Absent iff `no` (no document half is stored). */
57
+ po?: string | null;
58
+ /** Prelude byte length (0 for a navigationOnly entry). */
59
+ pl: number;
60
+ /**
61
+ * Snapshot byte length (0 when the entry has none). A body whose snapshot
62
+ * part is any other length was truncated or corrupted.
63
+ */
64
+ sl: number;
65
+ }
66
+
67
+ /** Validate a parsed head before any field reaches the serve path. */
68
+ function isShellFrameHead(value: unknown): value is ShellFrameHead {
69
+ if (value == null || typeof value !== "object") return false;
70
+ const head = value as Partial<ShellFrameHead>;
71
+ return (
72
+ typeof head.rv === "string" &&
73
+ (head.bv === undefined || typeof head.bv === "string") &&
74
+ isFiniteNumber(head.c) &&
75
+ isFiniteNumber(head.s) &&
76
+ isFiniteNumber(head.e) &&
77
+ (head.t === undefined ||
78
+ (Array.isArray(head.t) &&
79
+ head.t.every((tag) => typeof tag === "string"))) &&
80
+ (head.ta === undefined || isFiniteNumber(head.ta)) &&
81
+ (head.i === undefined || typeof head.i === "string") &&
82
+ (head.dk === undefined || typeof head.dk === "string") &&
83
+ (head.pr === undefined || typeof head.pr === "string") &&
84
+ (head.no === undefined || head.no === true) &&
85
+ // Document half: postponed is required unless the entry is navigationOnly.
86
+ (head.po === null ||
87
+ typeof head.po === "string" ||
88
+ (head.po === undefined && head.no === true)) &&
89
+ isByteLength(head.pl) &&
90
+ isByteLength(head.sl)
91
+ );
92
+ }
93
+
94
+ function isByteLength(value: unknown): value is number {
95
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
96
+ }
97
+
98
+ function isFiniteNumber(value: unknown): value is number {
99
+ return typeof value === "number" && Number.isFinite(value);
100
+ }
101
+
102
+ /**
103
+ * Serialize a shell entry into one prelude-first frame. Throws when the head's
104
+ * byte lengths (`pl`, `sl`) do not describe `prelude` and `snapshot`.
105
+ */
106
+ export function encodeShellFrame(
107
+ head: ShellFrameHead,
108
+ prelude: Uint8Array,
109
+ snapshot?: Uint8Array,
110
+ ): Uint8Array<ArrayBuffer> {
111
+ const snapshotLength = snapshot?.length ?? 0;
112
+ if (head.pl !== prelude.length || head.sl !== snapshotLength) {
113
+ throw new Error("shell frame head does not match its prelude/snapshot");
114
+ }
115
+ const headBytes = textEncoder.encode(JSON.stringify(head));
116
+ const frame = new Uint8Array(
117
+ PREFIX_BYTES + headBytes.length + prelude.length + snapshotLength,
118
+ );
119
+ frame.set(MAGIC, 0);
120
+ frame.set(
121
+ textEncoder.encode(headBytes.length.toString(16).padStart(8, "0")),
122
+ MAGIC.length,
123
+ );
124
+ frame.set(headBytes, PREFIX_BYTES);
125
+ frame.set(prelude, PREFIX_BYTES + headBytes.length);
126
+ if (snapshot) {
127
+ frame.set(snapshot, PREFIX_BYTES + headBytes.length + prelude.length);
128
+ }
129
+ return frame;
130
+ }
131
+
132
+ /**
133
+ * The frame as the string KV stores. Throws when the prelude is not valid
134
+ * UTF-8, which would not round-trip through a string.
135
+ */
136
+ export function shellFrameToText(frame: Uint8Array): string {
137
+ return strictTextDecoder.decode(frame);
138
+ }
139
+
140
+ /** UTF-8 JSON of a snapshot, or undefined when there is none to store. */
141
+ export function encodeShellSnapshot(
142
+ snapshot: ShellSnapshotRecord[] | undefined,
143
+ ): Uint8Array | undefined {
144
+ return snapshot && snapshot.length > 0
145
+ ? textEncoder.encode(JSON.stringify(snapshot))
146
+ : undefined;
147
+ }
148
+
149
+ /**
150
+ * Parse the snapshot bytes that follow the prelude. Zero bytes means the entry
151
+ * carries no snapshot. Throws on anything else that is not a JSON array: the
152
+ * caller treats that as corruption.
153
+ */
154
+ export function parseShellSnapshot(
155
+ bytes: Uint8Array,
156
+ ): ShellSnapshotRecord[] | undefined {
157
+ if (bytes.length === 0) return undefined;
158
+ const parsed: unknown = JSON.parse(textDecoder.decode(bytes));
159
+ if (!Array.isArray(parsed)) {
160
+ throw new Error("shell snapshot is not an array");
161
+ }
162
+ return parsed as ShellSnapshotRecord[];
163
+ }
164
+
165
+ /**
166
+ * Incremental reader over a streamed frame (a Cache API response body or a KV
167
+ * `{ type: "stream" }` value). The serve path reads the head and exactly the
168
+ * prelude's bytes before it commits; the snapshot is whatever remains.
169
+ */
170
+ export class ShellFrameReader {
171
+ private readonly reader: ReadableStreamDefaultReader<Uint8Array>;
172
+ private chunks: Uint8Array[] = [];
173
+ private buffered = 0;
174
+ private done = false;
175
+ /** Bytes of the prefix and head, once readHead has read them. */
176
+ headBytes = 0;
177
+
178
+ constructor(body: ReadableStream<Uint8Array>) {
179
+ this.reader = body.getReader();
180
+ }
181
+
182
+ /** Exactly `n` bytes, or null when the stream ends first. */
183
+ async take(n: number): Promise<Uint8Array | null> {
184
+ while (this.buffered < n && !this.done) {
185
+ const { done, value } = await this.reader.read();
186
+ if (done) {
187
+ this.done = true;
188
+ } else if (value.length > 0) {
189
+ this.chunks.push(value);
190
+ this.buffered += value.length;
191
+ }
192
+ }
193
+ if (this.buffered < n) return null;
194
+ const out = concat(this.chunks, n);
195
+ const leftover = this.buffered - n;
196
+ const last = this.chunks[this.chunks.length - 1]!;
197
+ this.chunks = leftover > 0 ? [last.subarray(last.length - leftover)] : [];
198
+ this.buffered = leftover;
199
+ return out;
200
+ }
201
+
202
+ /** The head, or null when the magic, length, or JSON is malformed or short. */
203
+ async readHead(): Promise<ShellFrameHead | null> {
204
+ const prefix = await this.take(PREFIX_BYTES);
205
+ if (!prefix || MAGIC.some((byte, index) => prefix[index] !== byte)) {
206
+ return null;
207
+ }
208
+ const lengthDigits = textDecoder.decode(prefix.subarray(MAGIC.length));
209
+ if (!/^[0-9a-f]{8}$/.test(lengthDigits)) return null;
210
+ const headBytes = await this.take(Number.parseInt(lengthDigits, 16));
211
+ if (!headBytes) return null;
212
+ this.headBytes = PREFIX_BYTES + headBytes.length;
213
+ let head: unknown;
214
+ try {
215
+ head = JSON.parse(textDecoder.decode(headBytes));
216
+ } catch {
217
+ return null;
218
+ }
219
+ return isShellFrameHead(head) ? head : null;
220
+ }
221
+
222
+ /** Everything after what was taken, to the end of the stream. */
223
+ async readRest(): Promise<Uint8Array> {
224
+ while (!this.done) {
225
+ const { done, value } = await this.reader.read();
226
+ if (done) {
227
+ this.done = true;
228
+ } else if (value.length > 0) {
229
+ this.chunks.push(value);
230
+ this.buffered += value.length;
231
+ }
232
+ }
233
+ const out = concat(this.chunks, this.buffered);
234
+ this.chunks = [];
235
+ this.buffered = 0;
236
+ return out;
237
+ }
238
+
239
+ /** Stop reading and release the body. */
240
+ cancel(): void {
241
+ this.done = true;
242
+ this.reader.cancel().catch(() => {});
243
+ }
244
+ }
245
+
246
+ /** The first `n` bytes of `chunks` as one array (no copy for a single chunk). */
247
+ function concat(chunks: Uint8Array[], n: number): Uint8Array {
248
+ if (chunks.length === 1 && chunks[0]!.length === n) return chunks[0]!;
249
+ const out = new Uint8Array(n);
250
+ let offset = 0;
251
+ for (const chunk of chunks) {
252
+ if (offset >= n) break;
253
+ const part = chunk.subarray(0, Math.min(chunk.length, n - offset));
254
+ out.set(part, offset);
255
+ offset += part.length;
256
+ }
257
+ return out;
258
+ }
@@ -2,12 +2,12 @@
2
2
  // Tag-marker memo
3
3
  // ============================================================================
4
4
  //
5
- // Per-request memoization of tag-invalidation marker reads, plus the prefix and
6
- // sentinel used by the optional per-colo L1 marker cache. Extracted from
7
- // cf-cache-store.ts; the WeakMaps stay MODULE SINGLETONS here (the module is
8
- // evaluated once), so binding-keyed memo semantics are unchanged: the maps are
9
- // keyed by the request-context object then the store INSTANCE, identical to
10
- // before the move.
5
+ // CFCacheStore's per-request tag-marker state: the data families' marker memo
6
+ // and in-flight reads, the PPR shell read's own marker record, and the prefix
7
+ // and sentinel of the optional per-colo L1 marker cache. The maps are module
8
+ // singletons keyed by the request-context object, then the store INSTANCE.
9
+
10
+ import type { MarkerMemoOutcome } from "../isolate-tag-memo.js";
11
11
 
12
12
  /**
13
13
  * Cache-API path prefix for the optional per-colo L1 cache of tag-invalidation
@@ -24,6 +24,32 @@ export const TAG_MARKER_CACHE_PREFIX = "__tagmarker__/";
24
24
  */
25
25
  export const TAG_MARKER_ABSENT = "none";
26
26
 
27
+ /**
28
+ * Request context -> store instance -> T, both levels weakly held. Also
29
+ * VercelCacheStore's request tag mask (#973).
30
+ */
31
+ export type PerRequestStoreMap<T> = WeakMap<object, WeakMap<object, T>>;
32
+
33
+ /** The value for (ctx, store), created on first use. */
34
+ export function perRequestStoreValue<T>(
35
+ map: PerRequestStoreMap<T>,
36
+ ctx: object,
37
+ store: object,
38
+ create: () => T,
39
+ ): T {
40
+ let byStore = map.get(ctx);
41
+ if (!byStore) {
42
+ byStore = new WeakMap();
43
+ map.set(ctx, byStore);
44
+ }
45
+ let value = byStore.get(store);
46
+ if (value === undefined) {
47
+ value = create();
48
+ byStore.set(store, value);
49
+ }
50
+ return value;
51
+ }
52
+
27
53
  /**
28
54
  * Per-request memo of tag-invalidation markers (tag -> latest invalidatedAt, or
29
55
  * null when no marker exists). Keyed first by the request context object (so it
@@ -42,38 +68,97 @@ export const TAG_MARKER_ABSENT = "none";
42
68
  * short-circuits when a store has no KV — and, in purge mode, through
43
69
  * isL1Invalidated(), which consults ONLY this memo on an L1 hit (no KV read),
44
70
  * so a purge-mode store allocates the memo even without KV for same-request
45
- * read-your-own-writes after updateTag().
71
+ * read-your-own-writes after updateTag()/revalidateTag().
46
72
  *
47
73
  * Without the memo, isGloballyInvalidated() issues a KV read per tag on every
48
74
  * tagged cache read, so a page composed of many segments/items sharing a tag
49
75
  * pays that cost N times. The memo collapses it to one KV read per distinct tag
50
- * per (request, store). invalidateTags() writes through so a same-request
51
- * updateTag() stays read-your-own-writes consistent (the action's own re-render
52
- * sees its own invalidation from the memo, without a re-read).
76
+ * per (request, store). invalidateTags() writes the new marker here before its
77
+ * first await (maskRequestTags, #973), so the request that ran updateTag() or
78
+ * revalidateTag() reads its own writes: the action's own re-render sees its
79
+ * invalidation from the memo, without waiting for the KV put that
80
+ * revalidateTag() leaves to waitUntil.
53
81
  *
54
82
  * It does NOT span requests, so a hot single-entry route still pays one KV read
55
83
  * per request; that read hits Cloudflare KV's own edge read cache for hot keys.
84
+ * A context derived from the request (Object.create(reqCtx): a PPR HIT tail, a
85
+ * shell capture) keeps a memo of its own, so its marker reads stay its own; it
86
+ * starts from the request's mask (requestMasks) instead of empty, and takes
87
+ * every mask the request sets later (requestMemos).
56
88
  */
57
- const tagMarkerMemo = new WeakMap<
58
- object,
59
- WeakMap<object, Map<string, number | null>>
60
- >();
89
+ const tagMarkerMemo: PerRequestStoreMap<Map<string, number | null>> =
90
+ new WeakMap();
91
+
92
+ /**
93
+ * The tags each request invalidated through each store, with the
94
+ * invalidatedAt its mask carries (#973). Keyed by the request's root context
95
+ * (RequestContext._requestRoot) rather than by the context that called, so a
96
+ * derived context sees the request's mask and a mask it sets reaches the
97
+ * request. Its values are unconfirmed until the KV put lands: a marker read
98
+ * that finds one of them publishes nothing to other requests
99
+ * (CFCacheStore.fetchTagMarker).
100
+ */
101
+ const requestMasks: PerRequestStoreMap<Map<string, number>> = new WeakMap();
102
+
103
+ /**
104
+ * Every memo of a request, whichever context owns it, per (root, store): a
105
+ * mask set later reaches the memos that already exist (maskRequestTags), not
106
+ * only the caller's. They live as long as the request does.
107
+ */
108
+ const requestMemos: PerRequestStoreMap<Set<Map<string, number | null>>> =
109
+ new WeakMap();
110
+
111
+ function requestRoot(ctx: object): object {
112
+ return (ctx as { _requestRoot?: object })._requestRoot ?? ctx;
113
+ }
61
114
 
62
115
  export function getTagMarkerMemo(
63
116
  ctx: object,
64
117
  store: object,
65
118
  ): Map<string, number | null> {
66
- let byStore = tagMarkerMemo.get(ctx);
67
- if (!byStore) {
68
- byStore = new WeakMap();
69
- tagMarkerMemo.set(ctx, byStore);
70
- }
71
- let memo = byStore.get(store);
72
- if (!memo) {
73
- memo = new Map();
74
- byStore.set(store, memo);
119
+ return perRequestStoreValue(tagMarkerMemo, ctx, store, () => {
120
+ const root = requestRoot(ctx);
121
+ const memo = new Map<string, number | null>(
122
+ requestMasks.get(root)?.get(store),
123
+ );
124
+ perRequestStoreValue(requestMemos, root, store, () => new Set()).add(memo);
125
+ return memo;
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Mask `tags` at `at` for the rest of the request: in the request's mask and
131
+ * in every memo the request's contexts hold for `store` (a memo created later
132
+ * starts from the mask).
133
+ */
134
+ export function maskRequestTags(
135
+ ctx: object,
136
+ store: object,
137
+ tags: readonly string[],
138
+ at: number,
139
+ ): void {
140
+ const root = requestRoot(ctx);
141
+ const mask = perRequestStoreValue(
142
+ requestMasks,
143
+ root,
144
+ store,
145
+ () => new Map<string, number>(),
146
+ );
147
+ getTagMarkerMemo(ctx, store);
148
+ const memos = requestMemos.get(root)?.get(store) ?? [];
149
+ for (const tag of tags) {
150
+ mask.set(tag, at);
151
+ for (const memo of memos) memo.set(tag, at);
75
152
  }
76
- return memo;
153
+ }
154
+
155
+ /** Whether this request masked `tag` through `store` (maskRequestTags). */
156
+ export function isRequestMasked(
157
+ ctx: object,
158
+ store: object,
159
+ tag: string,
160
+ ): boolean {
161
+ return requestMasks.get(requestRoot(ctx))?.get(store)?.has(tag) ?? false;
77
162
  }
78
163
 
79
164
  /**
@@ -85,24 +170,43 @@ export function getTagMarkerMemo(
85
170
  * collapses those to a single KV read. Entries are dropped once resolved (the
86
171
  * value is then in the memo), so this only spans the concurrent read window.
87
172
  */
88
- const tagMarkerInflight = new WeakMap<
89
- object,
90
- WeakMap<object, Map<string, Promise<number | null>>>
91
- >();
173
+ const tagMarkerInflight: PerRequestStoreMap<
174
+ Map<string, Promise<number | null>>
175
+ > = new WeakMap();
176
+
177
+ /**
178
+ * A request's PPR shell-read marker reads (issue #941), kept apart from the
179
+ * per-request memo above, HIT or MISS. A shell read may answer a marker from
180
+ * the isolate marker memo (isolate-tag-memo.ts), up to `markerMaxStaleMs`
181
+ * old; the data families must never see that value. Scar tissue: first the
182
+ * shell read wrote into the per-request memo, so a MISS request rendered and
183
+ * stored its `"use cache"` data under a stale marker; then a HIT copied its
184
+ * entry's tags across, which a partial navigation's matchPartial (on the same
185
+ * context as its replay gate's getShell) read, and a `cache()` segment it
186
+ * wrote kept the stale item past `markerMaxStaleMs` under a fresh taggedAt.
187
+ */
188
+ export interface ShellMarkerReads {
189
+ /** The read of each tag, settled or in flight (collapses concurrent reads). */
190
+ reads: Map<string, Promise<number | null>>;
191
+ /** How the isolate memo answered each tag (the marker row's `memo=`). */
192
+ outcomes: Map<string, MarkerMemoOutcome>;
193
+ }
194
+
195
+ const shellMarkerReads: PerRequestStoreMap<ShellMarkerReads> = new WeakMap();
196
+
197
+ export function getShellMarkerReads(
198
+ ctx: object,
199
+ store: object,
200
+ ): ShellMarkerReads {
201
+ return perRequestStoreValue(shellMarkerReads, ctx, store, () => ({
202
+ reads: new Map(),
203
+ outcomes: new Map(),
204
+ }));
205
+ }
92
206
 
93
207
  export function getTagMarkerInflight(
94
208
  ctx: object,
95
209
  store: object,
96
210
  ): Map<string, Promise<number | null>> {
97
- let byStore = tagMarkerInflight.get(ctx);
98
- if (!byStore) {
99
- byStore = new WeakMap();
100
- tagMarkerInflight.set(ctx, byStore);
101
- }
102
- let inflight = byStore.get(store);
103
- if (!inflight) {
104
- inflight = new Map();
105
- byStore.set(store, inflight);
106
- }
107
- return inflight;
211
+ return perRequestStoreValue(tagMarkerInflight, ctx, store, () => new Map());
108
212
  }
@@ -140,9 +140,10 @@ export function captureHandleOwners(
140
140
  * cache() record owns its segments' arrays, so it REPLACES them.
141
141
  *
142
142
  * A segment with `owners` is replaced with empty arrays and re-pushed in
143
- * recorded order, an owned value through pushReplayed: the owning loader
144
- * re-runs on the HIT (loaders stay live), and its live pushes replace the
145
- * recorded ones in place instead of appending a second copy. `claim`
143
+ * recorded order, an owned value through pushReplayed: if the owning loader
144
+ * runs on the HIT (a live loader, or a promise-carrying `ssr: false` one;
145
+ * a promise-free one is served from the shell and does not), its live pushes
146
+ * replace the recorded ones in place instead of appending a second copy. `claim`
146
147
  * (setupLoaderAccess _claimLoaderPushes) is asked once per owner, so the
147
148
  * loader's own cache() HIT does not replay its pushes a second time
148
149
  * (loader-cache.ts replayLoaderHandles); an owner it refuses is skipped. A
@@ -13,6 +13,8 @@ export { CACHE_READ_ERROR } from "./types.js";
13
13
 
14
14
  export { MemorySegmentCacheStore } from "./memory-segment-store.js";
15
15
 
16
+ export type { StoreMemoOptions } from "./shell-memo.js";
17
+
16
18
  export {
17
19
  CFCacheStore,
18
20
  type CFCacheStoreOptions,