@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
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Per-isolate tag-marker memos shared by the built-in stores (issue #941;
3
+ * docs/design/shell-entry-layout.md "The tag-marker memo").
4
+ *
5
+ * A tagged shell HIT waits for its tag-invalidation markers before the first
6
+ * byte. Two things here shorten that wait:
7
+ *
8
+ * - {@link TagNameHints}: which tag NAMES a shell key carried last time, so a
9
+ * HIT can start the marker reads alongside the entry read instead of after
10
+ * the entry's head names its tags. Never marker values.
11
+ * - {@link TagMarkerMemo}: the marker VALUES (a tag's latest invalidatedAt,
12
+ * or null), served stale-while-revalidate. A value younger than
13
+ * `markerFreshMs` is used as is; one younger than `markerMaxStaleMs` is used
14
+ * and refreshed from the store in the background; an older one blocks on
15
+ * the store read as before.
16
+ *
17
+ * Simple reads get SWR; a mutation gets correct reads. The request that runs
18
+ * updateTag()/revalidateTag() reads its own writes through the per-request
19
+ * memo; the isolate that runs it writes the new value through into this memo;
20
+ * and its response sets the fresh-reads cookie (request-context.ts), so the
21
+ * same user's next requests on OTHER isolates skip this memo and the shell
22
+ * memo for as long as either can be stale. Other users can see a marker up to
23
+ * `markerMaxStaleMs` old, on top of the platform's own consistency
24
+ * (Cloudflare KV is eventually consistent across locations, up to about 60 s;
25
+ * Vercel's `tm` markers are regional).
26
+ *
27
+ * Only PPR shell reads use the value memo. Cached data families (`cache()`,
28
+ * `"use cache"`, responses) and the shell write gate
29
+ * (`isTagsInvalidatedSince`) keep reading their markers, in every request,
30
+ * whatever its shell read found: a shell read's memoized values never reach
31
+ * the data families' per-request memo (cf-tag-marker-memo.ts
32
+ * ShellMarkerReads).
33
+ */
34
+
35
+ import type { RequestContext } from "../server/request-context.js";
36
+ import type { ShellReadStats } from "./types.js";
37
+
38
+ /** Default freshness of a memoized marker on CFCacheStore (ms). */
39
+ export const DEFAULT_CF_MARKER_FRESH_MS = 1000;
40
+ /** Default freshness of a memoized marker on VercelCacheStore (ms). */
41
+ export const DEFAULT_VERCEL_MARKER_FRESH_MS = 300;
42
+ /** Default oldest memoized marker served while it refreshes (ms), CF. */
43
+ export const DEFAULT_CF_MARKER_MAX_STALE_MS = 10_000;
44
+ /**
45
+ * The same cap on VercelCacheStore: the runtime cache expires tags globally
46
+ * within about 300 ms, so a much longer stale window would widen that for
47
+ * other users more than KV's own up-to-60 s cross-location lag does on CF.
48
+ */
49
+ export const DEFAULT_VERCEL_MARKER_MAX_STALE_MS = 2000;
50
+ /** Most tags a marker memo holds (least recently used evicted). */
51
+ const MARKER_MEMO_MAX_ENTRIES = 4096;
52
+ /**
53
+ * Most shell keys a hint map holds (least recently used evicted).
54
+ * @internal Exported for tests that fill the map to its cap.
55
+ */
56
+ export const TAG_HINTS_MAX_ENTRIES = 2048;
57
+
58
+ /** How a marker read was answered, for the debugPerformance marker row. */
59
+ export type MarkerMemoOutcome = "fresh" | "stale" | "read" | "bypass";
60
+
61
+ interface MarkerSlot {
62
+ value: number | null;
63
+ readAt: number;
64
+ }
65
+
66
+ /** A memoized marker a read may use: its value, and whether it is stale. */
67
+ export interface MarkerMemoHit {
68
+ value: number | null;
69
+ stale: boolean;
70
+ }
71
+
72
+ /**
73
+ * A store read of a marker for {@link TagMarkerMemo.readThrough}: the value,
74
+ * and whether it may be memoized (a timed-out read that failed open may not).
75
+ */
76
+ export interface MarkerFetch {
77
+ value: number | null;
78
+ memoize: boolean;
79
+ }
80
+
81
+ /** Options of {@link TagMarkerMemo.readThrough}. */
82
+ export interface MarkerReadThroughOptions {
83
+ /** Fresh window (ms); 0 or less reads the store and memoizes nothing. */
84
+ freshMs: number;
85
+ /** The oldest value served while it refreshes (ms). */
86
+ maxStaleMs: number;
87
+ /** The request carries the fresh-reads cookie: read the store. */
88
+ bypass: boolean;
89
+ /** Keeps a background refresh alive after the response (waitUntil). */
90
+ keepAlive?: (pending: Promise<void>) => void;
91
+ /** How the read was answered, for the debugPerformance marker row. */
92
+ onOutcome?: (outcome: MarkerMemoOutcome) => void;
93
+ }
94
+
95
+ export class TagMarkerMemo {
96
+ private readonly slots = new Map<string, MarkerSlot>();
97
+ private readonly refreshing = new Map<string, Promise<void>>();
98
+
99
+ constructor(private readonly maxEntries: number = MARKER_MEMO_MAX_ENTRIES) {}
100
+
101
+ /**
102
+ * The memoized marker for `key` if it is younger than `maxStaleMs` (stale
103
+ * when older than `freshMs`), or undefined when the caller must read the
104
+ * store. `freshMs <= 0` disables the memo.
105
+ */
106
+ lookup(
107
+ key: string,
108
+ freshMs: number,
109
+ maxStaleMs: number,
110
+ now: number = Date.now(),
111
+ ): MarkerMemoHit | undefined {
112
+ if (freshMs <= 0) return undefined;
113
+ const slot = this.slots.get(key);
114
+ if (!slot) return undefined;
115
+ const age = now - slot.readAt;
116
+ if (age >= Math.max(freshMs, maxStaleMs)) return undefined;
117
+ this.slots.delete(key);
118
+ this.slots.set(key, slot);
119
+ return { value: slot.value, stale: age >= freshMs };
120
+ }
121
+
122
+ /**
123
+ * Record a marker the store returned to a read that started at
124
+ * `readStartedAt`. The age counts from the read's start, not its end: a
125
+ * marker written while the read was in flight may be missing from it, so
126
+ * the value is only known to be current as of the start. Markers only move
127
+ * forward, so a value older than one already held (a write-through that
128
+ * landed during the read) is kept at the newer value, with the later stamp.
129
+ */
130
+ store(
131
+ key: string,
132
+ value: number | null,
133
+ readStartedAt: number = Date.now(),
134
+ ): void {
135
+ const held = this.slots.get(key);
136
+ const heldValue = held?.value ?? null;
137
+ const next =
138
+ heldValue !== null && (value === null || heldValue > value)
139
+ ? heldValue
140
+ : value;
141
+ this.slots.delete(key);
142
+ this.slots.set(key, {
143
+ value: next,
144
+ readAt: Math.max(held?.readAt ?? readStartedAt, readStartedAt),
145
+ });
146
+ if (this.slots.size > this.maxEntries) {
147
+ const oldest = this.slots.keys().next().value as string;
148
+ this.slots.delete(oldest);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Refresh a stale marker in the background: one read per key at a time,
154
+ * handed to `keepAlive` (the store's waitUntil) so the runtime lets it
155
+ * finish after the response; without it the read runs detached. A read
156
+ * that fails, or may not be memoized, stores nothing.
157
+ */
158
+ refresh(
159
+ key: string,
160
+ read: () => Promise<MarkerFetch>,
161
+ keepAlive?: (pending: Promise<void>) => void,
162
+ ): void {
163
+ if (this.refreshing.has(key)) return;
164
+ const pending = (async (): Promise<void> => {
165
+ const startedAt = Date.now();
166
+ try {
167
+ const { value, memoize } = await read();
168
+ if (memoize) this.store(key, value, startedAt);
169
+ } catch {
170
+ // The next read past the stale window blocks on the store anyway.
171
+ } finally {
172
+ this.refreshing.delete(key);
173
+ }
174
+ })();
175
+ this.refreshing.set(key, pending);
176
+ keepAlive?.(pending);
177
+ }
178
+
179
+ /**
180
+ * A marker through the memo, stale-while-revalidate: a value within
181
+ * `freshMs` is used as is; one within `maxStaleMs` is used while one
182
+ * background read refreshes it; otherwise, and for a request carrying the
183
+ * fresh-reads cookie, the store is read (`fetch(false)`) and the result
184
+ * memoized. `fetch(true)` is the background refresh, outside any request.
185
+ */
186
+ async readThrough(
187
+ key: string,
188
+ fetch: (background: boolean) => Promise<MarkerFetch>,
189
+ options: MarkerReadThroughOptions,
190
+ ): Promise<number | null> {
191
+ if (options.freshMs <= 0) return (await fetch(false)).value;
192
+ if (!options.bypass) {
193
+ const hit = this.lookup(key, options.freshMs, options.maxStaleMs);
194
+ if (hit) {
195
+ options.onOutcome?.(hit.stale ? "stale" : "fresh");
196
+ if (hit.stale) this.refresh(key, () => fetch(true), options.keepAlive);
197
+ return hit.value;
198
+ }
199
+ }
200
+ const startedAt = Date.now();
201
+ const { value, memoize } = await fetch(false);
202
+ if (memoize) this.store(key, value, startedAt);
203
+ options.onOutcome?.(options.bypass ? "bypass" : "read");
204
+ return value;
205
+ }
206
+
207
+ clear(): void {
208
+ this.slots.clear();
209
+ this.refreshing.clear();
210
+ }
211
+ }
212
+
213
+ /**
214
+ * The tag names a shell key's entry carried when it was last read or written
215
+ * in this isolate, so the next HIT can start their marker reads before the
216
+ * entry names them. A wrong or missing hint costs a wasted read or nothing;
217
+ * the freshness check always uses the entry's own tags.
218
+ */
219
+ export class TagNameHints {
220
+ private readonly keys = new Map<string, readonly string[]>();
221
+
222
+ constructor(private readonly maxEntries: number = TAG_HINTS_MAX_ENTRIES) {}
223
+
224
+ get(key: string): readonly string[] | undefined {
225
+ return this.keys.get(key);
226
+ }
227
+
228
+ remember(key: string, tags: readonly string[] | undefined): void {
229
+ this.keys.delete(key);
230
+ if (!tags || tags.length === 0) return;
231
+ this.keys.set(key, tags);
232
+ if (this.keys.size > this.maxEntries) {
233
+ const oldest = this.keys.keys().next().value as string;
234
+ this.keys.delete(oldest);
235
+ }
236
+ }
237
+
238
+ clear(): void {
239
+ this.keys.clear();
240
+ }
241
+ }
242
+
243
+ /** The union of a key's remembered tag names and the route's static tags. */
244
+ export function hintedTags(
245
+ remembered: readonly string[] | undefined,
246
+ routeTags: readonly string[] | undefined,
247
+ ): string[] {
248
+ if (!routeTags || routeTags.length === 0)
249
+ return remembered ? [...remembered] : [];
250
+ if (!remembered || remembered.length === 0) return [...routeTags];
251
+ return [...new Set([...remembered, ...routeTags])];
252
+ }
253
+
254
+ /**
255
+ * The marker row's memo outcome for a read's tags: the most store-bound one
256
+ * (a bypass or store read outranks a stale value, which outranks a fresh one).
257
+ */
258
+ export function summarizeMarkerOutcomes(
259
+ tags: readonly string[] | undefined,
260
+ outcomes: ReadonlyMap<string, MarkerMemoOutcome>,
261
+ ): MarkerMemoOutcome | undefined {
262
+ let summary: MarkerMemoOutcome | undefined;
263
+ for (const tag of tags ?? []) {
264
+ const outcome = outcomes.get(tag);
265
+ if (
266
+ outcome &&
267
+ (!summary || OUTCOME_RANK[outcome] > OUTCOME_RANK[summary])
268
+ ) {
269
+ summary = outcome;
270
+ }
271
+ }
272
+ return summary;
273
+ }
274
+
275
+ const OUTCOME_RANK: Record<MarkerMemoOutcome, number> = {
276
+ fresh: 0,
277
+ stale: 1,
278
+ read: 2,
279
+ bypass: 3,
280
+ };
281
+
282
+ /**
283
+ * Fill a shell read's marker-row stats (debugPerformance) for the entry's
284
+ * own tags: the memo outcome, and how many of them the read had hinted.
285
+ */
286
+ export function recordMarkerRow(
287
+ stats: ShellReadStats,
288
+ tags: readonly string[] | undefined,
289
+ outcomes: ReadonlyMap<string, MarkerMemoOutcome> | undefined,
290
+ ): void {
291
+ if (!tags || tags.length === 0) return;
292
+ if (outcomes) stats.markerMemo = summarizeMarkerOutcomes(tags, outcomes);
293
+ if (stats.markerHinted) {
294
+ const hinted = stats.markerHinted;
295
+ stats.markerHintHits = tags.filter((tag) => hinted.includes(tag)).length;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * True when this request carries the fresh-reads cookie: a request of the same
301
+ * user after an updateTag()/revalidateTag(), which must not be served from an
302
+ * isolate memo (shell or marker) that could predate the mutation.
303
+ */
304
+ export function freshReadsRequired(
305
+ ctx: Pick<RequestContext, "_freshReads"> | undefined,
306
+ ): boolean {
307
+ return ctx?._freshReads === true;
308
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Per-isolate memo of fresh PPR shell reads (issue #941, decision 1;
3
+ * docs/design/shell-entry-layout.md).
4
+ *
5
+ * A warm isolate serving the same shell many times a second re-reads, and
6
+ * re-parses, the same stored entry on every HIT. The store keeps the last
7
+ * fresh read per key for a short window (`memo.shellMs`) and serves the next
8
+ * HIT from memory. What the memo does NOT skip is the part that decides
9
+ * whether the entry may be served: the store still runs its tag-marker check
10
+ * on every read, so with KV bound `updateTag()` / `revalidateTag()` stay
11
+ * immediate across isolates. The isolate that runs the invalidation drops its
12
+ * own copies and keeps reads that were in flight from memoizing them again
13
+ * (RecentTagInvalidations). What another isolate can serve for up to one
14
+ * window: a newer capture of the same key; a KV-less purge-mode purge; on
15
+ * Vercel, an invalidation from another region (tag markers are regional) and
16
+ * a platform `expireTag` issued outside rango (it writes no tag marker).
17
+ *
18
+ * Store instances are usually created per request, so the memo must outlive
19
+ * them: CFCacheStore keeps one module-level memo per isolate, VercelCacheStore
20
+ * one per runtime-cache handle (the handle carries the namespace). One byte
21
+ * budget (`memo.shellMaxBytes`) caps a memo; storing sweeps entries past their
22
+ * window, then evicts the least recently used. Measured on a 3.2 MB shell
23
+ * (Node 22 heap): CFCacheStore holds 1.08x what it counts (prelude plus
24
+ * snapshot JSON); VercelCacheStore holds 0.93x (envelope JSON plus the
25
+ * decoded prelude).
26
+ */
27
+
28
+ /**
29
+ * Per-isolate memos a built-in store keeps (`CFCacheStore`: one per isolate;
30
+ * `VercelCacheStore`: one per `cache` handle). A window of 0 turns that memo
31
+ * off.
32
+ */
33
+ export interface StoreMemoOptions {
34
+ /**
35
+ * PPR shell memo window (ms): after a shell read, the next HITs of that key
36
+ * within the window are served from memory, with no store read and no
37
+ * parse. Only a fresh shell whose snapshot read completed is kept. The
38
+ * tag-marker check still runs on every HIT, and the isolate that runs
39
+ * `updateTag()` / `revalidateTag()` drops its own copies, so neither that
40
+ * request nor a later one on the same isolate gets the invalidated shell.
41
+ * What ANOTHER isolate can serve for up to one window:
42
+ * - a newer capture of the same key;
43
+ * - `CFCacheStore` without KV, in purge mode: a purged shell. The purge
44
+ * removes the stored entry, not other isolates' memos;
45
+ * - `VercelCacheStore`: an invalidation from another region (the tag
46
+ * markers are a regional `cache.set`; only `expireTag` is global), and a
47
+ * platform `expireTag` issued outside rango.
48
+ *
49
+ * The mutating user is not served these: the response of a request that
50
+ * ran `updateTag()` / `revalidateTag()` sets the fresh-reads cookie, and
51
+ * requests carrying it skip the memos. Set `{ shellMs: 0, markerFreshMs: 0 }`
52
+ * where every user's next request must see an invalidation (with KV,
53
+ * `CFCacheStore`'s marker memo alone can serve an invalidated shell for up
54
+ * to `markerMaxStaleMs`). Default 2000.
55
+ */
56
+ shellMs?: number;
57
+ /**
58
+ * Total bytes (prelude plus snapshot) the shell memo may hold; the least
59
+ * recently used shells are evicted first, and a shell larger than the whole
60
+ * budget is not kept. Default 16 MiB. 0 turns the memo off.
61
+ */
62
+ shellMaxBytes?: number;
63
+ /**
64
+ * Tag-marker memo for PPR shell reads: a tag's invalidation marker read
65
+ * within this many ms is used without a store read. Default 1000 on
66
+ * `CFCacheStore` (with KV; without KV there are no markers), 300 on
67
+ * `VercelCacheStore`. 0 turns the marker memo off. Cached data families
68
+ * (`cache()`, `"use cache"`, responses) keep reading their markers.
69
+ */
70
+ markerFreshMs?: number;
71
+ /**
72
+ * The oldest memoized marker a shell read still uses while a background
73
+ * read refreshes it; an older one blocks on the store read. Default 10000
74
+ * on `CFCacheStore`, 2000 on `VercelCacheStore`. The isolate that runs
75
+ * `updateTag()` / `revalidateTag()` writes the new marker into its memo,
76
+ * and the response sets the fresh-reads cookie, so the same user's next
77
+ * requests skip both memos on any isolate.
78
+ */
79
+ markerMaxStaleMs?: number;
80
+ }
81
+
82
+ /** Default memo window (ms). */
83
+ export const DEFAULT_SHELL_MEMO_MS = 2000;
84
+
85
+ /** Default total memo size per isolate (bytes). */
86
+ export const DEFAULT_SHELL_MEMO_MAX_BYTES: number = 16 * 1024 * 1024;
87
+
88
+ /** A store's memo options, validated. */
89
+ export interface ResolvedShellMemoOptions {
90
+ shellMs: number;
91
+ shellMaxBytes: number;
92
+ markerFreshMs: number;
93
+ markerMaxStaleMs: number;
94
+ }
95
+
96
+ /**
97
+ * Validate a store's `memo` option; the marker memo's defaults are per store
98
+ * (isolate-tag-memo.ts). A non-finite value (`NaN` from `Number(env.UNSET)`,
99
+ * `Infinity`, null) falls back to the default, like the CFCacheStore read
100
+ * budgets; a finite 0 or negative passes through and turns that memo off.
101
+ */
102
+ export function resolveShellMemoOptions(
103
+ memo: StoreMemoOptions | undefined,
104
+ markerDefaults: { markerFreshMs: number; markerMaxStaleMs: number },
105
+ ): ResolvedShellMemoOptions {
106
+ const finite = (value: number | undefined, fallback: number): number =>
107
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
108
+ return {
109
+ shellMs: finite(memo?.shellMs, DEFAULT_SHELL_MEMO_MS),
110
+ shellMaxBytes: finite(memo?.shellMaxBytes, DEFAULT_SHELL_MEMO_MAX_BYTES),
111
+ markerFreshMs: finite(memo?.markerFreshMs, markerDefaults.markerFreshMs),
112
+ markerMaxStaleMs: finite(
113
+ memo?.markerMaxStaleMs,
114
+ markerDefaults.markerMaxStaleMs,
115
+ ),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Headroom on the fresh-reads cookie's lifetime: revalidateTag() writes its
121
+ * markers in the background, so they can land after the response that sets
122
+ * the cookie, and the memos' windows count from then.
123
+ */
124
+ export const FRESH_READS_MARGIN_MS = 1000;
125
+
126
+ /**
127
+ * How long the fresh-reads cookie keeps a mutating user off a store's memos:
128
+ * the longest either memo can still hold a value from before the mutation
129
+ * (the shell window; the marker memo's max-stale cap when the store reads
130
+ * markers through it, counted from the start of the read that filled it),
131
+ * plus FRESH_READS_MARGIN_MS. 0 when both memos are off: no cookie.
132
+ */
133
+ export function freshReadsWindowMs(
134
+ memo: ResolvedShellMemoOptions,
135
+ markerMemo: boolean,
136
+ ): number {
137
+ const window = Math.max(
138
+ memo.shellMs > 0 && memo.shellMaxBytes > 0 ? memo.shellMs : 0,
139
+ markerMemo && memo.markerFreshMs > 0
140
+ ? Math.max(memo.markerFreshMs, memo.markerMaxStaleMs)
141
+ : 0,
142
+ );
143
+ return window > 0 ? window + FRESH_READS_MARGIN_MS : 0;
144
+ }
145
+
146
+ /**
147
+ * Whether a shell is neither stale nor expired: the only kind a memo keeps or
148
+ * serves (a stale read stays with the store, which schedules the recapture).
149
+ */
150
+ export function isShellFresh(
151
+ staleAt: number,
152
+ expiresAt: number,
153
+ now: number = Date.now(),
154
+ ): boolean {
155
+ return !(staleAt > 0 && now > staleAt) && now <= expiresAt;
156
+ }
157
+
158
+ /** Whether a shell's tags include any of `tags`. */
159
+ export function shellHasAnyTag(
160
+ shellTags: readonly string[] | undefined,
161
+ tags: readonly string[],
162
+ ): boolean {
163
+ return shellTags?.some((tag) => tags.includes(tag)) === true;
164
+ }
165
+
166
+ interface ShellMemoSlot<T> {
167
+ value: T;
168
+ bytes: number;
169
+ storedAt: number;
170
+ /** storedAt + the storing store's window: past it, set() sweeps the slot. */
171
+ expiresAt: number;
172
+ }
173
+
174
+ export class ShellMemo<T> {
175
+ private readonly slots = new Map<string, ShellMemoSlot<T>>();
176
+ private totalBytes = 0;
177
+
178
+ /**
179
+ * The value stored under `key` less than `windowMs` ago, or undefined.
180
+ * A hit becomes the most recently used entry.
181
+ */
182
+ get(key: string, windowMs: number, now: number = Date.now()): T | undefined {
183
+ if (windowMs <= 0) return undefined;
184
+ const slot = this.slots.get(key);
185
+ if (!slot) return undefined;
186
+ this.slots.delete(key);
187
+ if (now - slot.storedAt >= windowMs) {
188
+ this.totalBytes -= slot.bytes;
189
+ return undefined;
190
+ }
191
+ this.slots.set(key, slot);
192
+ return slot.value;
193
+ }
194
+
195
+ /**
196
+ * Store `value` (`bytes` counted against `maxBytes`). Entries past their own
197
+ * window are dropped first, so a key that is never read again does not hold
198
+ * memory until the cap pushes it out; then the least recently used entries
199
+ * are evicted until the value fits. A value larger than the whole budget, or
200
+ * a window of 0, is not stored.
201
+ */
202
+ set(
203
+ key: string,
204
+ value: T,
205
+ bytes: number,
206
+ windowMs: number,
207
+ maxBytes: number,
208
+ now: number = Date.now(),
209
+ ): void {
210
+ this.delete(key);
211
+ if (windowMs <= 0 || bytes > maxBytes) return;
212
+ for (const [other, slot] of this.slots) {
213
+ if (now >= slot.expiresAt) this.delete(other);
214
+ }
215
+ for (const [oldest, slot] of this.slots) {
216
+ if (this.totalBytes + bytes <= maxBytes) break;
217
+ this.slots.delete(oldest);
218
+ this.totalBytes -= slot.bytes;
219
+ }
220
+ this.slots.set(key, {
221
+ value,
222
+ bytes,
223
+ storedAt: now,
224
+ expiresAt: now + windowMs,
225
+ });
226
+ this.totalBytes += bytes;
227
+ }
228
+
229
+ delete(key: string): void {
230
+ const slot = this.slots.get(key);
231
+ if (!slot) return;
232
+ this.slots.delete(key);
233
+ this.totalBytes -= slot.bytes;
234
+ }
235
+
236
+ /** Drop every entry `predicate` selects. */
237
+ deleteWhere(predicate: (value: T) => boolean): void {
238
+ for (const [key, slot] of this.slots) {
239
+ if (predicate(slot.value)) this.delete(key);
240
+ }
241
+ }
242
+
243
+ /** Bytes currently held. */
244
+ get size(): number {
245
+ return this.totalBytes;
246
+ }
247
+
248
+ clear(): void {
249
+ this.slots.clear();
250
+ this.totalBytes = 0;
251
+ }
252
+ }
253
+
254
+ interface TagInvalidationRecord {
255
+ /** The latest invalidation's timestamp. */
256
+ at: number;
257
+ /** Invalidations of the tag still writing their markers or purge. */
258
+ pending: number;
259
+ /** When the record may be dropped once nothing is pending. */
260
+ heldUntil: number;
261
+ }
262
+
263
+ /**
264
+ * Tags this isolate invalidated recently, and when. `invalidateTags()` drops
265
+ * the isolate's memoized shells for its tags at once, but a read that was in
266
+ * flight (its store read done, its snapshot still arriving) can memoize one
267
+ * of them again while the markers or the purge are still being written. With
268
+ * KV that copy is harmless (every memo hit reads the markers); in KV-less
269
+ * purge mode the memo-hit check sees only the request's own marker memo, so
270
+ * nothing would reject it for a whole window, and the mutating user's next
271
+ * request on this isolate got the invalidated shell.
272
+ *
273
+ * A record lives from the invalidation's start until `holdMs` after it
274
+ * settles, which covers a read in flight when it settled (its snapshot read
275
+ * is bounded by the store's read budget, 170 ms by default on CFCacheStore).
276
+ * While it lives, a shell carrying the tag and tagged at or before the
277
+ * invalidation is neither memoized nor served from the memo. It only adds
278
+ * rejections: a shell tagged after the invalidation is not affected, and no
279
+ * marker read is skipped. Records are per tag across namespaces, like the
280
+ * memo drop in `invalidateTags()`: another namespace's shell with the same
281
+ * tag skips the memo for the record's life.
282
+ */
283
+ export class RecentTagInvalidations {
284
+ private readonly records = new Map<string, TagInvalidationRecord>();
285
+
286
+ /** An invalidation of `tags` at `at` started. */
287
+ begin(tags: readonly string[], at: number, now: number = Date.now()): void {
288
+ for (const [tag, record] of this.records) {
289
+ if (record.pending === 0 && now >= record.heldUntil) {
290
+ this.records.delete(tag);
291
+ }
292
+ }
293
+ for (const tag of tags) {
294
+ const record = this.records.get(tag);
295
+ if (record) {
296
+ record.at = Math.max(record.at, at);
297
+ record.pending++;
298
+ } else {
299
+ this.records.set(tag, { at, pending: 1, heldUntil: 0 });
300
+ }
301
+ }
302
+ }
303
+
304
+ /** The invalidation begun for `tags` settled: hold its records `holdMs`. */
305
+ settle(
306
+ tags: readonly string[],
307
+ holdMs: number,
308
+ now: number = Date.now(),
309
+ ): void {
310
+ for (const tag of tags) {
311
+ const record = this.records.get(tag);
312
+ if (!record) continue;
313
+ record.pending = Math.max(0, record.pending - 1);
314
+ record.heldUntil = Math.max(record.heldUntil, now + holdMs);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Whether a shell carrying `tags`, tagged at `taggedAt`, predates a live
320
+ * record of one of them (a tagged shell with no tag time is treated as
321
+ * predating it).
322
+ */
323
+ covers(
324
+ tags: readonly string[] | undefined,
325
+ taggedAt: number | undefined,
326
+ now: number = Date.now(),
327
+ ): boolean {
328
+ if (!tags || this.records.size === 0) return false;
329
+ for (const tag of tags) {
330
+ const record = this.records.get(tag);
331
+ if (!record) continue;
332
+ if (record.pending === 0 && now >= record.heldUntil) continue;
333
+ if (taggedAt === undefined || taggedAt <= record.at) return true;
334
+ }
335
+ return false;
336
+ }
337
+
338
+ clear(): void {
339
+ this.records.clear();
340
+ }
341
+ }