@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.
- package/README.md +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Capture data snapshot: recording + seeding stores for PPR shell parity.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* A PPR HIT serves frozen prelude bytes, then a Flight payload for hydration
|
|
5
|
+
* that must agree with them byte for byte. The handler layer's part of that
|
|
6
|
+
* payload is the capture's own doc segment record: the capture writes it
|
|
7
|
+
* first, renders its prelude from it, and every HIT replays it (no handler
|
|
8
|
+
* runs on a HIT). What still executes on a HIT is the loader layer: live
|
|
9
|
+
* loaders and promise-carrying bake-lane (`ssr: false`) loaders run, and the
|
|
10
|
+
* values the bake-lane ones read must match what the capture baked (a
|
|
11
|
+
* promise-free bake-lane loader is served from its pin and does not run). See docs/design/ppr-shell-resume.md.
|
|
11
12
|
*
|
|
12
|
-
* The
|
|
13
|
-
* the CAPTURE render records every cache-store read-hit and write it
|
|
14
|
-
* (the {@link RecordingShellStore}); the
|
|
15
|
-
* as its `snapshot`; on a HIT the tail render reads through a
|
|
16
|
-
* {@link SeededShellStore} overlay that serves those recorded values AS FRESH
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* were never recorded) stays live.
|
|
13
|
+
* The mechanism (Next.js resume-data-cache analog, adapted to Rango's cache
|
|
14
|
+
* rings): the CAPTURE render records every cache-store read-hit and write it
|
|
15
|
+
* performed (the {@link RecordingShellStore}); the records ride inside the
|
|
16
|
+
* ShellCacheEntry as its `snapshot`; on a HIT the tail render reads through a
|
|
17
|
+
* {@link SeededShellStore} overlay that serves those recorded values AS FRESH.
|
|
18
|
+
* Everything NOT recorded (the holes — masked loaders were never executed at
|
|
19
|
+
* capture, so their reads were never recorded) stays live.
|
|
20
20
|
*
|
|
21
|
-
* The invariant, verbatim: the snapshot is
|
|
22
|
-
* the capture
|
|
23
|
-
* content byte-identically; everything not recorded stays live.
|
|
21
|
+
* The invariant, verbatim: the snapshot is the doc record plus the cache-store
|
|
22
|
+
* reads the capture's loaders performed; replaying them on a HIT reproduces
|
|
23
|
+
* the shell content byte-identically; everything not recorded stays live.
|
|
24
|
+
* {@link pruneShellSnapshot} drops the rest.
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
import type {
|
|
@@ -39,6 +40,7 @@ import type {
|
|
|
39
40
|
import { CACHE_READ_ERROR } from "./types.js";
|
|
40
41
|
import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
|
|
41
42
|
import { isPerClientSignalHeader } from "../browser/cookie-name.js";
|
|
43
|
+
import { isInsideAnyLoaderScope } from "../server/context.js";
|
|
42
44
|
|
|
43
45
|
/** Compose the last-write-wins map key. NUL (`\u0000`) cannot appear in a cache key. */
|
|
44
46
|
function recordKey(family: ShellSnapshotRecord["family"], key: string): string {
|
|
@@ -83,12 +85,21 @@ function deserializeResponse(value: ShellSnapshotResponseValue): Response {
|
|
|
83
85
|
* — may land after the shell has quiesced. The capture collects those write
|
|
84
86
|
* promises via {@link trackWrite} and awaits them ({@link settleWrites}) before
|
|
85
87
|
* draining, so a MISS-at-capture value is still pinned.
|
|
88
|
+
*
|
|
89
|
+
* Loader attribution: every item/response access made inside a loader scope
|
|
90
|
+
* (hit, miss or write) marks its key in {@link loaderKeys}. A loader re-runs
|
|
91
|
+
* on every HIT, so those records stay pinned when the snapshot is pruned. A
|
|
92
|
+
* miss counts too: a loader that joins a handler's in-flight "use cache" call
|
|
93
|
+
* reads the store first (cache-runtime.ts) and finds nothing, and the record
|
|
94
|
+
* then comes from the handler's write.
|
|
86
95
|
*/
|
|
87
96
|
export class RecordingShellStore<
|
|
88
97
|
TEnv = unknown,
|
|
89
98
|
> implements SegmentCacheStore<TEnv> {
|
|
90
99
|
private readonly records = new Map<string, ShellSnapshotRecord>();
|
|
91
100
|
private readonly writes: Promise<unknown>[] = [];
|
|
101
|
+
/** Record keys ({@link recordKey}) a loader read or wrote during the capture. */
|
|
102
|
+
readonly loaderKeys: Set<string> = new Set();
|
|
92
103
|
|
|
93
104
|
constructor(private readonly inner: SegmentCacheStore<TEnv>) {}
|
|
94
105
|
|
|
@@ -110,6 +121,13 @@ export class RecordingShellStore<
|
|
|
110
121
|
this.records.set(recordKey(family, key), { family, key, value });
|
|
111
122
|
}
|
|
112
123
|
|
|
124
|
+
private noteLoaderAccess(
|
|
125
|
+
family: ShellSnapshotRecord["family"],
|
|
126
|
+
key: string,
|
|
127
|
+
): void {
|
|
128
|
+
if (isInsideAnyLoaderScope()) this.loaderKeys.add(recordKey(family, key));
|
|
129
|
+
}
|
|
130
|
+
|
|
113
131
|
/** Track a deferred cache-write promise so the capture can await it pre-drain. */
|
|
114
132
|
trackWrite(p: Promise<unknown>): void {
|
|
115
133
|
this.writes.push(p);
|
|
@@ -134,25 +152,35 @@ export class RecordingShellStore<
|
|
|
134
152
|
* cacheRoute path schedules its actual store.set in a second waitUntil while the
|
|
135
153
|
* first is running), so each awaited batch may enqueue more. Loop until the
|
|
136
154
|
* queue empties or the deadline passes. Bounded: a pathologically slow write
|
|
137
|
-
* must never stall the capture task
|
|
138
|
-
*
|
|
155
|
+
* must never stall the capture task. Resolves true when every tracked write
|
|
156
|
+
* settled, false at the deadline (settleCaptureRecord then fails the capture
|
|
157
|
+
* when the doc record is among the writes still pending).
|
|
139
158
|
*/
|
|
140
|
-
async settleWrites(timeoutMs: number): Promise<
|
|
159
|
+
async settleWrites(timeoutMs: number): Promise<boolean> {
|
|
141
160
|
const deadline = Date.now() + timeoutMs;
|
|
142
161
|
while (this.writes.length > 0) {
|
|
143
162
|
const remaining = deadline - Date.now();
|
|
144
|
-
if (remaining <= 0) return;
|
|
163
|
+
if (remaining <= 0) return false;
|
|
145
164
|
// Take the current batch; new writes scheduled while awaiting accumulate in
|
|
146
165
|
// this.writes and are drained on the next iteration.
|
|
147
166
|
const batch = this.writes.splice(0);
|
|
148
167
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
149
|
-
const guard = new Promise<
|
|
150
|
-
timer = setTimeout(resolve, remaining);
|
|
168
|
+
const guard = new Promise<false>((resolve) => {
|
|
169
|
+
timer = setTimeout(() => resolve(false), remaining);
|
|
151
170
|
(timer as { unref?: () => void }).unref?.();
|
|
152
171
|
});
|
|
153
|
-
await Promise.race([
|
|
172
|
+
const settled = await Promise.race([
|
|
173
|
+
Promise.allSettled(batch).then(() => true as const),
|
|
174
|
+
guard,
|
|
175
|
+
]);
|
|
154
176
|
if (timer) clearTimeout(timer);
|
|
177
|
+
if (!settled) {
|
|
178
|
+
// Keep tracking the batch: a later settleWrites still waits for it.
|
|
179
|
+
this.writes.unshift(...batch);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
155
182
|
}
|
|
183
|
+
return true;
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
/** The recorded snapshot (last-write-wins per family+key), or undefined if empty. */
|
|
@@ -160,6 +188,14 @@ export class RecordingShellStore<
|
|
|
160
188
|
return this.records.size > 0 ? [...this.records.values()] : undefined;
|
|
161
189
|
}
|
|
162
190
|
|
|
191
|
+
/** One recorded value, by family and key. */
|
|
192
|
+
getRecord(
|
|
193
|
+
family: ShellSnapshotRecord["family"],
|
|
194
|
+
key: string,
|
|
195
|
+
): ShellSnapshotRecord["value"] | undefined {
|
|
196
|
+
return this.records.get(recordKey(family, key))?.value;
|
|
197
|
+
}
|
|
198
|
+
|
|
163
199
|
async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
|
|
164
200
|
const result = await this.inner.get(key);
|
|
165
201
|
if (result && result !== CACHE_READ_ERROR) {
|
|
@@ -190,6 +226,7 @@ export class RecordingShellStore<
|
|
|
190
226
|
key: string,
|
|
191
227
|
): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
|
|
192
228
|
if (!this.inner.getResponse) return null;
|
|
229
|
+
this.noteLoaderAccess("response", key);
|
|
193
230
|
const result = await this.inner.getResponse(key);
|
|
194
231
|
if (result)
|
|
195
232
|
this.record("response", key, await serializeResponse(result.response));
|
|
@@ -204,12 +241,14 @@ export class RecordingShellStore<
|
|
|
204
241
|
tags?: string[],
|
|
205
242
|
): Promise<void> {
|
|
206
243
|
if (!this.inner.putResponse) return;
|
|
244
|
+
this.noteLoaderAccess("response", key);
|
|
207
245
|
this.record("response", key, await serializeResponse(response));
|
|
208
246
|
return this.inner.putResponse(key, response, ttl, swr, tags);
|
|
209
247
|
}
|
|
210
248
|
|
|
211
249
|
async getItem(key: string): Promise<CacheItemResult | null> {
|
|
212
250
|
if (!this.inner.getItem) return null;
|
|
251
|
+
this.noteLoaderAccess("item", key);
|
|
213
252
|
const result = await this.inner.getItem(key);
|
|
214
253
|
if (result) {
|
|
215
254
|
const value: ShellSnapshotItemValue = {
|
|
@@ -228,6 +267,7 @@ export class RecordingShellStore<
|
|
|
228
267
|
options?: CacheItemOptions,
|
|
229
268
|
): Promise<void> {
|
|
230
269
|
if (!this.inner.setItem) return;
|
|
270
|
+
this.noteLoaderAccess("item", key);
|
|
231
271
|
const stored: ShellSnapshotItemValue = {
|
|
232
272
|
value,
|
|
233
273
|
handles: options?.handles,
|
|
@@ -266,6 +306,130 @@ export function getRecordingStore<TEnv>(
|
|
|
266
306
|
return store instanceof RecordingShellStore ? store : undefined;
|
|
267
307
|
}
|
|
268
308
|
|
|
309
|
+
/**
|
|
310
|
+
* True when `snapshot` carries the canonical doc segment record `docKey`
|
|
311
|
+
* names, with at least one segment: the record partial replay and a
|
|
312
|
+
* fast-path HIT tail consume.
|
|
313
|
+
*/
|
|
314
|
+
export function hasDocRecord(
|
|
315
|
+
snapshot: readonly ShellSnapshotRecord[] | undefined,
|
|
316
|
+
docKey: string | undefined,
|
|
317
|
+
): boolean {
|
|
318
|
+
if (docKey === undefined || !snapshot) return false;
|
|
319
|
+
return snapshot.some((record) => {
|
|
320
|
+
if (
|
|
321
|
+
!record ||
|
|
322
|
+
typeof record !== "object" ||
|
|
323
|
+
record.family !== "segment" ||
|
|
324
|
+
record.key !== docKey ||
|
|
325
|
+
typeof record.value !== "object" ||
|
|
326
|
+
record.value === null
|
|
327
|
+
) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
const segments = (record.value as { segments?: unknown }).segments;
|
|
331
|
+
return Array.isArray(segments) && segments.length > 0;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Who reads a stored snapshot besides the doc record
|
|
337
|
+
* (docs/design/shell-entry-layout.md §2):
|
|
338
|
+
* - "segments": nobody. A navigation-only entry is read only by partial
|
|
339
|
+
* replay, which seeds the doc record alone (SeededShellStore
|
|
340
|
+
* `segmentsOnly`, implicit doc scope only).
|
|
341
|
+
* - "loaders": the loaders a document HIT re-runs. Every HIT tail replays
|
|
342
|
+
* the handler layer from the doc record and never runs a handler, so the
|
|
343
|
+
* records only handler code read are dead weight.
|
|
344
|
+
*/
|
|
345
|
+
export type ShellSnapshotReaders = "segments" | "loaders";
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Split a capture's snapshot into what its readers consume and the rest: the
|
|
349
|
+
* doc record (`docKey`) always; with "loaders", the loader-family records and
|
|
350
|
+
* the item/response records a loader touched during the capture
|
|
351
|
+
* (RecordingShellStore.loaderKeys). Every other segment record goes: an
|
|
352
|
+
* explicit cache() tier's reads and writes are keyed where no HIT tail and no
|
|
353
|
+
* partial replay looks (both consult only the doc record). The doc record
|
|
354
|
+
* stays a copy inside the entry: a reference to a separately stored record
|
|
355
|
+
* could be evicted on its own, and the HIT would have nothing to replay.
|
|
356
|
+
*/
|
|
357
|
+
export function pruneShellSnapshot(
|
|
358
|
+
snapshot: readonly ShellSnapshotRecord[],
|
|
359
|
+
readers: ShellSnapshotReaders,
|
|
360
|
+
loaderKeys: ReadonlySet<string>,
|
|
361
|
+
docKey: string,
|
|
362
|
+
): { kept: ShellSnapshotRecord[]; pruned: ShellSnapshotRecord[] } {
|
|
363
|
+
const kept: ShellSnapshotRecord[] = [];
|
|
364
|
+
const pruned: ShellSnapshotRecord[] = [];
|
|
365
|
+
for (const record of snapshot) {
|
|
366
|
+
const keep =
|
|
367
|
+
(record.family === "segment" && record.key === docKey) ||
|
|
368
|
+
(readers === "loaders" &&
|
|
369
|
+
(record.family === "loader" ||
|
|
370
|
+
(record.family !== "segment" &&
|
|
371
|
+
loaderKeys.has(recordKey(record.family, record.key)))));
|
|
372
|
+
(keep ? kept : pruned).push(record);
|
|
373
|
+
}
|
|
374
|
+
return { kept, pruned };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The shell entry head's fields estimateShellEntryBytes does not measure
|
|
379
|
+
* (versions, timestamps, theme, lengths, flags; about 200 bytes in a
|
|
380
|
+
* CFCacheStore frame) and the frame's 12-byte prefix, with room to spare.
|
|
381
|
+
*/
|
|
382
|
+
export const SHELL_ENTRY_HEAD_ALLOWANCE_BYTES: number = 512;
|
|
383
|
+
|
|
384
|
+
const ENTRY_BYTE_ENCODER = new TextEncoder();
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Bytes (UTF-8) a stored shell entry takes, as the built-in stores write it:
|
|
388
|
+
* the prelude, the snapshot's JSON, and a JSON head that carries the
|
|
389
|
+
* postponed state as an escaped string next to the tags and doc key
|
|
390
|
+
* (CFCacheStore's frame head, VercelCacheStore's envelope), plus
|
|
391
|
+
* SHELL_ENTRY_HEAD_ALLOWANCE_BYTES for the head's fixed fields and the
|
|
392
|
+
* frame prefix. Measuring the raw postponed bytes undercounted: every quote
|
|
393
|
+
* in it is escaped in the head (a /ppr-large/holes entry measured 1,098
|
|
394
|
+
* bytes under its KV value). The capture's whole-entry guard compares this
|
|
395
|
+
* with the store's value limit.
|
|
396
|
+
*/
|
|
397
|
+
export function estimateShellEntryBytes(parts: {
|
|
398
|
+
preludeBytes: number;
|
|
399
|
+
postponed: string | null | undefined;
|
|
400
|
+
snapshot: readonly ShellSnapshotRecord[] | undefined;
|
|
401
|
+
tags: readonly string[] | undefined;
|
|
402
|
+
docKey: string | undefined;
|
|
403
|
+
prunedRecords: string | undefined;
|
|
404
|
+
}): number {
|
|
405
|
+
const head = ENTRY_BYTE_ENCODER.encode(
|
|
406
|
+
JSON.stringify({
|
|
407
|
+
po: parts.postponed,
|
|
408
|
+
t: parts.tags,
|
|
409
|
+
dk: parts.docKey,
|
|
410
|
+
pr: parts.prunedRecords,
|
|
411
|
+
}),
|
|
412
|
+
).length;
|
|
413
|
+
const snapshot =
|
|
414
|
+
parts.snapshot && parts.snapshot.length > 0
|
|
415
|
+
? ENTRY_BYTE_ENCODER.encode(JSON.stringify(parts.snapshot)).length
|
|
416
|
+
: 0;
|
|
417
|
+
return (
|
|
418
|
+
parts.preludeBytes + head + snapshot + SHELL_ENTRY_HEAD_ALLOWANCE_BYTES
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Snapshot records by family, e.g. `segment:1/item:5` (no commas: it rides a Server-Timing desc). */
|
|
423
|
+
export function countSnapshotFamilies(
|
|
424
|
+
snapshot: readonly ShellSnapshotRecord[],
|
|
425
|
+
): string {
|
|
426
|
+
const counts = new Map<string, number>();
|
|
427
|
+
for (const record of snapshot) {
|
|
428
|
+
counts.set(record.family, (counts.get(record.family) ?? 0) + 1);
|
|
429
|
+
}
|
|
430
|
+
return [...counts].map(([family, n]) => `${family}:${n}`).join("/");
|
|
431
|
+
}
|
|
432
|
+
|
|
269
433
|
/**
|
|
270
434
|
* The store the shell fast path's IMPLICIT doc-cache scope resolves during a
|
|
271
435
|
* capture: reads pass through the recording store (a real-store hit is
|
|
@@ -324,6 +488,12 @@ export interface ShellLoaderSeedEntry {
|
|
|
324
488
|
* from the pin (loader-cache.ts pin-first path).
|
|
325
489
|
*/
|
|
326
490
|
holes: boolean;
|
|
491
|
+
/**
|
|
492
|
+
* The record needs its loader body to run on a HIT for pushes the capture
|
|
493
|
+
* could not record (ShellSnapshotLoaderValue.runs). Only read for a
|
|
494
|
+
* hole-free record: a hole-carrying one always runs.
|
|
495
|
+
*/
|
|
496
|
+
runs: boolean;
|
|
327
497
|
}
|
|
328
498
|
|
|
329
499
|
export async function buildShellLoaderSeed(
|
|
@@ -345,9 +515,12 @@ export async function buildShellLoaderSeed(
|
|
|
345
515
|
rec.key,
|
|
346
516
|
{
|
|
347
517
|
container: await deserializeResult(stored.value),
|
|
348
|
-
// A record without
|
|
349
|
-
// hole-carrying
|
|
518
|
+
// A record without a bit predates it: without the hole bit it
|
|
519
|
+
// reads as hole-carrying (unknown hole-ness keeps the gated
|
|
520
|
+
// path), and without the runs bit as runs (its snapshot lacks
|
|
521
|
+
// the loader-owned pushes, so the body supplies them).
|
|
350
522
|
holes: stored.holes !== 0,
|
|
523
|
+
runs: stored.runs !== 0,
|
|
351
524
|
},
|
|
352
525
|
];
|
|
353
526
|
} catch {
|
|
@@ -514,3 +687,20 @@ export class SeededShellStore<
|
|
|
514
687
|
return this.inner.invalidateTags?.(tags);
|
|
515
688
|
}
|
|
516
689
|
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Thrown by a document HIT tail's match when its doc record cannot supply the
|
|
693
|
+
* handler layer (it failed to decode, or the entry lost it). The tail must not
|
|
694
|
+
* run handlers after the prelude committed, so withCacheLookup throws this
|
|
695
|
+
* instead of resolving segments, and serveShellHit degrades the response:
|
|
696
|
+
* the entry is replaced with a tombstone, the memo is dropped, a recapture is
|
|
697
|
+
* scheduled, and the browser reloads into a MISS.
|
|
698
|
+
*/
|
|
699
|
+
export class ShellRecordUnavailableError extends Error {
|
|
700
|
+
constructor(docKey: string | undefined) {
|
|
701
|
+
super(
|
|
702
|
+
`PPR shell HIT: the doc record${docKey ? ` "${docKey}"` : ""} could not supply the handler layer`,
|
|
703
|
+
);
|
|
704
|
+
this.name = "ShellRecordUnavailableError";
|
|
705
|
+
}
|
|
706
|
+
}
|
|
@@ -21,9 +21,28 @@
|
|
|
21
21
|
* primitive for each tag. A single configured store (the common case) owns its
|
|
22
22
|
* own tag index and distributed invalidation - there is no separate
|
|
23
23
|
* tag-invalidation store.
|
|
24
|
+
*
|
|
25
|
+
* Both call invalidateTags() synchronously, inside the request (#973). A store
|
|
26
|
+
* masks the tags for the rest of the request before its first await (the
|
|
27
|
+
* built-in stores write their per-request marker state there), so the request
|
|
28
|
+
* that ran either verb reads its own writes; revalidateTag() leaves only the
|
|
29
|
+
* durable remainder (KV marker put, tag purge, expireTag) to waitUntil.
|
|
30
|
+
* Invariant: the mask only turns the invalidating request's hits into misses.
|
|
31
|
+
* If the durable write then fails, that request paid extra misses, never a
|
|
32
|
+
* stale read, and other requests read the durable state. Both also record
|
|
33
|
+
* the tags on the request (markInvalidated), so a "use cache" execution or
|
|
34
|
+
* stale refresh this request started before one of its own tags was
|
|
35
|
+
* invalidated is neither joined nor written (cache-runtime.ts). Its own tags
|
|
36
|
+
* only: a nested "use cache" function's tags do not reach the enclosing
|
|
37
|
+
* entry (#980), for this gate as for eviction. Not covered (#977): such an
|
|
38
|
+
* execution in another request, or a loader cache() write, still lands, and
|
|
39
|
+
* the store accepts it (docs/design/caching.md "Read-your-own-writes").
|
|
24
40
|
*/
|
|
25
41
|
|
|
26
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
_getRequestContext,
|
|
44
|
+
type RequestContext,
|
|
45
|
+
} from "../server/request-context.js";
|
|
27
46
|
import { reportingAsync } from "./cache-error.js";
|
|
28
47
|
import { normalizeTags } from "./cache-tag.js";
|
|
29
48
|
import type { SegmentCacheStore } from "./types.js";
|
|
@@ -104,6 +123,66 @@ function warnPartialTagStore(fn: string, incapable: number): void {
|
|
|
104
123
|
);
|
|
105
124
|
}
|
|
106
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Set the fresh-reads cookie on this request's response for as long as the
|
|
128
|
+
* invalidated stores' isolate memos can stay stale (isolate-tag-memo.ts), so
|
|
129
|
+
* the same user's next requests read past them on every isolate.
|
|
130
|
+
*/
|
|
131
|
+
function markFreshReads(stores: SegmentCacheStore[]): void {
|
|
132
|
+
const ctx = _getRequestContext();
|
|
133
|
+
if (!ctx?._setFreshReadsCookie) return;
|
|
134
|
+
let windowMs = 0;
|
|
135
|
+
for (const store of stores) {
|
|
136
|
+
windowMs = Math.max(windowMs, store.freshReadsWindowMs ?? 0);
|
|
137
|
+
}
|
|
138
|
+
if (windowMs > 0) ctx._setFreshReadsCookie(windowMs);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The isolate's order of tag invalidations and "use cache" execution starts
|
|
143
|
+
* (#973). A counter, not Date.now(): Workers advance the clock only on I/O,
|
|
144
|
+
* so an execution started right after an invalidation would share its
|
|
145
|
+
* millisecond and read as older.
|
|
146
|
+
*/
|
|
147
|
+
let cacheSeq = 0;
|
|
148
|
+
|
|
149
|
+
/** The current position in that order; an execution records it at start. */
|
|
150
|
+
export function currentCacheSeq(): number {
|
|
151
|
+
return cacheSeq;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Whether the request invalidated any of `tags` after `since` (a
|
|
156
|
+
* currentCacheSeq() value an execution recorded when it started): its value
|
|
157
|
+
* may predate the invalidation, so the request neither joins nor writes it.
|
|
158
|
+
*/
|
|
159
|
+
export function invalidatedSince(
|
|
160
|
+
ctx: RequestContext<any, any> | undefined,
|
|
161
|
+
tags: readonly string[] | undefined,
|
|
162
|
+
since: number,
|
|
163
|
+
): boolean {
|
|
164
|
+
const record = (ctx?._requestRoot ?? ctx)?._tagInvalidations;
|
|
165
|
+
if (!record || !tags) return false;
|
|
166
|
+
for (const tag of tags) {
|
|
167
|
+
const at = record.get(tag);
|
|
168
|
+
if (at !== undefined && at > since) return true;
|
|
169
|
+
}
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Record the invalidation on the request (RequestContext._tagInvalidations,
|
|
175
|
+
* on the root so derived contexts share it), before any store sees it.
|
|
176
|
+
*/
|
|
177
|
+
function markInvalidated(tags: readonly string[]): void {
|
|
178
|
+
const ctx = _getRequestContext();
|
|
179
|
+
if (!ctx) return;
|
|
180
|
+
const root = ctx._requestRoot ?? ctx;
|
|
181
|
+
const at = ++cacheSeq;
|
|
182
|
+
const record = (root._tagInvalidations ??= new Map());
|
|
183
|
+
for (const tag of tags) record.set(tag, at);
|
|
184
|
+
}
|
|
185
|
+
|
|
107
186
|
async function invalidateAcross(
|
|
108
187
|
stores: SegmentCacheStore[],
|
|
109
188
|
tags: string[],
|
|
@@ -163,6 +242,8 @@ export async function updateTag(...tags: string[]): Promise<void> {
|
|
|
163
242
|
}
|
|
164
243
|
if (incapable > 0) warnPartialTagStore("updateTag", incapable);
|
|
165
244
|
|
|
245
|
+
markFreshReads(capable);
|
|
246
|
+
markInvalidated(valid);
|
|
166
247
|
await invalidateAcross(capable, valid);
|
|
167
248
|
}
|
|
168
249
|
|
|
@@ -173,10 +254,12 @@ export async function updateTag(...tags: string[]): Promise<void> {
|
|
|
173
254
|
* This is NOT stale-while-revalidate: like updateTag() it hard-purges, so the
|
|
174
255
|
* next read after the invalidation lands is a miss that re-renders fresh. The
|
|
175
256
|
* only difference from updateTag() is awaitability - revalidateTag() defers the
|
|
176
|
-
*
|
|
257
|
+
* durable write off the response path and is not awaited.
|
|
177
258
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
259
|
+
* Read-your-own-writes (#973): each built-in store masks the tags for the rest
|
|
260
|
+
* of this request before revalidateTag() returns, so a Server Action's own
|
|
261
|
+
* re-render after it is fresh, as after `await updateTag()`. Other requests see
|
|
262
|
+
* the invalidation once the durable write lands.
|
|
180
263
|
*
|
|
181
264
|
* Fire-and-forget: because this returns void and runs in the background, a
|
|
182
265
|
* failed durable marker write (e.g. a transient KV outage) is NOT surfaced to
|
|
@@ -209,23 +292,25 @@ export function revalidateTag(...tags: string[]): void {
|
|
|
209
292
|
}
|
|
210
293
|
if (incapable > 0) warnPartialTagStore("revalidateTag", incapable);
|
|
211
294
|
|
|
295
|
+
markFreshReads(capable);
|
|
212
296
|
const ctx = _getRequestContext();
|
|
297
|
+
// A build-time render drops background work (RequestContext.waitUntil), so
|
|
298
|
+
// it never ran this invalidation; keep it that way.
|
|
299
|
+
if (ctx?.build) return;
|
|
300
|
+
markInvalidated(valid);
|
|
301
|
+
// Started here, not in the waitUntil task, so each store masks the tags
|
|
302
|
+
// before this returns (#973, header above).
|
|
303
|
+
//
|
|
213
304
|
// reportingAsync never rejects: it catches a failed durable write and routes
|
|
214
305
|
// it through reportCacheError (loud log + onError). This is the only place a
|
|
215
306
|
// revalidateTag failure can be observed, since it is not awaitable. Pass ctx
|
|
216
|
-
// explicitly
|
|
217
|
-
//
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (ctx?.waitUntil) {
|
|
226
|
-
ctx.waitUntil(run);
|
|
227
|
-
} else {
|
|
228
|
-
// No request context (e.g. called outside ALS): best-effort background run.
|
|
229
|
-
void run();
|
|
230
|
-
}
|
|
307
|
+
// explicitly: the write can settle after the response, outside the
|
|
308
|
+
// request's ALS scope, and onError fires only with the captured context.
|
|
309
|
+
const pending = reportingAsync(
|
|
310
|
+
() => invalidateAcross(capable, valid),
|
|
311
|
+
"cache-invalidate",
|
|
312
|
+
"[revalidateTag] background invalidation",
|
|
313
|
+
ctx,
|
|
314
|
+
);
|
|
315
|
+
ctx?.waitUntil(() => pending);
|
|
231
316
|
}
|