@rangojs/router 0.0.0-experimental.147 → 0.0.0-experimental.149
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/vite/index.js +19 -3
- package/package.json +1 -1
- package/skills/mime-routes/SKILL.md +25 -17
- package/skills/ppr/SKILL.md +20 -10
- package/src/cache/cf/cf-cache-store.ts +37 -2
- package/src/index.rsc.ts +6 -0
- package/src/prerender/build-shell-capture.ts +17 -1
- package/src/router/content-negotiation.ts +47 -5
- package/src/router/metrics.ts +17 -2
- package/src/router/router-interfaces.ts +7 -0
- package/src/router/router-options.ts +13 -0
- package/src/router.ts +5 -0
- package/src/rsc/handler.ts +4 -2
- package/src/rsc/rsc-rendering.ts +49 -0
- package/src/rsc/shell-build-manifest.ts +40 -10
- package/src/rsc/shell-capture-constants.ts +27 -0
- package/src/rsc/shell-capture.ts +388 -24
- package/src/rsc/shell-serve.ts +44 -0
- package/src/rsc/ssr-setup.ts +54 -22
- package/src/server/context.ts +1 -0
- package/src/ssr/index.tsx +42 -12
- package/src/urls/pattern-types.ts +29 -0
- package/src/vite/discovery/shell-prerender-phase.ts +2 -0
- package/src/vite/discovery/state.ts +3 -1
- package/src/vite/router-discovery.ts +20 -2
|
@@ -27,7 +27,12 @@
|
|
|
27
27
|
|
|
28
28
|
import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
|
|
29
29
|
import { sortedSearchString } from "../cache/cache-key-utils.js";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
DEV_SHELL_PROBE_TIMEOUT_MS,
|
|
32
|
+
hasIntactShellPayload,
|
|
33
|
+
isValidShellHit,
|
|
34
|
+
} from "./shell-serve.js";
|
|
35
|
+
import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture.js";
|
|
31
36
|
import { buildShellManifestKey } from "../prerender/shell-manifest-key.js";
|
|
32
37
|
|
|
33
38
|
/** One baked manifest record (the __ps asset module's default export). */
|
|
@@ -133,18 +138,37 @@ export interface DevShellLookup {
|
|
|
133
138
|
ttl: number;
|
|
134
139
|
swr?: number;
|
|
135
140
|
tags?: string[];
|
|
141
|
+
maxSnapshotBytes?: number;
|
|
142
|
+
/** Resolved `ppr.captureTimeout` (ms) — the endpoint's capture honors it. */
|
|
143
|
+
captureTimeout?: number;
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
/** Retry delay (~400ms) plus quiesce/fizz/store headroom past the budgets. */
|
|
147
|
+
const DEV_SHELL_RETRY_MARGIN_MS = 5_000;
|
|
148
|
+
|
|
138
149
|
/**
|
|
139
|
-
*
|
|
140
|
-
* waitUntil an unsettled fetch pends forever instead of rejecting; on
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* capture
|
|
144
|
-
*
|
|
145
|
-
*
|
|
150
|
+
* Timed out like the dev prerender store fetch (see #697): inside a workerd
|
|
151
|
+
* waitUntil an unsettled fetch pends forever instead of rejecting; on timeout
|
|
152
|
+
* this degrades to a MISS and the runtime capture path takes over. But this
|
|
153
|
+
* fetch blocks a foreground document request and its response IS an inline
|
|
154
|
+
* capture, so the bound must cover the endpoint's FULL sequential worst case
|
|
155
|
+
* — aborting a still-healthy capture turns it into a spurious MISS. The terms
|
|
156
|
+
* of the server envelope (dev /__rsc_shell, vite/router-discovery.ts):
|
|
157
|
+
* - DEV_SHELL_PROBE_TIMEOUT_MS: the sequential /__rsc_prerender pre-flight
|
|
158
|
+
* probe (same constant on the endpoint side).
|
|
159
|
+
* - 2x the capture settle budget (`ppr.captureTimeout`, default
|
|
160
|
+
* SHELL_CAPTURE_MAX_WAIT_MS): first attempt + one in-place cold-graph retry.
|
|
161
|
+
* - DEV_SHELL_RETRY_MARGIN_MS: the ~400ms retry delay plus headroom.
|
|
162
|
+
* Deeper fix (possible follow-up): the endpoint owns ONE total deadline and
|
|
163
|
+
* this bound becomes a plain liveness backstop instead of envelope math.
|
|
146
164
|
*/
|
|
147
|
-
|
|
165
|
+
function devShellFetchTimeoutMs(captureTimeout: number | undefined): number {
|
|
166
|
+
return (
|
|
167
|
+
DEV_SHELL_PROBE_TIMEOUT_MS +
|
|
168
|
+
2 * (captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS) +
|
|
169
|
+
DEV_SHELL_RETRY_MARGIN_MS
|
|
170
|
+
);
|
|
171
|
+
}
|
|
148
172
|
|
|
149
173
|
async function fetchDevShellEntry(
|
|
150
174
|
pathname: string,
|
|
@@ -162,9 +186,15 @@ async function fetchDevShellEntry(
|
|
|
162
186
|
});
|
|
163
187
|
if (dev.swr !== undefined) params.set("swr", String(dev.swr));
|
|
164
188
|
if (dev.tags && dev.tags.length > 0) params.set("tags", dev.tags.join(","));
|
|
189
|
+
if (dev.maxSnapshotBytes !== undefined) {
|
|
190
|
+
params.set("maxSnapshotBytes", String(dev.maxSnapshotBytes));
|
|
191
|
+
}
|
|
192
|
+
if (dev.captureTimeout !== undefined) {
|
|
193
|
+
params.set("captureTimeout", String(dev.captureTimeout));
|
|
194
|
+
}
|
|
165
195
|
try {
|
|
166
196
|
const res = await fetch(`${devUrl}/__rsc_shell?${params}`, {
|
|
167
|
-
signal: AbortSignal.timeout(
|
|
197
|
+
signal: AbortSignal.timeout(devShellFetchTimeoutMs(dev.captureTimeout)),
|
|
168
198
|
});
|
|
169
199
|
if (!res.ok) return undefined;
|
|
170
200
|
return (await res.json()) as BuildShellEntry;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default upper bound on the capture prerender wait before forcing the abort
|
|
3
|
+
* that freezes the shell — the ONLY wall-clock on the capture path, and a
|
|
4
|
+
* pathological guard: it should never fire once the caller's `quiesce` is a
|
|
5
|
+
* task-quantized, frozen-byte signal (the capture gate in shell-capture.ts).
|
|
6
|
+
*
|
|
7
|
+
* Leaf module on purpose (imports nothing): shell-capture.ts re-exports it
|
|
8
|
+
* (single import site for the rsc graph — shell-build-manifest.ts sizes the
|
|
9
|
+
* dev fetch envelope from that re-export) and ssr/index.tsx uses it as
|
|
10
|
+
* captureShellHTML's own fallback; the ssr graph must not pull the capture
|
|
11
|
+
* orchestration module for one number.
|
|
12
|
+
*
|
|
13
|
+
* 15s, raised from 5s: captures are background work (waitUntil), so the budget
|
|
14
|
+
* costs latency-to-HIT only — never a served response — and 5s spuriously
|
|
15
|
+
* refused legitimately-slow deferred shell material (a real storefront's meta
|
|
16
|
+
* chains settle at ~7s). Ceiling math: workerd's waitUntil lifetime is ~30s
|
|
17
|
+
* past response completion, and a guaranteed two-attempt envelope needs
|
|
18
|
+
* 2x budget + the in-place retry delay + store I/O, i.e. budget <= ~14s. The
|
|
19
|
+
* 15s default deliberately sits just past that: attempt 1 always gets its
|
|
20
|
+
* full 15s, but when it consumed the whole budget the in-place RETRY may be
|
|
21
|
+
* truncated by the platform kill on workerd — degrading to the existing
|
|
22
|
+
* best-effort contract (the key stays MISS; a later request re-captures).
|
|
23
|
+
* Node/dev and build-time captures have no waitUntil ceiling. The per-route
|
|
24
|
+
* `ppr.captureTimeout` knob remains for tightening below the default. See
|
|
25
|
+
* docs/design/ppr-shell-resume.md (Cost model).
|
|
26
|
+
*/
|
|
27
|
+
export const SHELL_CAPTURE_MAX_WAIT_MS = 15_000;
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { bufferToBase64 } from "../cache/cf/cf-base64.js";
|
|
|
21
21
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
22
22
|
import { runBackground } from "../cache/background-task.js";
|
|
23
23
|
import { enqueueSerializedCapture } from "./capture-queue.js";
|
|
24
|
+
import { SHELL_CAPTURE_MAX_WAIT_MS } from "./shell-capture-constants.js";
|
|
25
|
+
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
24
26
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
25
27
|
import {
|
|
26
28
|
runWithRequestContext,
|
|
@@ -78,8 +80,13 @@ import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
|
|
|
78
80
|
*/
|
|
79
81
|
const FLIGHT_QUIET_HOPS = 2;
|
|
80
82
|
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Default capture budget. Canonical value, raise rationale, and ceiling math
|
|
85
|
+
* live on the leaf module (shell-capture-constants.ts, importable from the ssr
|
|
86
|
+
* graph too). Re-exported here so shell-build-manifest.ts's envelope math
|
|
87
|
+
* keeps its existing import site and cannot drift from the capture.
|
|
88
|
+
*/
|
|
89
|
+
export { SHELL_CAPTURE_MAX_WAIT_MS };
|
|
83
90
|
|
|
84
91
|
/**
|
|
85
92
|
* Upper bound on waiting for the capture's DEFERRED cache writes to settle before
|
|
@@ -369,6 +376,254 @@ function warnUntaggedShellBakeOnce(key: string): void {
|
|
|
369
376
|
);
|
|
370
377
|
}
|
|
371
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
|
|
381
|
+
* inside a shell entry, when the route's `ppr` option does not set
|
|
382
|
+
* `maxSnapshotBytes`. 8 MiB: the snapshot shares the stored envelope with the
|
|
383
|
+
* base64 prelude and the postponed blob, and the tightest store value limit is
|
|
384
|
+
* Cloudflare KV's 25 MiB — 8 MiB of snapshot leaves the envelope well under it
|
|
385
|
+
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
386
|
+
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
387
|
+
* the option through undefaulted), so every producer and direct caller gets
|
|
388
|
+
* the same policy. Over the cap the snapshot is skipped (shell still stored;
|
|
389
|
+
* pinned reads drift — see PartialPrerenderProps.maxSnapshotBytes).
|
|
390
|
+
*/
|
|
391
|
+
export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
392
|
+
|
|
393
|
+
/** Cached encoder for the snapshot byte measurement (one per module, not per capture). */
|
|
394
|
+
const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
395
|
+
|
|
396
|
+
/** Keys already warned about an over-cap snapshot (once per key per isolate). */
|
|
397
|
+
const warnedOverCapSnapshots = new Set<string>();
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Warn once per key that the capture data snapshot exceeded the route's
|
|
401
|
+
* `maxSnapshotBytes` cap and was skipped. The shell entry is still stored and
|
|
402
|
+
* served — only the pinned-read replay is lost, so shell-baked cached content
|
|
403
|
+
* can drift from the frozen prelude between capture and HIT and hydration
|
|
404
|
+
* repairs it client-side (the pre-snapshot behavior). Once per key: the same
|
|
405
|
+
* page recaptures on every TTL roll and would otherwise re-warn forever.
|
|
406
|
+
*/
|
|
407
|
+
function warnSnapshotOverCapOnce(
|
|
408
|
+
key: string,
|
|
409
|
+
snapshotBytes: number,
|
|
410
|
+
capBytes: number,
|
|
411
|
+
): void {
|
|
412
|
+
if (warnedOverCapSnapshots.has(key)) return;
|
|
413
|
+
warnedOverCapSnapshots.add(key);
|
|
414
|
+
console.warn(
|
|
415
|
+
`[rango] Shell capture for "${key}" recorded a ${snapshotBytes}-byte data ` +
|
|
416
|
+
`snapshot, over the ${capBytes}-byte cap — the snapshot was skipped and ` +
|
|
417
|
+
"the shell was stored without it. The page keeps serving, but cached " +
|
|
418
|
+
"content baked into the shell is no longer pinned: if it drifts before " +
|
|
419
|
+
"the shell's TTL, hydration repairs the mismatch client-side. Raise the " +
|
|
420
|
+
"cap via the route's ppr option ({ maxSnapshotBytes }) if the entry " +
|
|
421
|
+
"still fits your store's value limit (Cloudflare KV: 25 MiB per value), " +
|
|
422
|
+
"or shrink the cache()'d data the shell bakes.",
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* One structured event from the background capture pipeline, mirroring the
|
|
428
|
+
* CFCacheReadDebugEvent pattern (cache/cf/cf-cache-types.ts): typed fields an
|
|
429
|
+
* operator can assert against, emitted per attempt and per skip, so the
|
|
430
|
+
* stored / no-shell / refused / backed-off lifecycle is observable outside
|
|
431
|
+
* dev console warnings. Configured via `createRouter({ debugShellCapture })`.
|
|
432
|
+
*/
|
|
433
|
+
export interface ShellCaptureDebugEvent {
|
|
434
|
+
/** Shell cache key the event is about. */
|
|
435
|
+
key: string;
|
|
436
|
+
/**
|
|
437
|
+
* What happened:
|
|
438
|
+
* - stored / redirect / no-shell / refused: one capture ATTEMPT's outcome
|
|
439
|
+
* (see CaptureAttemptOutcome for the semantics of each)
|
|
440
|
+
* - error: the capture task failed with a genuine error (also routed through
|
|
441
|
+
* reportCacheError; the key is backed off)
|
|
442
|
+
* - skip-in-flight: scheduleShellCapture found a capture already running for
|
|
443
|
+
* the key (stampede guard) and scheduled nothing
|
|
444
|
+
* - skip-backoff: the key is inside its refused-capture backoff window and
|
|
445
|
+
* the capture was not attempted
|
|
446
|
+
* - backoff: the key entered (or escalated) backoff after a terminal
|
|
447
|
+
* no-shell — carries the new backoff state
|
|
448
|
+
*/
|
|
449
|
+
outcome:
|
|
450
|
+
| "stored"
|
|
451
|
+
| "redirect"
|
|
452
|
+
| "no-shell"
|
|
453
|
+
| "refused"
|
|
454
|
+
| "error"
|
|
455
|
+
| "skip-in-flight"
|
|
456
|
+
| "skip-backoff"
|
|
457
|
+
| "backoff";
|
|
458
|
+
/** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
|
|
459
|
+
attempt?: number;
|
|
460
|
+
/** Wall-clock ms of the whole attempt (barrier + render + drain + put). */
|
|
461
|
+
attemptMs?: number;
|
|
462
|
+
/**
|
|
463
|
+
* Wall-clock ms the pre-render WRITE BARRIER waited on the foreground
|
|
464
|
+
* request's deferred cache writes (bounded by SHELL_CAPTURE_WRITE_BARRIER_MS).
|
|
465
|
+
*/
|
|
466
|
+
barrierWaitMs?: number;
|
|
467
|
+
/**
|
|
468
|
+
* Wall-clock ms spent awaiting the capture's own deferred cache writes
|
|
469
|
+
* before the snapshot drain (bounded by SHELL_SNAPSHOT_WRITE_SETTLE_MS).
|
|
470
|
+
*/
|
|
471
|
+
writeSettleMs?: number;
|
|
472
|
+
/** Stored prelude size in bytes (pre-base64). */
|
|
473
|
+
preludeBytes?: number;
|
|
474
|
+
/** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
|
|
475
|
+
snapshotBytes?: number;
|
|
476
|
+
/** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
|
|
477
|
+
snapshotSkipped?: boolean;
|
|
478
|
+
/** Consecutive failure count in the key's backoff entry, when one exists. */
|
|
479
|
+
backoffFailures?: number;
|
|
480
|
+
/** Ms remaining in the key's backoff window, when one exists. */
|
|
481
|
+
backoffRemainingMs?: number;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Debug sink for the capture pipeline, mirroring {@link CFCacheDebug}: `true`
|
|
486
|
+
* logs each event to console (visible via `wrangler tail`), a function
|
|
487
|
+
* receives the events for programmatic capture. Off by default.
|
|
488
|
+
*/
|
|
489
|
+
export type ShellCaptureDebug =
|
|
490
|
+
| boolean
|
|
491
|
+
| ((event: ShellCaptureDebugEvent) => void);
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Compact single-line form of an event's fields, shared by the console sink
|
|
495
|
+
* and the dev Server-Timing mirror's `desc` (rsc-rendering). Plain
|
|
496
|
+
* alphanumerics/`=`/`-`/`()` only, so it needs no quoted-string escaping.
|
|
497
|
+
*/
|
|
498
|
+
export function describeShellCaptureEvent(
|
|
499
|
+
event: ShellCaptureDebugEvent,
|
|
500
|
+
): string {
|
|
501
|
+
const parts: string[] = [event.outcome];
|
|
502
|
+
if (event.attempt !== undefined) parts.push(`attempt=${event.attempt}`);
|
|
503
|
+
if (event.attemptMs !== undefined) parts.push(`${event.attemptMs}ms`);
|
|
504
|
+
if (event.barrierWaitMs !== undefined) {
|
|
505
|
+
parts.push(`barrier=${event.barrierWaitMs}ms`);
|
|
506
|
+
}
|
|
507
|
+
if (event.writeSettleMs !== undefined) {
|
|
508
|
+
parts.push(`write-settle=${event.writeSettleMs}ms`);
|
|
509
|
+
}
|
|
510
|
+
if (event.preludeBytes !== undefined) {
|
|
511
|
+
parts.push(`prelude=${event.preludeBytes}b`);
|
|
512
|
+
}
|
|
513
|
+
if (event.snapshotBytes !== undefined) {
|
|
514
|
+
parts.push(
|
|
515
|
+
`snapshot=${event.snapshotBytes}b${event.snapshotSkipped ? " (over cap, skipped)" : ""}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (event.backoffFailures !== undefined) {
|
|
519
|
+
parts.push(`backoff-failures=${event.backoffFailures}`);
|
|
520
|
+
}
|
|
521
|
+
if (event.backoffRemainingMs !== undefined) {
|
|
522
|
+
parts.push(`backoff-remaining=${event.backoffRemainingMs}ms`);
|
|
523
|
+
}
|
|
524
|
+
return parts.join(" ");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The `debugShellCapture: true` console sink: one compact line per event. */
|
|
528
|
+
function consoleCaptureDebugSink(event: ShellCaptureDebugEvent): void {
|
|
529
|
+
console.log(
|
|
530
|
+
`[ShellCache][debug] ${event.key} ${describeShellCaptureEvent(event)}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Resolve the `debugShellCapture` router option to a callable sink, or
|
|
536
|
+
* undefined when off. The INTERNAL_RANGO_DEBUG env-flag fallback lives HERE
|
|
537
|
+
* (not at a call site) so every producer that resolves a sink inherits it;
|
|
538
|
+
* an explicit `false` wins over the env flag.
|
|
539
|
+
*/
|
|
540
|
+
export function resolveShellCaptureDebugSink(
|
|
541
|
+
option: ShellCaptureDebug | undefined,
|
|
542
|
+
): ((event: ShellCaptureDebugEvent) => void) | undefined {
|
|
543
|
+
if (option === false) return undefined;
|
|
544
|
+
if (option === true) return consoleCaptureDebugSink;
|
|
545
|
+
if (typeof option === "function") return option;
|
|
546
|
+
return INTERNAL_RANGO_DEBUG ? consoleCaptureDebugSink : undefined;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Attempt-terminal outcomes recorded for the dev Server-Timing mirror. Skip
|
|
551
|
+
* events are excluded so a later request's skip cannot overwrite the
|
|
552
|
+
* interesting terminal event before a metrics-enabled request reads it.
|
|
553
|
+
*/
|
|
554
|
+
const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
|
|
555
|
+
"stored",
|
|
556
|
+
"redirect",
|
|
557
|
+
"no-shell",
|
|
558
|
+
"refused",
|
|
559
|
+
"error",
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Dev-only last-terminal-event-per-key buffer backing the Server-Timing
|
|
564
|
+
* mirror: the capture runs AFTER its triggering response is committed, so its
|
|
565
|
+
* outcome can only ride a LATER response's header. rsc-rendering consumes this
|
|
566
|
+
* on the next ppr GET for the key when the metrics store is active
|
|
567
|
+
* (debugPerformance) and appends a `ppr:capture` Server-Timing entry. Dev-only
|
|
568
|
+
* (isDevMode) so production isolates never grow the map; FIFO-capped because
|
|
569
|
+
* with debugPerformance OFF nothing ever drains it, and a long dev session
|
|
570
|
+
* sweeping many URLs would otherwise accumulate one entry per shell key
|
|
571
|
+
* forever.
|
|
572
|
+
*/
|
|
573
|
+
const lastCaptureEventsForTiming = new Map<string, ShellCaptureDebugEvent>();
|
|
574
|
+
const MAX_TIMING_EVENT_KEYS = 100;
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Consume (read-and-clear) the buffered terminal capture event for `key`, so
|
|
578
|
+
* one capture reports into exactly one later response's Server-Timing.
|
|
579
|
+
*/
|
|
580
|
+
export function takeCaptureDebugEventForTiming(
|
|
581
|
+
key: string,
|
|
582
|
+
): ShellCaptureDebugEvent | undefined {
|
|
583
|
+
const event = lastCaptureEventsForTiming.get(key);
|
|
584
|
+
if (event) lastCaptureEventsForTiming.delete(key);
|
|
585
|
+
return event;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Publish one capture debug event: buffer terminal outcomes for the dev
|
|
590
|
+
* Server-Timing mirror, then hand the event to the configured sink. A
|
|
591
|
+
* throwing sink is swallowed — diagnostics must never fail a capture.
|
|
592
|
+
*/
|
|
593
|
+
function publishCaptureDebugEvent(
|
|
594
|
+
descriptor: Pick<ShellCaptureDescriptor, "debugSink">,
|
|
595
|
+
event: ShellCaptureDebugEvent,
|
|
596
|
+
): void {
|
|
597
|
+
if (isDevMode() && TIMING_RECORDED_OUTCOMES.has(event.outcome)) {
|
|
598
|
+
// Refresh insertion order for the FIFO cap, then evict the oldest key.
|
|
599
|
+
lastCaptureEventsForTiming.delete(event.key);
|
|
600
|
+
if (lastCaptureEventsForTiming.size >= MAX_TIMING_EVENT_KEYS) {
|
|
601
|
+
const oldest = lastCaptureEventsForTiming.keys().next().value;
|
|
602
|
+
if (oldest !== undefined) lastCaptureEventsForTiming.delete(oldest);
|
|
603
|
+
}
|
|
604
|
+
lastCaptureEventsForTiming.set(event.key, event);
|
|
605
|
+
}
|
|
606
|
+
const sink = descriptor.debugSink;
|
|
607
|
+
if (!sink) return;
|
|
608
|
+
try {
|
|
609
|
+
sink(event);
|
|
610
|
+
} catch {
|
|
611
|
+
// Diagnostics only: a throwing consumer sink must never fail the capture.
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Current backoff state fields for `key` (empty when no backoff entry). */
|
|
616
|
+
function backoffFields(
|
|
617
|
+
key: string,
|
|
618
|
+
): Pick<ShellCaptureDebugEvent, "backoffFailures" | "backoffRemainingMs"> {
|
|
619
|
+
const entry = refusedCaptures.get(key);
|
|
620
|
+
if (!entry) return {};
|
|
621
|
+
return {
|
|
622
|
+
backoffFailures: entry.failures,
|
|
623
|
+
backoffRemainingMs: Math.max(0, entry.until - Date.now()),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
372
627
|
export interface FlightCaptureGate {
|
|
373
628
|
/** Identity passthrough of the source stream; feed this to captureShellHTML. */
|
|
374
629
|
stream: ReadableStream<Uint8Array>;
|
|
@@ -548,9 +803,32 @@ export interface ShellCaptureDescriptor {
|
|
|
548
803
|
ttl?: number;
|
|
549
804
|
swr?: number;
|
|
550
805
|
tags?: string[];
|
|
806
|
+
/**
|
|
807
|
+
* Per-route capture settle budget in ms (`ppr.captureTimeout`, resolved by
|
|
808
|
+
* resolvePprConfig). Feeds captureShellHTML's maxWaitMs — the ONE deadline
|
|
809
|
+
* bounding the whole capture, so it covers BOTH the fizz prerender AND the
|
|
810
|
+
* deferred-material settle window (the handlesBaked/loader-container
|
|
811
|
+
* holdUntil that keeps the gate from freezing while top-level pushes are
|
|
812
|
+
* pending). Undefined = SHELL_CAPTURE_MAX_WAIT_MS (15_000).
|
|
813
|
+
*/
|
|
814
|
+
captureTimeout?: number;
|
|
551
815
|
store?: SegmentCacheStore<any>;
|
|
552
816
|
/** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
|
|
553
817
|
debug?: boolean;
|
|
818
|
+
/**
|
|
819
|
+
* Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
|
|
820
|
+
* the snapshot is skipped and the shell stored without it (reported once per
|
|
821
|
+
* key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
|
|
822
|
+
* captureAndStoreShell — the single defaulting site.
|
|
823
|
+
*/
|
|
824
|
+
maxSnapshotBytes?: number;
|
|
825
|
+
/**
|
|
826
|
+
* Structured capture-pipeline debug sink, resolved from
|
|
827
|
+
* `createRouter({ debugShellCapture })` (or INTERNAL_RANGO_DEBUG) via
|
|
828
|
+
* {@link resolveShellCaptureDebugSink}. Receives one
|
|
829
|
+
* {@link ShellCaptureDebugEvent} per attempt/skip.
|
|
830
|
+
*/
|
|
831
|
+
debugSink?: (event: ShellCaptureDebugEvent) => void;
|
|
554
832
|
}
|
|
555
833
|
|
|
556
834
|
/**
|
|
@@ -574,10 +852,20 @@ export function scheduleShellCapture(
|
|
|
574
852
|
descriptor: ShellCaptureDescriptor,
|
|
575
853
|
): void {
|
|
576
854
|
const key = descriptor.key;
|
|
577
|
-
if (inFlightCaptures.has(key))
|
|
855
|
+
if (inFlightCaptures.has(key)) {
|
|
856
|
+
publishCaptureDebugEvent(descriptor, { key, outcome: "skip-in-flight" });
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
578
859
|
// Refused/failed within the window → skip the doomed re-render (one probe per
|
|
579
860
|
// key per window per isolate). Expired entries self-evict inside the check.
|
|
580
|
-
if (isCaptureBackedOff(key))
|
|
861
|
+
if (isCaptureBackedOff(key)) {
|
|
862
|
+
publishCaptureDebugEvent(descriptor, {
|
|
863
|
+
key,
|
|
864
|
+
outcome: "skip-backoff",
|
|
865
|
+
...backoffFields(key),
|
|
866
|
+
});
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
581
869
|
inFlightCaptures.add(key);
|
|
582
870
|
const captureTask = async () => {
|
|
583
871
|
try {
|
|
@@ -595,12 +883,24 @@ export function scheduleShellCapture(
|
|
|
595
883
|
// off so the next requests don't re-probe it. A `redirect` has no shell but
|
|
596
884
|
// is not a doomed render — leave the backoff untouched.
|
|
597
885
|
if (outcome === "stored") clearCaptureBackoff(key);
|
|
598
|
-
else if (outcome === "no-shell")
|
|
886
|
+
else if (outcome === "no-shell") {
|
|
887
|
+
markCaptureBackoff(key);
|
|
888
|
+
publishCaptureDebugEvent(descriptor, {
|
|
889
|
+
key,
|
|
890
|
+
outcome: "backoff",
|
|
891
|
+
...backoffFields(key),
|
|
892
|
+
});
|
|
893
|
+
}
|
|
599
894
|
} catch (error) {
|
|
600
895
|
// Detached background task — pass reqCtx so onError still fires when the ALS
|
|
601
896
|
// context is gone. A genuine failure recurs, so back it off too (re-probe
|
|
602
897
|
// once per window, not every request) and report it once.
|
|
603
898
|
markCaptureBackoff(key);
|
|
899
|
+
publishCaptureDebugEvent(descriptor, {
|
|
900
|
+
key,
|
|
901
|
+
outcome: "error",
|
|
902
|
+
...backoffFields(key),
|
|
903
|
+
});
|
|
604
904
|
reportCacheError(error, "cache-write", "[ShellCache] capture", reqCtx);
|
|
605
905
|
} finally {
|
|
606
906
|
inFlightCaptures.delete(key);
|
|
@@ -633,6 +933,21 @@ export function scheduleShellCapture(
|
|
|
633
933
|
*/
|
|
634
934
|
type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
|
|
635
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Per-attempt observability fields, filled along the capture path (barrier in
|
|
938
|
+
* attemptCapture, the rest in captureAndStoreShell) and folded into the
|
|
939
|
+
* attempt's {@link ShellCaptureDebugEvent} by runShellCapture. A plain mutable
|
|
940
|
+
* bag, not a return value: captureAndStoreShell's outcome type stays a string
|
|
941
|
+
* union its existing callers (producer B, tests) consume unchanged.
|
|
942
|
+
*/
|
|
943
|
+
interface CaptureAttemptStats {
|
|
944
|
+
barrierWaitMs?: number;
|
|
945
|
+
writeSettleMs?: number;
|
|
946
|
+
preludeBytes?: number;
|
|
947
|
+
snapshotBytes?: number;
|
|
948
|
+
snapshotSkipped?: boolean;
|
|
949
|
+
}
|
|
950
|
+
|
|
636
951
|
/**
|
|
637
952
|
* Run the shell capture with a single in-place retry, then store the result.
|
|
638
953
|
*
|
|
@@ -665,15 +980,37 @@ async function runShellCapture(
|
|
|
665
980
|
? (message: string) => console.log(message)
|
|
666
981
|
: () => {};
|
|
667
982
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
983
|
+
// One attempt + its structured debug event: the stats object rides through
|
|
984
|
+
// attemptCapture/captureAndStoreShell collecting the observability fields
|
|
985
|
+
// (barrier wait, write-settle wait, prelude/snapshot bytes), and the event
|
|
986
|
+
// folds them with the outcome. A genuine render error skips the attempt
|
|
987
|
+
// event — scheduleShellCapture's catch publishes the terminal `error` event.
|
|
988
|
+
const timedAttempt = async (
|
|
989
|
+
attempt: number,
|
|
990
|
+
): Promise<CaptureAttemptOutcome> => {
|
|
991
|
+
const stats: CaptureAttemptStats = {};
|
|
992
|
+
const start = performance.now();
|
|
993
|
+
const outcome = await attemptCapture(
|
|
994
|
+
ctx,
|
|
995
|
+
request,
|
|
996
|
+
env,
|
|
997
|
+
url,
|
|
998
|
+
reqCtx,
|
|
999
|
+
ssrModule,
|
|
1000
|
+
descriptor,
|
|
1001
|
+
stats,
|
|
1002
|
+
);
|
|
1003
|
+
publishCaptureDebugEvent(descriptor, {
|
|
1004
|
+
key: descriptor.key,
|
|
1005
|
+
outcome,
|
|
1006
|
+
attempt,
|
|
1007
|
+
attemptMs: Math.round(performance.now() - start),
|
|
1008
|
+
...stats,
|
|
1009
|
+
});
|
|
1010
|
+
return outcome;
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
const first = await timedAttempt(1);
|
|
677
1014
|
// "refused" is deterministic (identity guard / rejected bake-lane loader —
|
|
678
1015
|
// its own warning already fired): no retry, and the caller backs the key off
|
|
679
1016
|
// exactly like a structural no-shell.
|
|
@@ -690,15 +1027,7 @@ async function runShellCapture(
|
|
|
690
1027
|
`[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
|
|
691
1028
|
);
|
|
692
1029
|
await delay(retryDelayMs);
|
|
693
|
-
const second = await
|
|
694
|
-
ctx,
|
|
695
|
-
request,
|
|
696
|
-
env,
|
|
697
|
-
url,
|
|
698
|
-
reqCtx,
|
|
699
|
-
ssrModule,
|
|
700
|
-
descriptor,
|
|
701
|
-
);
|
|
1030
|
+
const second = await timedAttempt(2);
|
|
702
1031
|
if (second === "refused") return "no-shell";
|
|
703
1032
|
if (second !== "no-shell") return second;
|
|
704
1033
|
|
|
@@ -767,6 +1096,7 @@ async function attemptCapture(
|
|
|
767
1096
|
reqCtx: RequestContext<any>,
|
|
768
1097
|
ssrModule: SSRModule,
|
|
769
1098
|
descriptor: ShellCaptureDescriptor,
|
|
1099
|
+
stats: CaptureAttemptStats,
|
|
770
1100
|
): Promise<CaptureAttemptOutcome> {
|
|
771
1101
|
// WRITE BARRIER (ordering edge, not a narrower race): settle the foreground's
|
|
772
1102
|
// already-scheduled background tasks — its deferred ring-3/ring-1 cache writes —
|
|
@@ -777,7 +1107,9 @@ async function attemptCapture(
|
|
|
777
1107
|
// skipped, cache-store middleware's write path gated off by state.cacheHit), so
|
|
778
1108
|
// prelude, snapshot, and ring-3 agree on the foreground's generation. Runs per
|
|
779
1109
|
// attempt (the retry re-checks; already-settled promises are free).
|
|
1110
|
+
const barrierStart = performance.now();
|
|
780
1111
|
await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
|
|
1112
|
+
stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
|
|
781
1113
|
|
|
782
1114
|
const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
|
|
783
1115
|
reqCtx,
|
|
@@ -817,6 +1149,7 @@ async function attemptCapture(
|
|
|
817
1149
|
freshHandleStore,
|
|
818
1150
|
derivedCtx,
|
|
819
1151
|
descriptor,
|
|
1152
|
+
stats,
|
|
820
1153
|
);
|
|
821
1154
|
});
|
|
822
1155
|
}
|
|
@@ -1014,6 +1347,7 @@ async function captureAndStoreShell(
|
|
|
1014
1347
|
handleStore: HandleStore,
|
|
1015
1348
|
reqCtx: RequestContext<any>,
|
|
1016
1349
|
capture: ShellCaptureDescriptor,
|
|
1350
|
+
stats?: CaptureAttemptStats,
|
|
1017
1351
|
): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
|
|
1018
1352
|
const captureShellHTML = ssrModule.captureShellHTML!;
|
|
1019
1353
|
|
|
@@ -1102,10 +1436,12 @@ async function captureAndStoreShell(
|
|
|
1102
1436
|
// captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
|
|
1103
1437
|
let result: Awaited<ReturnType<typeof captureShellHTML>>;
|
|
1104
1438
|
try {
|
|
1439
|
+
// One deadline for the whole capture — semantics spec'd on the option
|
|
1440
|
+
// (PartialPrerenderProps.captureTimeout, urls/pattern-types.ts).
|
|
1105
1441
|
result = await observePhase(PHASES.ssr, () =>
|
|
1106
1442
|
captureShellHTML(gate.stream, {
|
|
1107
1443
|
quiesce,
|
|
1108
|
-
maxWaitMs: SHELL_CAPTURE_MAX_WAIT_MS,
|
|
1444
|
+
maxWaitMs: capture.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS,
|
|
1109
1445
|
}),
|
|
1110
1446
|
);
|
|
1111
1447
|
} catch (error) {
|
|
@@ -1141,6 +1477,7 @@ async function captureAndStoreShell(
|
|
|
1141
1477
|
if (result === null) {
|
|
1142
1478
|
return "no-shell";
|
|
1143
1479
|
}
|
|
1480
|
+
if (stats) stats.preludeBytes = result.prelude.length;
|
|
1144
1481
|
|
|
1145
1482
|
// Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
|
|
1146
1483
|
// threads the SAME store it resolved for its getShell read (options.store ??
|
|
@@ -1180,7 +1517,11 @@ async function captureAndStoreShell(
|
|
|
1180
1517
|
const recording = getRecordingStore(reqCtx._cacheStore);
|
|
1181
1518
|
let snapshot: ShellSnapshotRecord[] | undefined;
|
|
1182
1519
|
if (recording) {
|
|
1520
|
+
const settleStart = performance.now();
|
|
1183
1521
|
await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
|
|
1522
|
+
if (stats) {
|
|
1523
|
+
stats.writeSettleMs = Math.round(performance.now() - settleStart);
|
|
1524
|
+
}
|
|
1184
1525
|
snapshot = recording.drainSnapshot();
|
|
1185
1526
|
}
|
|
1186
1527
|
|
|
@@ -1239,6 +1580,29 @@ async function captureAndStoreShell(
|
|
|
1239
1580
|
}
|
|
1240
1581
|
}
|
|
1241
1582
|
|
|
1583
|
+
// Snapshot size guard (issue #651): the snapshot duplicates every pinned
|
|
1584
|
+
// cache value inside the shell entry, so a page over a large cache()
|
|
1585
|
+
// segment can push the stored envelope toward store value limits (KV caps
|
|
1586
|
+
// a value at 25 MiB) with no signal — the kv.put rejects deep inside
|
|
1587
|
+
// waitUntil. Measure the serialized snapshot (UTF-8 bytes of the JSON that
|
|
1588
|
+
// rides in the envelope) AFTER the loader family is appended, and over the
|
|
1589
|
+
// cap store the shell WITHOUT it: pinned reads then fall back to the live
|
|
1590
|
+
// store on a HIT (documented drift — hydration repairs a mismatch
|
|
1591
|
+
// client-side, the pre-snapshot behavior), which beats losing the whole
|
|
1592
|
+
// entry to a store-side write rejection. Reported once per key.
|
|
1593
|
+
if (snapshot && snapshot.length > 0) {
|
|
1594
|
+
const snapshotBytes = SNAPSHOT_BYTE_ENCODER.encode(
|
|
1595
|
+
JSON.stringify(snapshot),
|
|
1596
|
+
).length;
|
|
1597
|
+
if (stats) stats.snapshotBytes = snapshotBytes;
|
|
1598
|
+
const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
|
|
1599
|
+
if (snapshotBytes > cap) {
|
|
1600
|
+
warnSnapshotOverCapOnce(capture.key, snapshotBytes, cap);
|
|
1601
|
+
snapshot = undefined;
|
|
1602
|
+
if (stats) stats.snapshotSkipped = true;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1242
1606
|
// Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
|
|
1243
1607
|
// here the capture has quiesced and the deferred cache writes were awaited, so
|
|
1244
1608
|
// tags recorded AFTER an await in async shell content (and by async
|