@rangojs/router 0.4.3 → 0.5.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/dist/types/cache/cf/cf-cache-store.d.ts +2 -0
- package/dist/types/cache/types.d.ts +39 -6
- package/dist/types/rsc/capture-queue.d.ts +19 -3
- package/dist/types/rsc/shell-build-manifest.d.ts +2 -2
- package/dist/types/rsc/shell-capture.d.ts +22 -2
- package/dist/types/rsc/shell-serve.d.ts +18 -12
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/ppr/SKILL.md +44 -3
- package/src/cache/cf/cf-cache-store.ts +27 -8
- package/src/cache/types.ts +41 -6
- package/src/cache/vercel/vercel-cache-store.ts +24 -10
- package/src/rsc/capture-queue.ts +120 -47
- package/src/rsc/rsc-rendering.ts +11 -3
- package/src/rsc/shell-build-manifest.ts +20 -7
- package/src/rsc/shell-capture.ts +164 -12
- package/src/rsc/shell-serve.ts +27 -12
|
@@ -30,6 +30,8 @@ import type { KVNamespace, CFCacheReadDebugEvent, CFCacheDebug, CFCacheStoreOpti
|
|
|
30
30
|
export type { KVNamespace, CFCacheReadDebugEvent, CFCacheDebug, CFCacheStoreOptions, };
|
|
31
31
|
export declare class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
32
32
|
readonly supportsPassiveShellReads: true;
|
|
33
|
+
/** True when constructed without KV: the shell family no-ops (see ctor). */
|
|
34
|
+
readonly shellFamilyInert?: boolean;
|
|
33
35
|
readonly defaults?: CacheDefaults;
|
|
34
36
|
readonly keyGenerator?: (ctx: RequestContext<TEnv>, defaultKey: string) => string | Promise<string>;
|
|
35
37
|
private readonly namespace?;
|
|
@@ -164,6 +164,15 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
164
164
|
* `stored` when acknowledged, or void for stores without acknowledgements.
|
|
165
165
|
*/
|
|
166
166
|
putShell?(key: string, entry: ShellCacheEntry, ttlSeconds?: number, swrSeconds?: number, tags?: string[]): Promise<"stored" | "invalidated" | void>;
|
|
167
|
+
/**
|
|
168
|
+
* Declares the shell family present-but-inert: getShell/putShell exist but
|
|
169
|
+
* no-op (CFCacheStore without a KV namespace). scheduleShellCapture skips
|
|
170
|
+
* captures whose only write target is inert — the background render would be
|
|
171
|
+
* dead work that still occupies the per-isolate serialized capture queue
|
|
172
|
+
* (a promise-heavy route bakes for seconds per MISS with nothing stored).
|
|
173
|
+
* Absent/false means the family, when present, actually stores.
|
|
174
|
+
*/
|
|
175
|
+
shellFamilyInert?: boolean;
|
|
167
176
|
/**
|
|
168
177
|
* Get a cached function result by key.
|
|
169
178
|
* Returns the serialized value, optional handle data, and staleness flag.
|
|
@@ -221,8 +230,8 @@ export interface CacheItemResult {
|
|
|
221
230
|
/**
|
|
222
231
|
* A cached PPR (Partial Pre-rendering) shell entry.
|
|
223
232
|
*
|
|
224
|
-
*
|
|
225
|
-
* and React's postponed state — because the pair is version- and
|
|
233
|
+
* A DOCUMENT entry carries BOTH artifacts a resume needs — the rendered HTML
|
|
234
|
+
* prelude and React's postponed state — because the pair is version- and
|
|
226
235
|
* generation-coupled and must never be mixed across a React upgrade or a build
|
|
227
236
|
* change. The reactVersion and buildVersion fields are the read-time gates that
|
|
228
237
|
* enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
|
|
@@ -231,15 +240,28 @@ export interface CacheItemResult {
|
|
|
231
240
|
* positions against one exact tree; resuming it against a different React or a
|
|
232
241
|
* different app build tree-mismatches inside resume(), AFTER the 200 + prelude
|
|
233
242
|
* are committed — an unrecoverable broken serve).
|
|
243
|
+
*
|
|
244
|
+
* A `navigationOnly` entry stores NEITHER half: nothing ever serves its HTML
|
|
245
|
+
* (document serving skips navigationOnly entries at the read gate, and partial
|
|
246
|
+
* replay consumes only `snapshot`/`docKey`), so the prelude would ride every
|
|
247
|
+
* store write and read as dead weight at KV-value scale. The capture still runs
|
|
248
|
+
* the full fizz prerender — it is the completeness arbiter and sanity gate —
|
|
249
|
+
* but its output is dropped before putShell. hasIntactShellPayload is the
|
|
250
|
+
* document-half gate; navigationOnly entries never satisfy it.
|
|
234
251
|
*/
|
|
235
252
|
export interface ShellCacheEntry {
|
|
236
|
-
/**
|
|
237
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing).
|
|
255
|
+
* Absent on `navigationOnly` entries (no document half is stored — see the
|
|
256
|
+
* interface doc); present on every document-servable entry.
|
|
257
|
+
*/
|
|
258
|
+
prelude?: string;
|
|
238
259
|
/**
|
|
239
260
|
* JSON.stringify of React's postponed state, or null when the shell settled
|
|
240
|
-
* with no holes (the DATA variant — served without a fizz resume).
|
|
261
|
+
* with no holes (the DATA variant — served without a fizz resume). Absent
|
|
262
|
+
* exactly when `prelude` is (navigationOnly entries).
|
|
241
263
|
*/
|
|
242
|
-
postponed
|
|
264
|
+
postponed?: string | null;
|
|
243
265
|
/** React.version captured at prerender time; the read-time invalidation gate. */
|
|
244
266
|
reactVersion: string;
|
|
245
267
|
/**
|
|
@@ -322,6 +344,17 @@ export interface ShellCacheEntry {
|
|
|
322
344
|
/** Capture-generation start time; tag invalidations at or after it win. */
|
|
323
345
|
createdAt: number;
|
|
324
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* A shell entry whose document half is present — what the document HIT path
|
|
349
|
+
* (serveShellHit / lookupBuildShell) consumes. hasIntactShellPayload
|
|
350
|
+
* (rsc/shell-serve.ts) is the runtime gate AND the type narrowing to this
|
|
351
|
+
* shape; navigationOnly entries never pass it (their document half is not
|
|
352
|
+
* stored).
|
|
353
|
+
*/
|
|
354
|
+
export type DocumentShellCacheEntry = ShellCacheEntry & {
|
|
355
|
+
prelude: string;
|
|
356
|
+
postponed: string | null;
|
|
357
|
+
};
|
|
325
358
|
/**
|
|
326
359
|
* The families a shell snapshot pins. The item/segment/response families are
|
|
327
360
|
* cache-store reads/writes (recorded by RecordingShellStore); the loader family
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
/** Bound queued/running capture closures retained by one isolate. */
|
|
2
2
|
export declare const MAX_ADMITTED_CAPTURES: number;
|
|
3
|
+
/** Point-in-time queue occupancy, for capture-scheduling telemetry. */
|
|
4
|
+
export interface CaptureQueueDepths {
|
|
5
|
+
/** A capture is currently executing (never preempted). */
|
|
6
|
+
running: boolean;
|
|
7
|
+
/** Waiting document-priority captures (run before every navigation capture). */
|
|
8
|
+
document: number;
|
|
9
|
+
/** Waiting navigation-priority captures. */
|
|
10
|
+
navigation: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Snapshot the queue's waiting/running state. Wait-timed-out entries still
|
|
14
|
+
* sitting in the arrays (pruned lazily by takeNextCapture) are excluded — they
|
|
15
|
+
* will never run, so counting them would overstate the backlog.
|
|
16
|
+
*/
|
|
17
|
+
export declare function captureQueueDepths(): CaptureQueueDepths;
|
|
3
18
|
/** The caller may drop this best-effort capture and retry on a later request. */
|
|
4
19
|
export declare class CaptureQueueFullError extends Error {
|
|
5
20
|
constructor();
|
|
@@ -24,10 +39,11 @@ export declare class CaptureQueueWaitTimeoutError extends Error {
|
|
|
24
39
|
constructor(waitedMs: number);
|
|
25
40
|
}
|
|
26
41
|
/**
|
|
27
|
-
* Run `task` after
|
|
28
|
-
* promise for THIS task's completion
|
|
29
|
-
* the queue itself is insulated).
|
|
42
|
+
* Run `task` after the active capture and every higher/equal-priority capture
|
|
43
|
+
* ahead of it have settled. Returns a promise for THIS task's completion
|
|
44
|
+
* (rejections propagate to the caller — the queue itself is insulated).
|
|
30
45
|
*/
|
|
31
46
|
export declare function enqueueSerializedCapture(task: () => Promise<void>, opts?: {
|
|
32
47
|
maxQueueWaitMs?: number;
|
|
48
|
+
priority?: "document" | "navigation";
|
|
33
49
|
}): Promise<void>;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* as the reference instant. No tombstones — the manifest is immutable; the
|
|
25
25
|
* markers say whether it is still current.
|
|
26
26
|
*/
|
|
27
|
-
import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
|
|
27
|
+
import type { DocumentShellCacheEntry, SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
|
|
28
28
|
import type { SearchParamsFilter } from "../cache/search-params-filter.js";
|
|
29
29
|
/** One baked manifest record (the __ps asset module's default export). */
|
|
30
30
|
export interface BuildShellEntry {
|
|
@@ -49,7 +49,7 @@ declare global {
|
|
|
49
49
|
/** Reset the memoized manifest (unit tests swap the global loader). */
|
|
50
50
|
export declare function resetBuildShellManifestForTests(): void;
|
|
51
51
|
export interface BuildShellHit {
|
|
52
|
-
entry:
|
|
52
|
+
entry: DocumentShellCacheEntry;
|
|
53
53
|
/** Past createdAt + ttl: serve, but schedule the runtime recapture. */
|
|
54
54
|
stale: boolean;
|
|
55
55
|
}
|
|
@@ -125,6 +125,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
125
125
|
* - skip-backoff: the key is inside its refused-capture backoff window and
|
|
126
126
|
* the capture was not attempted
|
|
127
127
|
* - skip-capacity: the isolate capture queue is full; a later request may retry
|
|
128
|
+
* - skip-inert-store: the resolved store's shell family is missing or
|
|
129
|
+
* declared inert (SegmentCacheStore.shellFamilyInert — a CFCacheStore
|
|
130
|
+
* without a KV namespace); nothing could store the result, so the
|
|
131
|
+
* background render was not scheduled at all
|
|
128
132
|
* - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
|
|
129
133
|
* behind other captures and was dropped unrun (no backoff — the route is
|
|
130
134
|
* not doomed, the isolate was busy; a later request re-probes). Carries
|
|
@@ -132,7 +136,7 @@ export interface ShellCaptureDebugEvent {
|
|
|
132
136
|
* - backoff: the key entered (or escalated) backoff after a terminal
|
|
133
137
|
* no-shell — carries the new backoff state
|
|
134
138
|
*/
|
|
135
|
-
outcome: "stored" | "redirect" | "no-shell" | "refused" | "error" | "skip-in-flight" | "skip-backoff" | "skip-capacity" | "skip-queue-timeout" | "backoff";
|
|
139
|
+
outcome: "stored" | "redirect" | "no-shell" | "refused" | "error" | "skip-in-flight" | "skip-backoff" | "skip-capacity" | "skip-inert-store" | "skip-queue-timeout" | "backoff";
|
|
136
140
|
/** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
|
|
137
141
|
attempt?: number;
|
|
138
142
|
/** Wall-clock ms of the whole attempt (barrier + render + drain + put). */
|
|
@@ -163,6 +167,22 @@ export interface ShellCaptureDebugEvent {
|
|
|
163
167
|
backoffRemainingMs?: number;
|
|
164
168
|
/** Ms the capture waited in the serialized queue (skip-queue-timeout). */
|
|
165
169
|
queueWaitMs?: number;
|
|
170
|
+
/** Queue priority class at enqueue: document outranks queued navigation. */
|
|
171
|
+
queuePriority?: "document" | "navigation";
|
|
172
|
+
/**
|
|
173
|
+
* Captures ahead at enqueue: the active one plus every waiting capture of
|
|
174
|
+
* same-or-higher priority. 0 = this capture starts immediately.
|
|
175
|
+
*/
|
|
176
|
+
queueAhead?: number;
|
|
177
|
+
/**
|
|
178
|
+
* Ms the capture gate was HELD waiting for the slowest bake source — a
|
|
179
|
+
* top-level pushed handle promise or a bake-lane loader container — to
|
|
180
|
+
* settle, measured from capture start. Bake time is by-design shell latency
|
|
181
|
+
* (the hole doctrine: top-level promises bake), but it recurs on EVERY
|
|
182
|
+
* capture of the route and occupies the serialized queue; this field makes
|
|
183
|
+
* it attributable. Absent when nothing held the gate.
|
|
184
|
+
*/
|
|
185
|
+
bakeWaitMs?: number;
|
|
166
186
|
}
|
|
167
187
|
/**
|
|
168
188
|
* Debug sink for the capture pipeline, mirroring {@link CFCacheDebug}: `true`
|
|
@@ -322,7 +342,7 @@ type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
|
|
|
322
342
|
* bag, not a return value: captureAndStoreShell's outcome type stays a string
|
|
323
343
|
* union its existing callers (producer B, tests) consume unchanged.
|
|
324
344
|
*/
|
|
325
|
-
type CaptureAttemptStats = Pick<ShellCaptureDebugEvent, "barrierWaitMs" | "writeSettleMs" | "preludeBytes" | "snapshotBytes" | "snapshotSkipped" | "untaggedBake" | "storeWrite">;
|
|
345
|
+
type CaptureAttemptStats = Pick<ShellCaptureDebugEvent, "barrierWaitMs" | "writeSettleMs" | "preludeBytes" | "snapshotBytes" | "snapshotSkipped" | "untaggedBake" | "storeWrite" | "bakeWaitMs">;
|
|
326
346
|
/**
|
|
327
347
|
* Run the shell capture with a single in-place retry, then store the result.
|
|
328
348
|
*
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { type EntryData } from "../server/context.js";
|
|
19
19
|
import type { SearchParamsFilter } from "../cache/search-params-filter.js";
|
|
20
|
-
import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
|
|
20
|
+
import type { DocumentShellCacheEntry, ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
|
|
21
21
|
/** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
|
|
22
22
|
export declare const SHELL_STATUS_HEADER = "x-rango-shell";
|
|
23
23
|
/** Partial-navigation status header set only after captured PPR segments are consumed. */
|
|
@@ -106,18 +106,24 @@ export declare function buildShellKey(url: URL, filter?: SearchParamsFilter): st
|
|
|
106
106
|
*/
|
|
107
107
|
export declare function isValidShellHit(entry: ShellCacheEntry, buildVersion: string): boolean;
|
|
108
108
|
/**
|
|
109
|
-
*
|
|
110
|
-
* whose prelude is not decodable base64 or whose
|
|
111
|
-
* parseable JSON would otherwise throw AFTER the 200 +
|
|
112
|
-
* flushed (`serveShellHit` decodes at stream construction,
|
|
113
|
-
* parses in the tail) — the client gets a visually complete
|
|
114
|
-
* hydrates, re-served on every request until the entry ages
|
|
115
|
-
* path exists; failure schedules no recapture by itself).
|
|
116
|
-
* a corrupt entry (store-layer fault) into a plain MISS
|
|
117
|
-
* overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
|
|
118
|
-
* prelude flush that dominates the path.
|
|
109
|
+
* DOCUMENT-half integrity gate and type narrowing, run BEFORE the HIT response
|
|
110
|
+
* commits: a stored entry whose prelude is not decodable base64 or whose
|
|
111
|
+
* postponed blob is not parseable JSON would otherwise throw AFTER the 200 +
|
|
112
|
+
* full static prelude flushed (`serveShellHit` decodes at stream construction,
|
|
113
|
+
* `resumeShellHTML` parses in the tail) — the client gets a visually complete
|
|
114
|
+
* page that never hydrates, re-served on every request until the entry ages
|
|
115
|
+
* out (no eviction path exists; failure schedules no recapture by itself).
|
|
116
|
+
* Checking here turns a corrupt entry (store-layer fault) into a plain MISS
|
|
117
|
+
* the recapture overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
|
|
118
|
+
* against a prelude flush that dominates the path.
|
|
119
|
+
*
|
|
120
|
+
* navigationOnly entries store no document half (prelude/postponed absent) and
|
|
121
|
+
* therefore never pass. The partial-replay path skips this gate for them
|
|
122
|
+
* (replayableShellSnapshot) — snapshot fragment corruption is caught by the
|
|
123
|
+
* consumer-side Flight decoders instead (SegmentFragmentDecodeError → healing
|
|
124
|
+
* capture).
|
|
119
125
|
*/
|
|
120
|
-
export declare function hasIntactShellPayload(entry: ShellCacheEntry):
|
|
126
|
+
export declare function hasIntactShellPayload(entry: ShellCacheEntry): entry is DocumentShellCacheEntry;
|
|
121
127
|
/** Decode a base64 prelude back into bytes for stream composition. */
|
|
122
128
|
export declare function base64ToBytes(b64: string): Uint8Array;
|
|
123
129
|
/** True when the store implements the shell entry family. */
|
package/dist/vite/index.js
CHANGED
|
@@ -2530,7 +2530,7 @@ import { resolve } from "node:path";
|
|
|
2530
2530
|
// package.json
|
|
2531
2531
|
var package_default = {
|
|
2532
2532
|
name: "@rangojs/router",
|
|
2533
|
-
version: "0.
|
|
2533
|
+
version: "0.5.0",
|
|
2534
2534
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2535
2535
|
keywords: [
|
|
2536
2536
|
"react",
|
package/package.json
CHANGED
package/skills/ppr/SKILL.md
CHANGED
|
@@ -358,14 +358,27 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
|
|
|
358
358
|
falls through to KV on a miss, and promotes the KV hit back into that colo.
|
|
359
359
|
WITHOUT a KV namespace its shell family remains inert: every ppr route stays
|
|
360
360
|
`MISS` forever. The store warns once per isolate — bind KV
|
|
361
|
-
(`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
|
|
361
|
+
(`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store. An
|
|
362
|
+
inert store also stops captures at the gate: the store declares
|
|
363
|
+
`shellFamilyInert` and the scheduler skips the background render entirely
|
|
364
|
+
(`skip-inert-store` on the debug event) instead of burning a full capture per
|
|
365
|
+
MISS whose write could only no-op.
|
|
362
366
|
- Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
|
|
363
367
|
logs one line per capture attempt/skip (outcome, durations, prelude and
|
|
364
368
|
snapshot bytes, backoff state); pass a function to receive each
|
|
365
369
|
`ShellCaptureDebugEvent` instead. `skip-capacity` means the isolate already
|
|
366
370
|
has 32 queued/running captures; the dropped best-effort capture can retry on a
|
|
367
|
-
later request.
|
|
368
|
-
|
|
371
|
+
later request. `skip-queue-timeout` means this capture waited 15s behind the
|
|
372
|
+
active or same-priority work and was dropped before rendering; document-shell
|
|
373
|
+
captures outrank queued navigation-only snapshots, but never interrupt the
|
|
374
|
+
active capture — the skip event and the `rango.background` span carry
|
|
375
|
+
`queuePriority`/`queueAhead` (class and backlog at enqueue) so a parked
|
|
376
|
+
capture diagnoses itself. A stored attempt reports `bakeWaitMs`: how long the
|
|
377
|
+
slowest bake source (a top-level pushed handle promise or a bake-lane loader
|
|
378
|
+
container) held the capture gate — in dev, past 2s, the source is also named
|
|
379
|
+
once per key in a console warning with the remedies (nest the promise /
|
|
380
|
+
`cache()` the work / add `loading()`). In dev, with `debugPerformance` on,
|
|
381
|
+
the last capture outcome for a key also rides the next document GET's
|
|
369
382
|
`Server-Timing` as `ppr-capture;desc="…"`.
|
|
370
383
|
- For deployed Cloudflare tier diagnostics, build with
|
|
371
384
|
`INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
|
|
@@ -621,6 +634,34 @@ capture with identity reads permitted, and the value bakes as a shared
|
|
|
621
634
|
capture-time copy — mirroring `cache()` semantics (the consumption-lane
|
|
622
635
|
rule; semantic-matrix row PPR3).
|
|
623
636
|
|
|
637
|
+
### Designing routes for cheap captures (the cost model)
|
|
638
|
+
|
|
639
|
+
The hole doctrine has a cost corollary that bites silently: **everything that
|
|
640
|
+
bakes is awaited at capture, and that wait recurs on EVERY capture of the
|
|
641
|
+
route**. A top-level pushed handle promise that takes 5s and a Meta promise
|
|
642
|
+
chained 2s further make every capture occupy the per-isolate serialized queue
|
|
643
|
+
for ~7s — while the served response still reads 13ms TTFB, because captures are
|
|
644
|
+
detached. You will not see this cost in any request timing; you see it as slow
|
|
645
|
+
MISS→HIT flips, `skip-queue-timeout` under prefetch pressure, and (in dev, past
|
|
646
|
+
2s) the bake-cost warning naming the source.
|
|
647
|
+
|
|
648
|
+
Three levers, in preference order:
|
|
649
|
+
|
|
650
|
+
1. **Nest it** (`{ data: promise }` instead of the promise): the value becomes
|
|
651
|
+
a hole — per-request, streamed under the consumer's `<Suspense>`, zero
|
|
652
|
+
capture cost. Choose this whenever the value may be dynamic anyway.
|
|
653
|
+
2. **`cache()` the work inside it**: the value stays baked (shell material,
|
|
654
|
+
tag-invalidatable), but the capture replays the cached value instead of
|
|
655
|
+
re-executing the expensive body (mixed-chain). Choose this for shared,
|
|
656
|
+
expensive-to-compute shell content.
|
|
657
|
+
3. **`loading()` on the entry** (loaders only): moves the loader to the live
|
|
658
|
+
lane — masked at capture, fresh per serve — at the cost of a fallback in
|
|
659
|
+
the shell.
|
|
660
|
+
|
|
661
|
+
Head material (Meta) generally cannot be a hole — the head is shell — so its
|
|
662
|
+
promises bake by design; make them cheap with lever 2. `bakeWaitMs` on the
|
|
663
|
+
capture debug event tells you what each capture actually paid.
|
|
664
|
+
|
|
624
665
|
## Execution matrix
|
|
625
666
|
|
|
626
667
|
| Phase | MISS (foreground) | Background capture | HIT (foreground) |
|
|
@@ -267,10 +267,13 @@ interface KVItemEnvelope {
|
|
|
267
267
|
* @internal
|
|
268
268
|
*/
|
|
269
269
|
interface CFShellEnvelope {
|
|
270
|
-
/**
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
/**
|
|
271
|
+
* base64-encoded prelude bytes. Absent iff `no` (navigationOnly entries
|
|
272
|
+
* store no document half — ShellCacheEntry.prelude).
|
|
273
|
+
*/
|
|
274
|
+
p?: string;
|
|
275
|
+
/** postponed state JSON, or null (DATA variant — no holes). Absent iff `no`. */
|
|
276
|
+
po?: string | null;
|
|
274
277
|
/** React.version captured at prerender time */
|
|
275
278
|
rv: string;
|
|
276
279
|
/** Build version captured at prerender time (ShellCacheEntry.buildVersion) */
|
|
@@ -347,8 +350,14 @@ function isShellEnvelope(value: unknown): value is CFShellEnvelope {
|
|
|
347
350
|
if (value == null || typeof value !== "object") return false;
|
|
348
351
|
const envelope = value as Partial<CFShellEnvelope>;
|
|
349
352
|
return (
|
|
350
|
-
|
|
351
|
-
|
|
353
|
+
// Document half: required unless the envelope is navigationOnly (`no`),
|
|
354
|
+
// which stores neither field. Tolerate a legacy navigationOnly envelope
|
|
355
|
+
// that still carries them.
|
|
356
|
+
(typeof envelope.p === "string" ||
|
|
357
|
+
(envelope.p === undefined && envelope.no === true)) &&
|
|
358
|
+
(envelope.po === null ||
|
|
359
|
+
typeof envelope.po === "string" ||
|
|
360
|
+
(envelope.po === undefined && envelope.no === true)) &&
|
|
352
361
|
typeof envelope.rv === "string" &&
|
|
353
362
|
(envelope.bv === undefined || typeof envelope.bv === "string") &&
|
|
354
363
|
typeof envelope.c === "number" &&
|
|
@@ -400,6 +409,8 @@ interface KVResponseEnvelope {
|
|
|
400
409
|
|
|
401
410
|
export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
402
411
|
readonly supportsPassiveShellReads: true = true;
|
|
412
|
+
/** True when constructed without KV: the shell family no-ops (see ctor). */
|
|
413
|
+
readonly shellFamilyInert?: boolean;
|
|
403
414
|
readonly defaults?: CacheDefaults;
|
|
404
415
|
readonly keyGenerator?: (
|
|
405
416
|
ctx: RequestContext<TEnv>,
|
|
@@ -471,6 +482,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
471
482
|
this.keyGenerator = options.keyGenerator;
|
|
472
483
|
this.waitUntil = (fn) => options.ctx.waitUntil(fn());
|
|
473
484
|
this.kv = options.kv;
|
|
485
|
+
// The shell family requires KV (getShell/putShell no-op without it — see
|
|
486
|
+
// warnShellFamilyInertOnce). Declaring it lets scheduleShellCapture skip
|
|
487
|
+
// captures whose write could only no-op instead of burning a background
|
|
488
|
+
// render per MISS that still occupies the serialized capture queue.
|
|
489
|
+
this.shellFamilyInert = options.kv ? undefined : true;
|
|
474
490
|
this.onRevalidateTag = options.onRevalidateTag;
|
|
475
491
|
// tagPurge accepts a ready purge function or a credentials object; the
|
|
476
492
|
// object form is normalized through the built-in zone purge client, which
|
|
@@ -2083,8 +2099,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2083
2099
|
}
|
|
2084
2100
|
|
|
2085
2101
|
const envelope: CFShellEnvelope = {
|
|
2086
|
-
|
|
2087
|
-
|
|
2102
|
+
// Presence-keyed: a navigationOnly entry has no document half, and an
|
|
2103
|
+
// omitted key (vs an explicit undefined) also keeps it out of the
|
|
2104
|
+
// serialized JSON.
|
|
2105
|
+
...(entry.prelude !== undefined ? { p: entry.prelude } : {}),
|
|
2106
|
+
...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
|
|
2088
2107
|
rv: entry.reactVersion,
|
|
2089
2108
|
bv: entry.buildVersion,
|
|
2090
2109
|
c: entry.createdAt,
|
package/src/cache/types.ts
CHANGED
|
@@ -198,6 +198,16 @@ export interface SegmentCacheStore<TEnv = unknown> {
|
|
|
198
198
|
tags?: string[],
|
|
199
199
|
): Promise<"stored" | "invalidated" | void>;
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Declares the shell family present-but-inert: getShell/putShell exist but
|
|
203
|
+
* no-op (CFCacheStore without a KV namespace). scheduleShellCapture skips
|
|
204
|
+
* captures whose only write target is inert — the background render would be
|
|
205
|
+
* dead work that still occupies the per-isolate serialized capture queue
|
|
206
|
+
* (a promise-heavy route bakes for seconds per MISS with nothing stored).
|
|
207
|
+
* Absent/false means the family, when present, actually stores.
|
|
208
|
+
*/
|
|
209
|
+
shellFamilyInert?: boolean;
|
|
210
|
+
|
|
201
211
|
/**
|
|
202
212
|
* Get a cached function result by key.
|
|
203
213
|
* Returns the serialized value, optional handle data, and staleness flag.
|
|
@@ -264,8 +274,8 @@ export interface CacheItemResult {
|
|
|
264
274
|
/**
|
|
265
275
|
* A cached PPR (Partial Pre-rendering) shell entry.
|
|
266
276
|
*
|
|
267
|
-
*
|
|
268
|
-
* and React's postponed state — because the pair is version- and
|
|
277
|
+
* A DOCUMENT entry carries BOTH artifacts a resume needs — the rendered HTML
|
|
278
|
+
* prelude and React's postponed state — because the pair is version- and
|
|
269
279
|
* generation-coupled and must never be mixed across a React upgrade or a build
|
|
270
280
|
* change. The reactVersion and buildVersion fields are the read-time gates that
|
|
271
281
|
* enforce both halves: isValidShellHit (rsc/shell-serve.ts) treats an entry
|
|
@@ -274,15 +284,28 @@ export interface CacheItemResult {
|
|
|
274
284
|
* positions against one exact tree; resuming it against a different React or a
|
|
275
285
|
* different app build tree-mismatches inside resume(), AFTER the 200 + prelude
|
|
276
286
|
* are committed — an unrecoverable broken serve).
|
|
287
|
+
*
|
|
288
|
+
* A `navigationOnly` entry stores NEITHER half: nothing ever serves its HTML
|
|
289
|
+
* (document serving skips navigationOnly entries at the read gate, and partial
|
|
290
|
+
* replay consumes only `snapshot`/`docKey`), so the prelude would ride every
|
|
291
|
+
* store write and read as dead weight at KV-value scale. The capture still runs
|
|
292
|
+
* the full fizz prerender — it is the completeness arbiter and sanity gate —
|
|
293
|
+
* but its output is dropped before putShell. hasIntactShellPayload is the
|
|
294
|
+
* document-half gate; navigationOnly entries never satisfy it.
|
|
277
295
|
*/
|
|
278
296
|
export interface ShellCacheEntry {
|
|
279
|
-
/**
|
|
280
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Rendered HTML prelude bytes, base64-encoded (stores are JSON-serializing).
|
|
299
|
+
* Absent on `navigationOnly` entries (no document half is stored — see the
|
|
300
|
+
* interface doc); present on every document-servable entry.
|
|
301
|
+
*/
|
|
302
|
+
prelude?: string;
|
|
281
303
|
/**
|
|
282
304
|
* JSON.stringify of React's postponed state, or null when the shell settled
|
|
283
|
-
* with no holes (the DATA variant — served without a fizz resume).
|
|
305
|
+
* with no holes (the DATA variant — served without a fizz resume). Absent
|
|
306
|
+
* exactly when `prelude` is (navigationOnly entries).
|
|
284
307
|
*/
|
|
285
|
-
postponed
|
|
308
|
+
postponed?: string | null;
|
|
286
309
|
/** React.version captured at prerender time; the read-time invalidation gate. */
|
|
287
310
|
reactVersion: string;
|
|
288
311
|
/**
|
|
@@ -366,6 +389,18 @@ export interface ShellCacheEntry {
|
|
|
366
389
|
createdAt: number;
|
|
367
390
|
}
|
|
368
391
|
|
|
392
|
+
/**
|
|
393
|
+
* A shell entry whose document half is present — what the document HIT path
|
|
394
|
+
* (serveShellHit / lookupBuildShell) consumes. hasIntactShellPayload
|
|
395
|
+
* (rsc/shell-serve.ts) is the runtime gate AND the type narrowing to this
|
|
396
|
+
* shape; navigationOnly entries never pass it (their document half is not
|
|
397
|
+
* stored).
|
|
398
|
+
*/
|
|
399
|
+
export type DocumentShellCacheEntry = ShellCacheEntry & {
|
|
400
|
+
prelude: string;
|
|
401
|
+
postponed: string | null;
|
|
402
|
+
};
|
|
403
|
+
|
|
369
404
|
/**
|
|
370
405
|
* The families a shell snapshot pins. The item/segment/response families are
|
|
371
406
|
* cache-store reads/writes (recorded by RecordingShellStore); the loader family
|
|
@@ -184,10 +184,13 @@ interface VercelResponseEnvelope {
|
|
|
184
184
|
|
|
185
185
|
/** Stored envelope for a PPR shell entry (getShell/putShell). */
|
|
186
186
|
interface VercelShellEnvelope {
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
/**
|
|
188
|
+
* base64-encoded prelude bytes. Absent iff `no` (navigationOnly entries
|
|
189
|
+
* store no document half — ShellCacheEntry.prelude).
|
|
190
|
+
*/
|
|
191
|
+
p?: string;
|
|
192
|
+
/** postponed state JSON, or null (DATA variant). Absent iff `no`. */
|
|
193
|
+
po?: string | null;
|
|
191
194
|
/** React.version at capture. */
|
|
192
195
|
rv: string;
|
|
193
196
|
/** Build version at capture (ShellCacheEntry.buildVersion). */
|
|
@@ -868,8 +871,11 @@ export class VercelCacheStore<
|
|
|
868
871
|
? Math.min(totalTtl, TAG_MARKER_TTL_SECONDS)
|
|
869
872
|
: totalTtl;
|
|
870
873
|
const env: VercelShellEnvelope = {
|
|
871
|
-
|
|
872
|
-
|
|
874
|
+
// Presence-keyed: a navigationOnly entry has no document half, and an
|
|
875
|
+
// omitted key (vs an explicit undefined) also keeps it out of the
|
|
876
|
+
// serialized JSON.
|
|
877
|
+
...(entry.prelude !== undefined ? { p: entry.prelude } : {}),
|
|
878
|
+
...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
|
|
873
879
|
rv: entry.reactVersion,
|
|
874
880
|
bv: entry.buildVersion,
|
|
875
881
|
c: entry.createdAt,
|
|
@@ -1210,13 +1216,21 @@ export class VercelCacheStore<
|
|
|
1210
1216
|
private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
|
|
1211
1217
|
if (!isRecord(raw)) return null;
|
|
1212
1218
|
const { p, po, rv, bv, c, s, e, t, i, sn, dk, lh, tw, no } = raw;
|
|
1213
|
-
if (typeof
|
|
1214
|
-
|
|
1219
|
+
if (typeof rv !== "string") return null;
|
|
1220
|
+
// Document half: required unless navigationOnly (`no`), which stores
|
|
1221
|
+
// neither field. Tolerate a legacy navigationOnly envelope that still
|
|
1222
|
+
// carries them.
|
|
1223
|
+
if (p === undefined ? no !== true : typeof p !== "string") return null;
|
|
1224
|
+
if (
|
|
1225
|
+
po === undefined ? no !== true : po !== null && typeof po !== "string"
|
|
1226
|
+
) {
|
|
1227
|
+
return null;
|
|
1228
|
+
}
|
|
1215
1229
|
if (typeof c !== "number") return null;
|
|
1216
1230
|
if (typeof s !== "number" || typeof e !== "number") return null;
|
|
1217
1231
|
return {
|
|
1218
|
-
p,
|
|
1219
|
-
po: po as string | null,
|
|
1232
|
+
...(typeof p === "string" ? { p } : {}),
|
|
1233
|
+
...(po !== undefined ? { po: po as string | null } : {}),
|
|
1220
1234
|
rv,
|
|
1221
1235
|
bv: typeof bv === "string" ? bv : undefined,
|
|
1222
1236
|
c,
|
package/src/rsc/capture-queue.ts
CHANGED
|
@@ -19,15 +19,55 @@
|
|
|
19
19
|
* is bounded; a capture killed mid-queue by that bound simply recaptures on a
|
|
20
20
|
* later request (the existing best-effort contract).
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Document shells outrank queued navigation-only snapshots. Production Link
|
|
23
|
+
* prefetching can enqueue several expensive navigation captures at once; a
|
|
24
|
+
* strict FIFO made the requesting page's document capture wait
|
|
25
|
+
* behind speculative work until its queue budget expired. The active capture
|
|
26
|
+
* is never interrupted, and each priority remains FIFO.
|
|
24
27
|
*/
|
|
25
|
-
let captureQueue: Promise<void> = Promise.resolve();
|
|
26
28
|
let admittedCaptures = 0;
|
|
29
|
+
let captureRunning = false;
|
|
30
|
+
|
|
31
|
+
interface QueuedCapture {
|
|
32
|
+
waitStart: number;
|
|
33
|
+
state: "waiting" | "running" | "settled";
|
|
34
|
+
signalStart: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const documentCaptures: QueuedCapture[] = [];
|
|
38
|
+
const navigationCaptures: QueuedCapture[] = [];
|
|
27
39
|
|
|
28
40
|
/** Bound queued/running capture closures retained by one isolate. */
|
|
29
41
|
export const MAX_ADMITTED_CAPTURES: number = 32;
|
|
30
42
|
|
|
43
|
+
/** Point-in-time queue occupancy, for capture-scheduling telemetry. */
|
|
44
|
+
export interface CaptureQueueDepths {
|
|
45
|
+
/** A capture is currently executing (never preempted). */
|
|
46
|
+
running: boolean;
|
|
47
|
+
/** Waiting document-priority captures (run before every navigation capture). */
|
|
48
|
+
document: number;
|
|
49
|
+
/** Waiting navigation-priority captures. */
|
|
50
|
+
navigation: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Snapshot the queue's waiting/running state. Wait-timed-out entries still
|
|
55
|
+
* sitting in the arrays (pruned lazily by takeNextCapture) are excluded — they
|
|
56
|
+
* will never run, so counting them would overstate the backlog.
|
|
57
|
+
*/
|
|
58
|
+
export function captureQueueDepths(): CaptureQueueDepths {
|
|
59
|
+
const waiting = (queue: QueuedCapture[]): number => {
|
|
60
|
+
let count = 0;
|
|
61
|
+
for (const queued of queue) if (queued.state === "waiting") count++;
|
|
62
|
+
return count;
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
running: captureRunning,
|
|
66
|
+
document: waiting(documentCaptures),
|
|
67
|
+
navigation: waiting(navigationCaptures),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
31
71
|
/** The caller may drop this best-effort capture and retry on a later request. */
|
|
32
72
|
export class CaptureQueueFullError extends Error {
|
|
33
73
|
constructor() {
|
|
@@ -65,7 +105,7 @@ export class CaptureQueueWaitTimeoutError extends Error {
|
|
|
65
105
|
}
|
|
66
106
|
|
|
67
107
|
/**
|
|
68
|
-
* Upper bound on how long one
|
|
108
|
+
* Upper bound on how long one running capture may hold the queue. A capture task
|
|
69
109
|
* normally settles well inside this (attempt + in-place retry + writes), but
|
|
70
110
|
* a task wedged on never-settling I/O — a workerd waitUntil fetch that pends
|
|
71
111
|
* instead of rejecting (seen on GH runners with the dev prerender store
|
|
@@ -76,70 +116,103 @@ export class CaptureQueueWaitTimeoutError extends Error {
|
|
|
76
116
|
const QUEUE_LINK_CAP_MS = 60_000;
|
|
77
117
|
|
|
78
118
|
/**
|
|
79
|
-
* Run `task` after
|
|
80
|
-
* promise for THIS task's completion
|
|
81
|
-
* the queue itself is insulated).
|
|
119
|
+
* Run `task` after the active capture and every higher/equal-priority capture
|
|
120
|
+
* ahead of it have settled. Returns a promise for THIS task's completion
|
|
121
|
+
* (rejections propagate to the caller — the queue itself is insulated).
|
|
82
122
|
*/
|
|
83
123
|
export function enqueueSerializedCapture(
|
|
84
124
|
task: () => Promise<void>,
|
|
85
|
-
opts?: {
|
|
125
|
+
opts?: {
|
|
126
|
+
maxQueueWaitMs?: number;
|
|
127
|
+
priority?: "document" | "navigation";
|
|
128
|
+
},
|
|
86
129
|
): Promise<void> {
|
|
87
130
|
if (admittedCaptures >= MAX_ADMITTED_CAPTURES) {
|
|
88
131
|
return Promise.reject(new CaptureQueueFullError());
|
|
89
132
|
}
|
|
90
133
|
admittedCaptures++;
|
|
91
|
-
|
|
92
|
-
let
|
|
93
|
-
|
|
94
|
-
|
|
134
|
+
|
|
135
|
+
let signalStart!: () => void;
|
|
136
|
+
const startGate = new Promise<void>((resolve) => {
|
|
137
|
+
signalStart = resolve;
|
|
95
138
|
});
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
const queued: QueuedCapture = {
|
|
140
|
+
waitStart: performance.now(),
|
|
141
|
+
state: "waiting",
|
|
142
|
+
signalStart,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const completion = (async () => {
|
|
98
146
|
const budget = opts?.maxQueueWaitMs ?? CAPTURE_QUEUE_WAIT_BUDGET_MS;
|
|
99
|
-
// The wait itself is budget-raced: the drop fires AT the budget, not once
|
|
100
|
-
// the predecessor eventually settles — a waiter parked behind a wedged
|
|
101
|
-
// link must not stay pending until that link's 60s cap just to learn it
|
|
102
|
-
// was already over budget.
|
|
103
147
|
let waitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
104
148
|
const waitTimedOut = await Promise.race([
|
|
105
|
-
|
|
149
|
+
startGate.then(() => false),
|
|
106
150
|
new Promise<boolean>((resolve) => {
|
|
107
|
-
waitTimer = setTimeout(() =>
|
|
151
|
+
waitTimer = setTimeout(() => {
|
|
152
|
+
if (queued.state !== "waiting") return;
|
|
153
|
+
queued.state = "settled";
|
|
154
|
+
admittedCaptures--;
|
|
155
|
+
resolve(true);
|
|
156
|
+
}, budget);
|
|
108
157
|
(waitTimer as { unref?: () => void }).unref?.();
|
|
109
158
|
}),
|
|
110
159
|
]);
|
|
111
160
|
if (waitTimer) clearTimeout(waitTimer);
|
|
112
161
|
if (waitTimedOut) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// releasing now would let a successor run concurrently with it.
|
|
117
|
-
admittedCaptures--;
|
|
118
|
-
prior.catch(() => {}).then(releaseQueue, releaseQueue);
|
|
119
|
-
throw new CaptureQueueWaitTimeoutError(performance.now() - waitStart);
|
|
162
|
+
throw new CaptureQueueWaitTimeoutError(
|
|
163
|
+
performance.now() - queued.waitStart,
|
|
164
|
+
);
|
|
120
165
|
}
|
|
166
|
+
|
|
167
|
+
let capTimer: ReturnType<typeof setTimeout> | undefined;
|
|
168
|
+
const taskPromise = Promise.resolve()
|
|
169
|
+
.then(task)
|
|
170
|
+
.finally(() => {
|
|
171
|
+
// A capped capture releases serialization, but its detached task still
|
|
172
|
+
// counts against admission until it actually settles.
|
|
173
|
+
admittedCaptures--;
|
|
174
|
+
});
|
|
175
|
+
const cap = new Promise<void>((resolve) => {
|
|
176
|
+
capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
|
|
177
|
+
(capTimer as { unref?: () => void }).unref?.();
|
|
178
|
+
});
|
|
179
|
+
|
|
121
180
|
try {
|
|
122
|
-
|
|
123
|
-
const taskPromise = Promise.resolve()
|
|
124
|
-
.then(task)
|
|
125
|
-
.finally(() => {
|
|
126
|
-
// A timed-out link releases serialization, but its detached task still
|
|
127
|
-
// counts against admission until it actually settles.
|
|
128
|
-
admittedCaptures--;
|
|
129
|
-
});
|
|
130
|
-
try {
|
|
131
|
-
await Promise.race([
|
|
132
|
-
taskPromise,
|
|
133
|
-
new Promise<void>((resolve) => {
|
|
134
|
-
capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
|
|
135
|
-
(capTimer as { unref?: () => void }).unref?.();
|
|
136
|
-
}),
|
|
137
|
-
]);
|
|
138
|
-
} finally {
|
|
139
|
-
if (capTimer) clearTimeout(capTimer);
|
|
140
|
-
}
|
|
181
|
+
await Promise.race([taskPromise, cap]);
|
|
141
182
|
} finally {
|
|
142
|
-
|
|
183
|
+
if (capTimer) clearTimeout(capTimer);
|
|
184
|
+
queued.state = "settled";
|
|
185
|
+
captureRunning = false;
|
|
186
|
+
// Handoff must happen before this caller's waitUntil promise settles.
|
|
187
|
+
// Resolving first lets workerd terminate the context with the queue lock
|
|
188
|
+
// still held, permanently parking later captures in the isolate.
|
|
189
|
+
startNextCapture();
|
|
143
190
|
}
|
|
144
191
|
})();
|
|
192
|
+
|
|
193
|
+
const queue =
|
|
194
|
+
opts?.priority === "document" ? documentCaptures : navigationCaptures;
|
|
195
|
+
queue.push(queued);
|
|
196
|
+
startNextCapture();
|
|
197
|
+
return completion;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function takeNextCapture(): QueuedCapture | undefined {
|
|
201
|
+
for (const queue of [documentCaptures, navigationCaptures]) {
|
|
202
|
+
let queued: QueuedCapture | undefined;
|
|
203
|
+
while ((queued = queue.shift())) {
|
|
204
|
+
if (queued.state === "waiting") return queued;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function startNextCapture(): void {
|
|
211
|
+
if (captureRunning) return;
|
|
212
|
+
const queued = takeNextCapture();
|
|
213
|
+
if (!queued) return;
|
|
214
|
+
|
|
215
|
+
captureRunning = true;
|
|
216
|
+
queued.state = "running";
|
|
217
|
+
queued.signalStart();
|
|
145
218
|
}
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -79,6 +79,7 @@ import { reportCacheError } from "../cache/cache-error.js";
|
|
|
79
79
|
import type { SearchParamsFilter } from "../cache/search-params-filter.js";
|
|
80
80
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
81
81
|
import type {
|
|
82
|
+
DocumentShellCacheEntry,
|
|
82
83
|
SegmentCacheStore,
|
|
83
84
|
ShellCacheEntry,
|
|
84
85
|
ShellSnapshotRecord,
|
|
@@ -212,7 +213,14 @@ function replayableShellSnapshot(
|
|
|
212
213
|
if (!isValidShellHit(entry, buildVersion)) {
|
|
213
214
|
return { reason: "invalid-version" };
|
|
214
215
|
}
|
|
215
|
-
|
|
216
|
+
// navigationOnly entries store no document half (prelude/postponed absent),
|
|
217
|
+
// so the payload gate applies to document-snapshot entries alone. Replay
|
|
218
|
+
// consumes only the snapshot; fragment corruption inside it is caught by the
|
|
219
|
+
// consumer-side Flight decoders (SegmentFragmentDecodeError →
|
|
220
|
+
// segmentReplayCorrupt → healing capture at the document key).
|
|
221
|
+
if (!entry.navigationOnly && !hasIntactShellPayload(entry)) {
|
|
222
|
+
return { reason: "corrupt-entry" };
|
|
223
|
+
}
|
|
216
224
|
if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
|
|
217
225
|
if (entry.transitionWhen) return { reason: "transition-when" };
|
|
218
226
|
// Eligibility requires the CANONICAL doc segment record (`docKey`), not just
|
|
@@ -386,7 +394,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
386
394
|
// build-manifest hit further down): schedule the background
|
|
387
395
|
// recapture when asked, then commit the composed response.
|
|
388
396
|
const serveHit = (
|
|
389
|
-
entry:
|
|
397
|
+
entry: DocumentShellCacheEntry,
|
|
390
398
|
revalidate: boolean | undefined,
|
|
391
399
|
): Response => {
|
|
392
400
|
if (revalidate) {
|
|
@@ -1159,7 +1167,7 @@ function serveShellHit(
|
|
|
1159
1167
|
reqCtx: RequestContext<any>,
|
|
1160
1168
|
handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
|
|
1161
1169
|
ssrModule: SSRModule,
|
|
1162
|
-
entry:
|
|
1170
|
+
entry: DocumentShellCacheEntry,
|
|
1163
1171
|
descriptor: ShellCaptureDescriptor,
|
|
1164
1172
|
): Response {
|
|
1165
1173
|
const preludeBytes = base64ToBytes(entry.prelude);
|
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
* markers say whether it is still current.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
DocumentShellCacheEntry,
|
|
30
|
+
SegmentCacheStore,
|
|
31
|
+
ShellCacheEntry,
|
|
32
|
+
} from "../cache/types.js";
|
|
29
33
|
import { sortedSearchString } from "../cache/cache-key-utils.js";
|
|
30
34
|
import type { SearchParamsFilter } from "../cache/search-params-filter.js";
|
|
31
35
|
import {
|
|
@@ -88,20 +92,29 @@ function loadManifest(): Promise<ShellManifestModule | null> {
|
|
|
88
92
|
* Spec-only keying is sound because buildVersion is process-constant on the
|
|
89
93
|
* manifest path (folded into the shipped worker; dev never loads a manifest).
|
|
90
94
|
*/
|
|
91
|
-
const validatedSpecs = new Map<string,
|
|
95
|
+
const validatedSpecs = new Map<string, ValidatedBuildShellEntry | null>();
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A manifest record whose document half passed hasIntactShellPayload — the
|
|
99
|
+
* casts below sit directly on that runtime gate (build entries are always
|
|
100
|
+
* document-shaped; the predicate narrows entry, not the record around it).
|
|
101
|
+
*/
|
|
102
|
+
type ValidatedBuildShellEntry = BuildShellEntry & {
|
|
103
|
+
entry: DocumentShellCacheEntry;
|
|
104
|
+
};
|
|
92
105
|
|
|
93
106
|
async function validatedManifestRecord(
|
|
94
107
|
mod: ShellManifestModule,
|
|
95
108
|
spec: string,
|
|
96
109
|
buildVersion: string,
|
|
97
|
-
): Promise<
|
|
110
|
+
): Promise<ValidatedBuildShellEntry | undefined> {
|
|
98
111
|
let verdict = validatedSpecs.get(spec);
|
|
99
112
|
if (verdict === undefined) {
|
|
100
113
|
const record = (await mod.loadShellAsset(spec)).default;
|
|
101
114
|
verdict =
|
|
102
115
|
isValidShellHit(record.entry, buildVersion) &&
|
|
103
116
|
hasIntactShellPayload(record.entry)
|
|
104
|
-
? record
|
|
117
|
+
? (record as ValidatedBuildShellEntry)
|
|
105
118
|
: null;
|
|
106
119
|
validatedSpecs.set(spec, verdict);
|
|
107
120
|
}
|
|
@@ -118,7 +131,7 @@ export function resetBuildShellManifestForTests(): void {
|
|
|
118
131
|
const warnedTagCheckUnsupported = new Set<string>();
|
|
119
132
|
|
|
120
133
|
export interface BuildShellHit {
|
|
121
|
-
entry:
|
|
134
|
+
entry: DocumentShellCacheEntry;
|
|
122
135
|
/** Past createdAt + ttl: serve, but schedule the runtime recapture. */
|
|
123
136
|
stale: boolean;
|
|
124
137
|
}
|
|
@@ -273,7 +286,7 @@ export async function lookupBuildShell(
|
|
|
273
286
|
const hasManifest = globalThis.__loadShellManifestModule !== undefined;
|
|
274
287
|
if (!hasManifest && !dev) return null;
|
|
275
288
|
if (sortedSearchString(url.searchParams, filter) !== "") return null;
|
|
276
|
-
let record:
|
|
289
|
+
let record: ValidatedBuildShellEntry | undefined;
|
|
277
290
|
if (hasManifest) {
|
|
278
291
|
const mod = await loadManifest();
|
|
279
292
|
if (!mod) return null;
|
|
@@ -286,7 +299,7 @@ export async function lookupBuildShell(
|
|
|
286
299
|
fetched !== undefined &&
|
|
287
300
|
isValidShellHit(fetched.entry, buildVersion) &&
|
|
288
301
|
hasIntactShellPayload(fetched.entry)
|
|
289
|
-
? fetched
|
|
302
|
+
? (fetched as ValidatedBuildShellEntry)
|
|
290
303
|
: undefined;
|
|
291
304
|
}
|
|
292
305
|
if (!record) return null;
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { runBackground } from "../cache/background-task.js";
|
|
|
23
23
|
import {
|
|
24
24
|
CaptureQueueFullError,
|
|
25
25
|
CaptureQueueWaitTimeoutError,
|
|
26
|
+
captureQueueDepths,
|
|
26
27
|
enqueueSerializedCapture,
|
|
27
28
|
} from "./capture-queue.js";
|
|
28
29
|
import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
|
|
@@ -397,6 +398,43 @@ function warnSnapshotOverCapOnce(
|
|
|
397
398
|
);
|
|
398
399
|
}
|
|
399
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Dev warning threshold (ms) for the slowest bake-source settlement wait. Bake
|
|
403
|
+
* time is by-design shell latency (top-level pushed handle promises and
|
|
404
|
+
* bake-lane loader containers settle before the freeze), but it recurs on
|
|
405
|
+
* EVERY capture of the route and occupies the per-isolate serialized capture
|
|
406
|
+
* queue — silently: the served response's own timing never shows it (a route
|
|
407
|
+
* can read 13ms TTFB while each of its captures holds the queue for seconds).
|
|
408
|
+
* Past this threshold the source gets named once per key with the remedy
|
|
409
|
+
* ladder.
|
|
410
|
+
*/
|
|
411
|
+
const SHELL_CAPTURE_BAKE_WARN_MS = 2_000;
|
|
412
|
+
|
|
413
|
+
/** Keys already warned about an expensive bake source (once per key). */
|
|
414
|
+
const warnedBakeCosts = new Set<string>();
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Dev-only, once per key: name the slowest bake source and the three remedies
|
|
418
|
+
* in preference order. The doctrine ("nesting = liveness") makes the cost
|
|
419
|
+
* legitimate, so this is a cost report with an exit, not a deprecation.
|
|
420
|
+
*/
|
|
421
|
+
function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
422
|
+
if (warnedBakeCosts.has(key)) return;
|
|
423
|
+
warnedBakeCosts.add(key);
|
|
424
|
+
console.warn(
|
|
425
|
+
`[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
|
|
426
|
+
"settle before the shell could freeze. This cost recurs on every capture " +
|
|
427
|
+
"of the route and occupies the per-isolate capture queue; the served " +
|
|
428
|
+
"response never shows it. Top-level promises BAKE by design (the " +
|
|
429
|
+
"container settles; nested promises stay live). To make the value " +
|
|
430
|
+
"per-request instead, nest it ({ data: promise }) and read it with use() " +
|
|
431
|
+
"under Suspense; to keep it baked but cheap, wrap the work in cache() " +
|
|
432
|
+
"(the capture replays the cached value); for a segment loader, add a " +
|
|
433
|
+
"loading() boundary to its entry (live lane, masked at capture). See the " +
|
|
434
|
+
"/ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
400
438
|
/**
|
|
401
439
|
* One structured event from the background capture pipeline, mirroring the
|
|
402
440
|
* CFCacheReadDebugEvent pattern (cache/cf/cf-cache-types.ts): typed fields an
|
|
@@ -418,6 +456,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
418
456
|
* - skip-backoff: the key is inside its refused-capture backoff window and
|
|
419
457
|
* the capture was not attempted
|
|
420
458
|
* - skip-capacity: the isolate capture queue is full; a later request may retry
|
|
459
|
+
* - skip-inert-store: the resolved store's shell family is missing or
|
|
460
|
+
* declared inert (SegmentCacheStore.shellFamilyInert — a CFCacheStore
|
|
461
|
+
* without a KV namespace); nothing could store the result, so the
|
|
462
|
+
* background render was not scheduled at all
|
|
421
463
|
* - skip-queue-timeout: the capture waited past CAPTURE_QUEUE_WAIT_BUDGET_MS
|
|
422
464
|
* behind other captures and was dropped unrun (no backoff — the route is
|
|
423
465
|
* not doomed, the isolate was busy; a later request re-probes). Carries
|
|
@@ -434,6 +476,7 @@ export interface ShellCaptureDebugEvent {
|
|
|
434
476
|
| "skip-in-flight"
|
|
435
477
|
| "skip-backoff"
|
|
436
478
|
| "skip-capacity"
|
|
479
|
+
| "skip-inert-store"
|
|
437
480
|
| "skip-queue-timeout"
|
|
438
481
|
| "backoff";
|
|
439
482
|
/** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
|
|
@@ -466,6 +509,22 @@ export interface ShellCaptureDebugEvent {
|
|
|
466
509
|
backoffRemainingMs?: number;
|
|
467
510
|
/** Ms the capture waited in the serialized queue (skip-queue-timeout). */
|
|
468
511
|
queueWaitMs?: number;
|
|
512
|
+
/** Queue priority class at enqueue: document outranks queued navigation. */
|
|
513
|
+
queuePriority?: "document" | "navigation";
|
|
514
|
+
/**
|
|
515
|
+
* Captures ahead at enqueue: the active one plus every waiting capture of
|
|
516
|
+
* same-or-higher priority. 0 = this capture starts immediately.
|
|
517
|
+
*/
|
|
518
|
+
queueAhead?: number;
|
|
519
|
+
/**
|
|
520
|
+
* Ms the capture gate was HELD waiting for the slowest bake source — a
|
|
521
|
+
* top-level pushed handle promise or a bake-lane loader container — to
|
|
522
|
+
* settle, measured from capture start. Bake time is by-design shell latency
|
|
523
|
+
* (the hole doctrine: top-level promises bake), but it recurs on EVERY
|
|
524
|
+
* capture of the route and occupies the serialized queue; this field makes
|
|
525
|
+
* it attributable. Absent when nothing held the gate.
|
|
526
|
+
*/
|
|
527
|
+
bakeWaitMs?: number;
|
|
469
528
|
}
|
|
470
529
|
|
|
471
530
|
/**
|
|
@@ -515,6 +574,15 @@ export function describeShellCaptureEvent(
|
|
|
515
574
|
if (event.queueWaitMs !== undefined) {
|
|
516
575
|
parts.push(`queue-wait=${event.queueWaitMs}ms`);
|
|
517
576
|
}
|
|
577
|
+
if (event.queuePriority !== undefined) {
|
|
578
|
+
parts.push(`queue-priority=${event.queuePriority}`);
|
|
579
|
+
}
|
|
580
|
+
if (event.queueAhead !== undefined) {
|
|
581
|
+
parts.push(`queue-ahead=${event.queueAhead}`);
|
|
582
|
+
}
|
|
583
|
+
if (event.bakeWaitMs !== undefined) {
|
|
584
|
+
parts.push(`bake=${event.bakeWaitMs}ms`);
|
|
585
|
+
}
|
|
518
586
|
return parts.join(" ");
|
|
519
587
|
}
|
|
520
588
|
|
|
@@ -864,6 +932,16 @@ export function scheduleShellCapture(
|
|
|
864
932
|
});
|
|
865
933
|
return;
|
|
866
934
|
}
|
|
935
|
+
// A capture whose write can only no-op is dead work that still occupies the
|
|
936
|
+
// serialized queue (a promise-heavy route bakes for seconds per MISS),
|
|
937
|
+
// starving captures that CAN store. Skip scheduling entirely when the
|
|
938
|
+
// resolved store (same defaulting as captureAndStoreShell) has no shell
|
|
939
|
+
// family or declared it inert (CFCacheStore without a KV namespace).
|
|
940
|
+
const scheduledStore = descriptor.store ?? reqCtx._cacheStore;
|
|
941
|
+
if (!scheduledStore?.putShell || scheduledStore.shellFamilyInert) {
|
|
942
|
+
publishCaptureDebugEvent(descriptor, { key, outcome: "skip-inert-store" });
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
867
945
|
inFlightCaptures.add(key);
|
|
868
946
|
const captureTask = async (span: TraceSpan) => {
|
|
869
947
|
try {
|
|
@@ -941,15 +1019,34 @@ export function scheduleShellCapture(
|
|
|
941
1019
|
const serializedTask = async () => {
|
|
942
1020
|
await observePhase(PHASES.background("shell-capture"), async (span) => {
|
|
943
1021
|
span.setAttribute("rango.shell_key", key);
|
|
1022
|
+
// A production page can enqueue several navigation-only captures through
|
|
1023
|
+
// viewport prefetching. Let a later document MISS overtake that queued
|
|
1024
|
+
// speculative work so it can become a shell HIT before the 15s queue
|
|
1025
|
+
// budget expires. Never preempts the active capture. Priority and the
|
|
1026
|
+
// backlog ahead at enqueue ride the span (and the skip event below), so
|
|
1027
|
+
// a skip-queue-timeout diagnoses itself instead of needing a trace dive.
|
|
1028
|
+
const queuePriority = descriptor.navigationOnly
|
|
1029
|
+
? ("navigation" as const)
|
|
1030
|
+
: ("document" as const);
|
|
1031
|
+
const depths = captureQueueDepths();
|
|
1032
|
+
const queueAhead =
|
|
1033
|
+
(depths.running ? 1 : 0) +
|
|
1034
|
+
depths.document +
|
|
1035
|
+
(queuePriority === "navigation" ? depths.navigation : 0);
|
|
1036
|
+
span.setAttribute("rango.background.queue_priority", queuePriority);
|
|
1037
|
+
span.setAttribute("rango.background.queue_ahead", queueAhead);
|
|
944
1038
|
const queueStart = performance.now();
|
|
945
1039
|
try {
|
|
946
|
-
await enqueueSerializedCapture(
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1040
|
+
await enqueueSerializedCapture(
|
|
1041
|
+
() => {
|
|
1042
|
+
span.setAttribute(
|
|
1043
|
+
"rango.background.queue_wait_ms",
|
|
1044
|
+
Math.round(performance.now() - queueStart),
|
|
1045
|
+
);
|
|
1046
|
+
return captureTask(span);
|
|
1047
|
+
},
|
|
1048
|
+
{ priority: queuePriority },
|
|
1049
|
+
);
|
|
953
1050
|
} catch (error) {
|
|
954
1051
|
if (error instanceof CaptureQueueFullError) {
|
|
955
1052
|
inFlightCaptures.delete(key);
|
|
@@ -974,6 +1071,8 @@ export function scheduleShellCapture(
|
|
|
974
1071
|
key,
|
|
975
1072
|
outcome: "skip-queue-timeout",
|
|
976
1073
|
queueWaitMs: Math.round(error.waitedMs),
|
|
1074
|
+
queuePriority,
|
|
1075
|
+
queueAhead,
|
|
977
1076
|
});
|
|
978
1077
|
return;
|
|
979
1078
|
}
|
|
@@ -1035,6 +1134,7 @@ type CaptureAttemptStats = Pick<
|
|
|
1035
1134
|
| "snapshotSkipped"
|
|
1036
1135
|
| "untaggedBake"
|
|
1037
1136
|
| "storeWrite"
|
|
1137
|
+
| "bakeWaitMs"
|
|
1038
1138
|
>;
|
|
1039
1139
|
|
|
1040
1140
|
/**
|
|
@@ -1511,6 +1611,29 @@ async function captureAndStoreShell(
|
|
|
1511
1611
|
loaderRecordsForHold && loaderRecordsForHold.size > 0
|
|
1512
1612
|
? Promise.allSettled([handlesBaked, ...loaderRecordsForHold.values()])
|
|
1513
1613
|
: handlesBaked;
|
|
1614
|
+
// Bake-cost attribution: record how long each bake source held the gate,
|
|
1615
|
+
// measured from capture start. Side-channel observers only — holdUntil and
|
|
1616
|
+
// its consumers are untouched, and a rejected source is recorded the same
|
|
1617
|
+
// (allSettled swallows it; the drain below owns the refusal). Folded into
|
|
1618
|
+
// stats.bakeWaitMs (+ a once-per-key dev warning) after a successful
|
|
1619
|
+
// capture; on a refusal the other warnings own the story.
|
|
1620
|
+
const bakeStart = performance.now();
|
|
1621
|
+
const bakeWaits: { source: string; ms: number }[] = [];
|
|
1622
|
+
const observeBake = (source: string, promise: Promise<unknown>): void => {
|
|
1623
|
+
const record = (): void => {
|
|
1624
|
+
bakeWaits.push({
|
|
1625
|
+
source,
|
|
1626
|
+
ms: Math.round(performance.now() - bakeStart),
|
|
1627
|
+
});
|
|
1628
|
+
};
|
|
1629
|
+
promise.then(record, record);
|
|
1630
|
+
};
|
|
1631
|
+
observeBake("top-level pushed handle promises", handlesBaked);
|
|
1632
|
+
if (loaderRecordsForHold) {
|
|
1633
|
+
for (const [loaderId, promise] of loaderRecordsForHold) {
|
|
1634
|
+
observeBake(`bake-lane segment loader "${loaderId}"`, promise);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1514
1637
|
const gate = gateFlightForCapture(rscStream, undefined, holdUntil);
|
|
1515
1638
|
// Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
|
|
1516
1639
|
// half stalling is bounded by captureShellHTML's maxWaitMs.
|
|
@@ -1590,6 +1713,23 @@ async function captureAndStoreShell(
|
|
|
1590
1713
|
}
|
|
1591
1714
|
if (stats) stats.preludeBytes = result.prelude.length;
|
|
1592
1715
|
|
|
1716
|
+
// Every bake source has settled by here (quiesce gates on holdUntil).
|
|
1717
|
+
// Surface the slowest one: sub-ms settles are noise, threshold-crossers
|
|
1718
|
+
// get the dev warning with the remedy ladder.
|
|
1719
|
+
let slowestBake: { source: string; ms: number } | undefined;
|
|
1720
|
+
for (const wait of bakeWaits) {
|
|
1721
|
+
if (!slowestBake || wait.ms > slowestBake.ms) slowestBake = wait;
|
|
1722
|
+
}
|
|
1723
|
+
if (slowestBake && slowestBake.ms > 0) {
|
|
1724
|
+
if (stats) stats.bakeWaitMs = slowestBake.ms;
|
|
1725
|
+
if (
|
|
1726
|
+
process.env.NODE_ENV !== "production" &&
|
|
1727
|
+
slowestBake.ms >= SHELL_CAPTURE_BAKE_WARN_MS
|
|
1728
|
+
) {
|
|
1729
|
+
warnBakeCostOnce(capture.key, slowestBake.source, slowestBake.ms);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1593
1733
|
// Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
|
|
1594
1734
|
// threads the SAME store it resolved for its getShell read (options.store ??
|
|
1595
1735
|
// _cacheStore), so a store-attached middleware writes captures where it reads
|
|
@@ -1746,11 +1886,23 @@ async function captureAndStoreShell(
|
|
|
1746
1886
|
if (store?.putShell) {
|
|
1747
1887
|
try {
|
|
1748
1888
|
const entry: ShellCacheEntry = {
|
|
1749
|
-
//
|
|
1750
|
-
//
|
|
1751
|
-
//
|
|
1752
|
-
|
|
1753
|
-
|
|
1889
|
+
// Document half only for document captures. A navigationOnly entry's
|
|
1890
|
+
// HTML is never served (document reads skip the flag; partial replay
|
|
1891
|
+
// consumes only snapshot/docKey), so storing it would ride every KV
|
|
1892
|
+
// write/read as dead weight — the prerender still ran above as the
|
|
1893
|
+
// completeness arbiter and sanity gate, its output is dropped here.
|
|
1894
|
+
...(capture.navigationOnly
|
|
1895
|
+
? {}
|
|
1896
|
+
: {
|
|
1897
|
+
// slice() copies just this view's bytes into a fresh
|
|
1898
|
+
// ArrayBuffer, so a prelude that is a subarray of a larger
|
|
1899
|
+
// backing buffer encodes only its own region — bufferToBase64
|
|
1900
|
+
// reads the whole ArrayBuffer it is handed.
|
|
1901
|
+
prelude: bufferToBase64(
|
|
1902
|
+
result.prelude.slice().buffer as ArrayBuffer,
|
|
1903
|
+
),
|
|
1904
|
+
postponed: result.postponed,
|
|
1905
|
+
}),
|
|
1754
1906
|
reactVersion: React.version,
|
|
1755
1907
|
buildVersion: capture.buildVersion,
|
|
1756
1908
|
// The theme this capture's payload was built with (buildFullPayload
|
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -20,7 +20,11 @@ import React from "react";
|
|
|
20
20
|
import { isPprEntry, type EntryData } from "../server/context.js";
|
|
21
21
|
import { sortedSearchString } from "../cache/cache-key-utils.js";
|
|
22
22
|
import type { SearchParamsFilter } from "../cache/search-params-filter.js";
|
|
23
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
DocumentShellCacheEntry,
|
|
25
|
+
ShellCacheEntry,
|
|
26
|
+
SegmentCacheStore,
|
|
27
|
+
} from "../cache/types.js";
|
|
24
28
|
import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
|
|
25
29
|
|
|
26
30
|
/** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
|
|
@@ -150,18 +154,29 @@ export function isValidShellHit(
|
|
|
150
154
|
}
|
|
151
155
|
|
|
152
156
|
/**
|
|
153
|
-
*
|
|
154
|
-
* whose prelude is not decodable base64 or whose
|
|
155
|
-
* parseable JSON would otherwise throw AFTER the 200 +
|
|
156
|
-
* flushed (`serveShellHit` decodes at stream construction,
|
|
157
|
-
* parses in the tail) — the client gets a visually complete
|
|
158
|
-
* hydrates, re-served on every request until the entry ages
|
|
159
|
-
* path exists; failure schedules no recapture by itself).
|
|
160
|
-
* a corrupt entry (store-layer fault) into a plain MISS
|
|
161
|
-
* overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
|
|
162
|
-
* prelude flush that dominates the path.
|
|
157
|
+
* DOCUMENT-half integrity gate and type narrowing, run BEFORE the HIT response
|
|
158
|
+
* commits: a stored entry whose prelude is not decodable base64 or whose
|
|
159
|
+
* postponed blob is not parseable JSON would otherwise throw AFTER the 200 +
|
|
160
|
+
* full static prelude flushed (`serveShellHit` decodes at stream construction,
|
|
161
|
+
* `resumeShellHTML` parses in the tail) — the client gets a visually complete
|
|
162
|
+
* page that never hydrates, re-served on every request until the entry ages
|
|
163
|
+
* out (no eviction path exists; failure schedules no recapture by itself).
|
|
164
|
+
* Checking here turns a corrupt entry (store-layer fault) into a plain MISS
|
|
165
|
+
* the recapture overwrites. Cost: one duplicate decode/parse per HIT, sub-ms
|
|
166
|
+
* against a prelude flush that dominates the path.
|
|
167
|
+
*
|
|
168
|
+
* navigationOnly entries store no document half (prelude/postponed absent) and
|
|
169
|
+
* therefore never pass. The partial-replay path skips this gate for them
|
|
170
|
+
* (replayableShellSnapshot) — snapshot fragment corruption is caught by the
|
|
171
|
+
* consumer-side Flight decoders instead (SegmentFragmentDecodeError → healing
|
|
172
|
+
* capture).
|
|
163
173
|
*/
|
|
164
|
-
export function hasIntactShellPayload(
|
|
174
|
+
export function hasIntactShellPayload(
|
|
175
|
+
entry: ShellCacheEntry,
|
|
176
|
+
): entry is DocumentShellCacheEntry {
|
|
177
|
+
if (typeof entry.prelude !== "string" || entry.postponed === undefined) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
165
180
|
try {
|
|
166
181
|
base64ToBytes(entry.prelude);
|
|
167
182
|
if (entry.postponed !== null) JSON.parse(entry.postponed);
|