@rangojs/router 0.0.0-experimental.146 → 0.0.0-experimental.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/bin/rango.js +7 -0
  2. package/dist/vite/index.js +823 -235
  3. package/package.json +6 -1
  4. package/skills/mime-routes/SKILL.md +25 -17
  5. package/skills/ppr/SKILL.md +20 -10
  6. package/src/browser/event-controller.ts +16 -2
  7. package/src/browser/rsc-router.tsx +11 -0
  8. package/src/cache/cache-scope.ts +11 -2
  9. package/src/cache/cf/cf-cache-store.ts +60 -2
  10. package/src/cache/memory-segment-store.ts +32 -0
  11. package/src/cache/segment-codec.ts +47 -0
  12. package/src/cache/types.ts +14 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/index.rsc.ts +6 -0
  15. package/src/prerender/build-shell-capture.ts +253 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/content-negotiation.ts +47 -5
  19. package/src/router/match-middleware/cache-lookup.ts +12 -1
  20. package/src/router/metrics.ts +17 -2
  21. package/src/router/prerender-match.ts +21 -0
  22. package/src/router/router-interfaces.ts +7 -0
  23. package/src/router/router-options.ts +13 -0
  24. package/src/router.ts +5 -0
  25. package/src/rsc/capture-queue.ts +67 -0
  26. package/src/rsc/handler.ts +4 -2
  27. package/src/rsc/rsc-rendering.ts +131 -23
  28. package/src/rsc/shell-build-manifest.ts +274 -0
  29. package/src/rsc/shell-capture.ts +486 -63
  30. package/src/rsc/shell-serve.ts +44 -0
  31. package/src/rsc/ssr-setup.ts +54 -22
  32. package/src/segment-fragments.ts +124 -0
  33. package/src/server/context.ts +1 -0
  34. package/src/server/request-context.ts +65 -11
  35. package/src/ssr/index.tsx +47 -9
  36. package/src/ssr/ssr-root.tsx +35 -2
  37. package/src/urls/pattern-types.ts +27 -0
  38. package/src/vite/discovery/discover-routers.ts +27 -0
  39. package/src/vite/discovery/prerender-collection.ts +16 -0
  40. package/src/vite/discovery/shell-prerender-phase.ts +397 -0
  41. package/src/vite/discovery/state.ts +44 -0
  42. package/src/vite/plugins/version-plugin.ts +8 -0
  43. package/src/vite/rango.ts +1 -0
  44. package/src/vite/router-discovery.ts +310 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
@@ -20,10 +20,13 @@ import React from "react";
20
20
  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
+ import { enqueueSerializedCapture } from "./capture-queue.js";
24
+ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
23
25
  import { observePhase, PHASES } from "../router/instrument.js";
24
26
  import {
25
27
  runWithRequestContext,
26
28
  setRequestContextParams,
29
+ wireRenderBarrier,
27
30
  UNTRACKED_BACKGROUND_TASK,
28
31
  type RequestContext,
29
32
  } from "../server/request-context.js";
@@ -76,8 +79,12 @@ import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
76
79
  */
77
80
  const FLIGHT_QUIET_HOPS = 2;
78
81
 
79
- /** Default upper bound on the capture prerender wait before forcing the abort. */
80
- const SHELL_CAPTURE_MAX_WAIT_MS = 5000;
82
+ /**
83
+ * Default upper bound on the capture prerender wait before forcing the abort.
84
+ * Single owner of the default budget — shell-build-manifest.ts imports it so
85
+ * the dev fetch bound's envelope math cannot drift from the capture.
86
+ */
87
+ export const SHELL_CAPTURE_MAX_WAIT_MS = 5000;
81
88
 
82
89
  /**
83
90
  * Upper bound on waiting for the capture's DEFERRED cache writes to settle before
@@ -367,6 +374,254 @@ function warnUntaggedShellBakeOnce(key: string): void {
367
374
  );
368
375
  }
369
376
 
377
+ /**
378
+ * Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
379
+ * inside a shell entry, when the route's `ppr` option does not set
380
+ * `maxSnapshotBytes`. 8 MiB: the snapshot shares the stored envelope with the
381
+ * base64 prelude and the postponed blob, and the tightest store value limit is
382
+ * Cloudflare KV's 25 MiB — 8 MiB of snapshot leaves the envelope well under it
383
+ * while still fitting any sane pinned-ring payload. Applied ONLY in
384
+ * captureAndStoreShell (the single defaulting site — resolvePprConfig passes
385
+ * the option through undefaulted), so every producer and direct caller gets
386
+ * the same policy. Over the cap the snapshot is skipped (shell still stored;
387
+ * pinned reads drift — see PartialPrerenderProps.maxSnapshotBytes).
388
+ */
389
+ export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
390
+
391
+ /** Cached encoder for the snapshot byte measurement (one per module, not per capture). */
392
+ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
393
+
394
+ /** Keys already warned about an over-cap snapshot (once per key per isolate). */
395
+ const warnedOverCapSnapshots = new Set<string>();
396
+
397
+ /**
398
+ * Warn once per key that the capture data snapshot exceeded the route's
399
+ * `maxSnapshotBytes` cap and was skipped. The shell entry is still stored and
400
+ * served — only the pinned-read replay is lost, so shell-baked cached content
401
+ * can drift from the frozen prelude between capture and HIT and hydration
402
+ * repairs it client-side (the pre-snapshot behavior). Once per key: the same
403
+ * page recaptures on every TTL roll and would otherwise re-warn forever.
404
+ */
405
+ function warnSnapshotOverCapOnce(
406
+ key: string,
407
+ snapshotBytes: number,
408
+ capBytes: number,
409
+ ): void {
410
+ if (warnedOverCapSnapshots.has(key)) return;
411
+ warnedOverCapSnapshots.add(key);
412
+ console.warn(
413
+ `[rango] Shell capture for "${key}" recorded a ${snapshotBytes}-byte data ` +
414
+ `snapshot, over the ${capBytes}-byte cap — the snapshot was skipped and ` +
415
+ "the shell was stored without it. The page keeps serving, but cached " +
416
+ "content baked into the shell is no longer pinned: if it drifts before " +
417
+ "the shell's TTL, hydration repairs the mismatch client-side. Raise the " +
418
+ "cap via the route's ppr option ({ maxSnapshotBytes }) if the entry " +
419
+ "still fits your store's value limit (Cloudflare KV: 25 MiB per value), " +
420
+ "or shrink the cache()'d data the shell bakes.",
421
+ );
422
+ }
423
+
424
+ /**
425
+ * One structured event from the background capture pipeline, mirroring the
426
+ * CFCacheReadDebugEvent pattern (cache/cf/cf-cache-types.ts): typed fields an
427
+ * operator can assert against, emitted per attempt and per skip, so the
428
+ * stored / no-shell / refused / backed-off lifecycle is observable outside
429
+ * dev console warnings. Configured via `createRouter({ debugShellCapture })`.
430
+ */
431
+ export interface ShellCaptureDebugEvent {
432
+ /** Shell cache key the event is about. */
433
+ key: string;
434
+ /**
435
+ * What happened:
436
+ * - stored / redirect / no-shell / refused: one capture ATTEMPT's outcome
437
+ * (see CaptureAttemptOutcome for the semantics of each)
438
+ * - error: the capture task failed with a genuine error (also routed through
439
+ * reportCacheError; the key is backed off)
440
+ * - skip-in-flight: scheduleShellCapture found a capture already running for
441
+ * the key (stampede guard) and scheduled nothing
442
+ * - skip-backoff: the key is inside its refused-capture backoff window and
443
+ * the capture was not attempted
444
+ * - backoff: the key entered (or escalated) backoff after a terminal
445
+ * no-shell — carries the new backoff state
446
+ */
447
+ outcome:
448
+ | "stored"
449
+ | "redirect"
450
+ | "no-shell"
451
+ | "refused"
452
+ | "error"
453
+ | "skip-in-flight"
454
+ | "skip-backoff"
455
+ | "backoff";
456
+ /** Attempt number (1 = first, 2 = in-place retry). Absent on skips. */
457
+ attempt?: number;
458
+ /** Wall-clock ms of the whole attempt (barrier + render + drain + put). */
459
+ attemptMs?: number;
460
+ /**
461
+ * Wall-clock ms the pre-render WRITE BARRIER waited on the foreground
462
+ * request's deferred cache writes (bounded by SHELL_CAPTURE_WRITE_BARRIER_MS).
463
+ */
464
+ barrierWaitMs?: number;
465
+ /**
466
+ * Wall-clock ms spent awaiting the capture's own deferred cache writes
467
+ * before the snapshot drain (bounded by SHELL_SNAPSHOT_WRITE_SETTLE_MS).
468
+ */
469
+ writeSettleMs?: number;
470
+ /** Stored prelude size in bytes (pre-base64). */
471
+ preludeBytes?: number;
472
+ /** Serialized snapshot size in UTF-8 bytes. Absent when nothing was recorded. */
473
+ snapshotBytes?: number;
474
+ /** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
475
+ snapshotSkipped?: boolean;
476
+ /** Consecutive failure count in the key's backoff entry, when one exists. */
477
+ backoffFailures?: number;
478
+ /** Ms remaining in the key's backoff window, when one exists. */
479
+ backoffRemainingMs?: number;
480
+ }
481
+
482
+ /**
483
+ * Debug sink for the capture pipeline, mirroring {@link CFCacheDebug}: `true`
484
+ * logs each event to console (visible via `wrangler tail`), a function
485
+ * receives the events for programmatic capture. Off by default.
486
+ */
487
+ export type ShellCaptureDebug =
488
+ | boolean
489
+ | ((event: ShellCaptureDebugEvent) => void);
490
+
491
+ /**
492
+ * Compact single-line form of an event's fields, shared by the console sink
493
+ * and the dev Server-Timing mirror's `desc` (rsc-rendering). Plain
494
+ * alphanumerics/`=`/`-`/`()` only, so it needs no quoted-string escaping.
495
+ */
496
+ export function describeShellCaptureEvent(
497
+ event: ShellCaptureDebugEvent,
498
+ ): string {
499
+ const parts: string[] = [event.outcome];
500
+ if (event.attempt !== undefined) parts.push(`attempt=${event.attempt}`);
501
+ if (event.attemptMs !== undefined) parts.push(`${event.attemptMs}ms`);
502
+ if (event.barrierWaitMs !== undefined) {
503
+ parts.push(`barrier=${event.barrierWaitMs}ms`);
504
+ }
505
+ if (event.writeSettleMs !== undefined) {
506
+ parts.push(`write-settle=${event.writeSettleMs}ms`);
507
+ }
508
+ if (event.preludeBytes !== undefined) {
509
+ parts.push(`prelude=${event.preludeBytes}b`);
510
+ }
511
+ if (event.snapshotBytes !== undefined) {
512
+ parts.push(
513
+ `snapshot=${event.snapshotBytes}b${event.snapshotSkipped ? " (over cap, skipped)" : ""}`,
514
+ );
515
+ }
516
+ if (event.backoffFailures !== undefined) {
517
+ parts.push(`backoff-failures=${event.backoffFailures}`);
518
+ }
519
+ if (event.backoffRemainingMs !== undefined) {
520
+ parts.push(`backoff-remaining=${event.backoffRemainingMs}ms`);
521
+ }
522
+ return parts.join(" ");
523
+ }
524
+
525
+ /** The `debugShellCapture: true` console sink: one compact line per event. */
526
+ function consoleCaptureDebugSink(event: ShellCaptureDebugEvent): void {
527
+ console.log(
528
+ `[ShellCache][debug] ${event.key} ${describeShellCaptureEvent(event)}`,
529
+ );
530
+ }
531
+
532
+ /**
533
+ * Resolve the `debugShellCapture` router option to a callable sink, or
534
+ * undefined when off. The INTERNAL_RANGO_DEBUG env-flag fallback lives HERE
535
+ * (not at a call site) so every producer that resolves a sink inherits it;
536
+ * an explicit `false` wins over the env flag.
537
+ */
538
+ export function resolveShellCaptureDebugSink(
539
+ option: ShellCaptureDebug | undefined,
540
+ ): ((event: ShellCaptureDebugEvent) => void) | undefined {
541
+ if (option === false) return undefined;
542
+ if (option === true) return consoleCaptureDebugSink;
543
+ if (typeof option === "function") return option;
544
+ return INTERNAL_RANGO_DEBUG ? consoleCaptureDebugSink : undefined;
545
+ }
546
+
547
+ /**
548
+ * Attempt-terminal outcomes recorded for the dev Server-Timing mirror. Skip
549
+ * events are excluded so a later request's skip cannot overwrite the
550
+ * interesting terminal event before a metrics-enabled request reads it.
551
+ */
552
+ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
553
+ "stored",
554
+ "redirect",
555
+ "no-shell",
556
+ "refused",
557
+ "error",
558
+ ]);
559
+
560
+ /**
561
+ * Dev-only last-terminal-event-per-key buffer backing the Server-Timing
562
+ * mirror: the capture runs AFTER its triggering response is committed, so its
563
+ * outcome can only ride a LATER response's header. rsc-rendering consumes this
564
+ * on the next ppr GET for the key when the metrics store is active
565
+ * (debugPerformance) and appends a `ppr:capture` Server-Timing entry. Dev-only
566
+ * (isDevMode) so production isolates never grow the map; FIFO-capped because
567
+ * with debugPerformance OFF nothing ever drains it, and a long dev session
568
+ * sweeping many URLs would otherwise accumulate one entry per shell key
569
+ * forever.
570
+ */
571
+ const lastCaptureEventsForTiming = new Map<string, ShellCaptureDebugEvent>();
572
+ const MAX_TIMING_EVENT_KEYS = 100;
573
+
574
+ /**
575
+ * Consume (read-and-clear) the buffered terminal capture event for `key`, so
576
+ * one capture reports into exactly one later response's Server-Timing.
577
+ */
578
+ export function takeCaptureDebugEventForTiming(
579
+ key: string,
580
+ ): ShellCaptureDebugEvent | undefined {
581
+ const event = lastCaptureEventsForTiming.get(key);
582
+ if (event) lastCaptureEventsForTiming.delete(key);
583
+ return event;
584
+ }
585
+
586
+ /**
587
+ * Publish one capture debug event: buffer terminal outcomes for the dev
588
+ * Server-Timing mirror, then hand the event to the configured sink. A
589
+ * throwing sink is swallowed — diagnostics must never fail a capture.
590
+ */
591
+ function publishCaptureDebugEvent(
592
+ descriptor: Pick<ShellCaptureDescriptor, "debugSink">,
593
+ event: ShellCaptureDebugEvent,
594
+ ): void {
595
+ if (isDevMode() && TIMING_RECORDED_OUTCOMES.has(event.outcome)) {
596
+ // Refresh insertion order for the FIFO cap, then evict the oldest key.
597
+ lastCaptureEventsForTiming.delete(event.key);
598
+ if (lastCaptureEventsForTiming.size >= MAX_TIMING_EVENT_KEYS) {
599
+ const oldest = lastCaptureEventsForTiming.keys().next().value;
600
+ if (oldest !== undefined) lastCaptureEventsForTiming.delete(oldest);
601
+ }
602
+ lastCaptureEventsForTiming.set(event.key, event);
603
+ }
604
+ const sink = descriptor.debugSink;
605
+ if (!sink) return;
606
+ try {
607
+ sink(event);
608
+ } catch {
609
+ // Diagnostics only: a throwing consumer sink must never fail the capture.
610
+ }
611
+ }
612
+
613
+ /** Current backoff state fields for `key` (empty when no backoff entry). */
614
+ function backoffFields(
615
+ key: string,
616
+ ): Pick<ShellCaptureDebugEvent, "backoffFailures" | "backoffRemainingMs"> {
617
+ const entry = refusedCaptures.get(key);
618
+ if (!entry) return {};
619
+ return {
620
+ backoffFailures: entry.failures,
621
+ backoffRemainingMs: Math.max(0, entry.until - Date.now()),
622
+ };
623
+ }
624
+
370
625
  export interface FlightCaptureGate {
371
626
  /** Identity passthrough of the source stream; feed this to captureShellHTML. */
372
627
  stream: ReadableStream<Uint8Array>;
@@ -546,9 +801,32 @@ export interface ShellCaptureDescriptor {
546
801
  ttl?: number;
547
802
  swr?: number;
548
803
  tags?: string[];
804
+ /**
805
+ * Per-route capture settle budget in ms (`ppr.captureTimeout`, resolved by
806
+ * resolvePprConfig). Feeds captureShellHTML's maxWaitMs — the ONE deadline
807
+ * bounding the whole capture, so it covers BOTH the fizz prerender AND the
808
+ * deferred-material settle window (the handlesBaked/loader-container
809
+ * holdUntil that keeps the gate from freezing while top-level pushes are
810
+ * pending). Undefined = SHELL_CAPTURE_MAX_WAIT_MS (5000).
811
+ */
812
+ captureTimeout?: number;
549
813
  store?: SegmentCacheStore<any>;
550
814
  /** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
551
815
  debug?: boolean;
816
+ /**
817
+ * Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
818
+ * the snapshot is skipped and the shell stored without it (reported once per
819
+ * key). Absent = DEFAULT_PPR_MAX_SNAPSHOT_BYTES, applied in
820
+ * captureAndStoreShell — the single defaulting site.
821
+ */
822
+ maxSnapshotBytes?: number;
823
+ /**
824
+ * Structured capture-pipeline debug sink, resolved from
825
+ * `createRouter({ debugShellCapture })` (or INTERNAL_RANGO_DEBUG) via
826
+ * {@link resolveShellCaptureDebugSink}. Receives one
827
+ * {@link ShellCaptureDebugEvent} per attempt/skip.
828
+ */
829
+ debugSink?: (event: ShellCaptureDebugEvent) => void;
552
830
  }
553
831
 
554
832
  /**
@@ -572,10 +850,20 @@ export function scheduleShellCapture(
572
850
  descriptor: ShellCaptureDescriptor,
573
851
  ): void {
574
852
  const key = descriptor.key;
575
- if (inFlightCaptures.has(key)) return;
853
+ if (inFlightCaptures.has(key)) {
854
+ publishCaptureDebugEvent(descriptor, { key, outcome: "skip-in-flight" });
855
+ return;
856
+ }
576
857
  // Refused/failed within the window → skip the doomed re-render (one probe per
577
858
  // key per window per isolate). Expired entries self-evict inside the check.
578
- if (isCaptureBackedOff(key)) return;
859
+ if (isCaptureBackedOff(key)) {
860
+ publishCaptureDebugEvent(descriptor, {
861
+ key,
862
+ outcome: "skip-backoff",
863
+ ...backoffFields(key),
864
+ });
865
+ return;
866
+ }
579
867
  inFlightCaptures.add(key);
580
868
  const captureTask = async () => {
581
869
  try {
@@ -593,25 +881,43 @@ export function scheduleShellCapture(
593
881
  // off so the next requests don't re-probe it. A `redirect` has no shell but
594
882
  // is not a doomed render — leave the backoff untouched.
595
883
  if (outcome === "stored") clearCaptureBackoff(key);
596
- else if (outcome === "no-shell") markCaptureBackoff(key);
884
+ else if (outcome === "no-shell") {
885
+ markCaptureBackoff(key);
886
+ publishCaptureDebugEvent(descriptor, {
887
+ key,
888
+ outcome: "backoff",
889
+ ...backoffFields(key),
890
+ });
891
+ }
597
892
  } catch (error) {
598
893
  // Detached background task — pass reqCtx so onError still fires when the ALS
599
894
  // context is gone. A genuine failure recurs, so back it off too (re-probe
600
895
  // once per window, not every request) and report it once.
601
896
  markCaptureBackoff(key);
897
+ publishCaptureDebugEvent(descriptor, {
898
+ key,
899
+ outcome: "error",
900
+ ...backoffFields(key),
901
+ });
602
902
  reportCacheError(error, "cache-write", "[ShellCache] capture", reqCtx);
603
903
  } finally {
604
904
  inFlightCaptures.delete(key);
605
905
  }
606
906
  };
907
+ // Serialize capture EXECUTION per isolate (capture-queue.ts): concurrent
908
+ // captures starve each other's task-quantized quiet windows — one grinding
909
+ // capture makes the sibling freeze a trivial prelude and store nothing
910
+ // (rotating eternal-MISS victims on GH runners). The stampede guard above
911
+ // stays per-key (dedupe while queued); the queue is cross-key.
912
+ const serializedTask = () => enqueueSerializedCapture(captureTask);
607
913
  // The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
608
914
  // capture drains that list before rendering (the write-barrier ordering edge),
609
915
  // and awaiting its own still-running promise would burn the whole barrier
610
916
  // deadline on every capture.
611
- (captureTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
917
+ (serializedTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
612
918
  UNTRACKED_BACKGROUND_TASK
613
919
  ] = true;
614
- runBackground(reqCtx, captureTask);
920
+ runBackground(reqCtx, serializedTask);
615
921
  }
616
922
 
617
923
  /**
@@ -625,6 +931,21 @@ export function scheduleShellCapture(
625
931
  */
626
932
  type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
627
933
 
934
+ /**
935
+ * Per-attempt observability fields, filled along the capture path (barrier in
936
+ * attemptCapture, the rest in captureAndStoreShell) and folded into the
937
+ * attempt's {@link ShellCaptureDebugEvent} by runShellCapture. A plain mutable
938
+ * bag, not a return value: captureAndStoreShell's outcome type stays a string
939
+ * union its existing callers (producer B, tests) consume unchanged.
940
+ */
941
+ interface CaptureAttemptStats {
942
+ barrierWaitMs?: number;
943
+ writeSettleMs?: number;
944
+ preludeBytes?: number;
945
+ snapshotBytes?: number;
946
+ snapshotSkipped?: boolean;
947
+ }
948
+
628
949
  /**
629
950
  * Run the shell capture with a single in-place retry, then store the result.
630
951
  *
@@ -657,15 +978,37 @@ async function runShellCapture(
657
978
  ? (message: string) => console.log(message)
658
979
  : () => {};
659
980
 
660
- const first = await attemptCapture(
661
- ctx,
662
- request,
663
- env,
664
- url,
665
- reqCtx,
666
- ssrModule,
667
- descriptor,
668
- );
981
+ // One attempt + its structured debug event: the stats object rides through
982
+ // attemptCapture/captureAndStoreShell collecting the observability fields
983
+ // (barrier wait, write-settle wait, prelude/snapshot bytes), and the event
984
+ // folds them with the outcome. A genuine render error skips the attempt
985
+ // event — scheduleShellCapture's catch publishes the terminal `error` event.
986
+ const timedAttempt = async (
987
+ attempt: number,
988
+ ): Promise<CaptureAttemptOutcome> => {
989
+ const stats: CaptureAttemptStats = {};
990
+ const start = performance.now();
991
+ const outcome = await attemptCapture(
992
+ ctx,
993
+ request,
994
+ env,
995
+ url,
996
+ reqCtx,
997
+ ssrModule,
998
+ descriptor,
999
+ stats,
1000
+ );
1001
+ publishCaptureDebugEvent(descriptor, {
1002
+ key: descriptor.key,
1003
+ outcome,
1004
+ attempt,
1005
+ attemptMs: Math.round(performance.now() - start),
1006
+ ...stats,
1007
+ });
1008
+ return outcome;
1009
+ };
1010
+
1011
+ const first = await timedAttempt(1);
669
1012
  // "refused" is deterministic (identity guard / rejected bake-lane loader —
670
1013
  // its own warning already fired): no retry, and the caller backs the key off
671
1014
  // exactly like a structural no-shell.
@@ -682,15 +1025,7 @@ async function runShellCapture(
682
1025
  `[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
683
1026
  );
684
1027
  await delay(retryDelayMs);
685
- const second = await attemptCapture(
686
- ctx,
687
- request,
688
- env,
689
- url,
690
- reqCtx,
691
- ssrModule,
692
- descriptor,
693
- );
1028
+ const second = await timedAttempt(2);
694
1029
  if (second === "refused") return "no-shell";
695
1030
  if (second !== "no-shell") return second;
696
1031
 
@@ -737,6 +1072,9 @@ function handlerLayerIsLive(
737
1072
  * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
738
1073
  * - _metricsStore: undefined so the capture never appends to the foreground's
739
1074
  * (already-finalized) metrics.
1075
+ * - _renderBarrier family: an own barrier wired to the fresh handle store
1076
+ * (wireRenderBarrier), plus _treeHasStreaming/deadlock-guard resets — the
1077
+ * capture's rendered() lifecycle is its own, not the foreground's.
740
1078
  *
741
1079
  * The capture is MIXED-CHAIN: its match() behaves like a normal render with
742
1080
  * respect to the segment cache — cache()'d segments replay from ring 3, UNCACHED
@@ -756,6 +1094,7 @@ async function attemptCapture(
756
1094
  reqCtx: RequestContext<any>,
757
1095
  ssrModule: SSRModule,
758
1096
  descriptor: ShellCaptureDescriptor,
1097
+ stats: CaptureAttemptStats,
759
1098
  ): Promise<CaptureAttemptOutcome> {
760
1099
  // WRITE BARRIER (ordering edge, not a narrower race): settle the foreground's
761
1100
  // already-scheduled background tasks — its deferred ring-3/ring-1 cache writes —
@@ -766,8 +1105,79 @@ async function attemptCapture(
766
1105
  // skipped, cache-store middleware's write path gated off by state.cacheHit), so
767
1106
  // prelude, snapshot, and ring-3 agree on the foreground's generation. Runs per
768
1107
  // attempt (the retry re-checks; already-settled promises are free).
1108
+ const barrierStart = performance.now();
769
1109
  await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
1110
+ stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
1111
+
1112
+ const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
1113
+ reqCtx,
1114
+ descriptor,
1115
+ );
1116
+
1117
+ return runWithRequestContext(derivedCtx, async () => {
1118
+ const match = await ctx.router.match(request, { env });
1119
+ // A route that redirects has no shell to capture — bail (no store write, no
1120
+ // retry: a redirect is deterministic).
1121
+ if (match.redirect) return "redirect";
1122
+
1123
+ setRequestContextParams(match.params, match.routeName);
770
1124
 
1125
+ const payload = buildFullPayload(
1126
+ match,
1127
+ ctx,
1128
+ url,
1129
+ derivedCtx,
1130
+ freshHandleStore,
1131
+ );
1132
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
1133
+ onError: (error: unknown) => {
1134
+ ctx.callOnError(error, "rendering", { request, url, env });
1135
+ },
1136
+ });
1137
+
1138
+ // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
1139
+ // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
1140
+ // captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
1141
+ // content (and tags propagated by async cache()/"use cache" reads) lands after
1142
+ // this synchronous construction point, so snapshotting here dropped it — the
1143
+ // shell-tag snapshot must sit behind the quiesce gate (issue #676).
1144
+ return captureAndStoreShell(
1145
+ ssrModule,
1146
+ rscStream,
1147
+ freshHandleStore,
1148
+ derivedCtx,
1149
+ descriptor,
1150
+ stats,
1151
+ );
1152
+ });
1153
+ }
1154
+
1155
+ /**
1156
+ * The derived capture context and its fresh (mask-funneled) handle store,
1157
+ * shared by BOTH shell producers: the runtime background capture
1158
+ * (attemptCapture, producer A) and the build-time prerender shell capture
1159
+ * (prerender/build-shell-capture.ts, producer B — issue #699). One
1160
+ * implementation so the capture semantics — the nested-thenable mask funnel,
1161
+ * handler-liveness bookkeeping, snapshot recording, the implicit doc-cache
1162
+ * scope — cannot drift between producers.
1163
+ */
1164
+ export interface CaptureContextDerivation {
1165
+ derivedCtx: RequestContext;
1166
+ freshHandleStore: HandleStore;
1167
+ }
1168
+
1169
+ /**
1170
+ * Derive the capture request context from a base context. Producer A passes
1171
+ * the foreground request's post-middleware context (the derived context
1172
+ * inherits its variables/env/cookie machinery through the prototype);
1173
+ * producer B passes a synthetic build-request context created by
1174
+ * createRequestContext over the build env, with a fresh MemorySegmentCacheStore
1175
+ * as `_cacheStore` so the recording/snapshot machinery arms identically.
1176
+ */
1177
+ export function deriveShellCaptureContext(
1178
+ reqCtx: RequestContext<any>,
1179
+ descriptor: Pick<ShellCaptureDescriptor, "ttl" | "swr">,
1180
+ ): CaptureContextDerivation {
771
1181
  const freshHandleStore = createHandleStore();
772
1182
  freshHandleStore.onError = reqCtx._handleStore.onError;
773
1183
  // Shape = liveness for handles, exactly as for bake-lane loader containers
@@ -839,6 +1249,15 @@ async function attemptCapture(
839
1249
 
840
1250
  const derivedCtx: RequestContext = Object.create(reqCtx);
841
1251
  derivedCtx._handleStore = freshHandleStore;
1252
+ // Own render barrier, closure-bound to the derived ctx and the fresh store
1253
+ // (issue #684, plan 009). Without this every _renderBarrier* read fell
1254
+ // through the prototype to the foreground's ALREADY-RESOLVED barrier: a
1255
+ // bake-lane loader's `await ctx.rendered()` resolved instantly and
1256
+ // ctx.use(handle) read the FOREGROUND handle snapshot — foreground
1257
+ // per-request handle data could bake into the shared shell. wireRenderBarrier
1258
+ // also resets _treeHasStreaming (recomputed for the capture's tree) and the
1259
+ // deadlock-guard fields as own properties.
1260
+ wireRenderBarrier(derivedCtx, freshHandleStore);
842
1261
  derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
843
1262
  derivedCtx._requestTags = new Set<string>();
844
1263
  // Own explicit-store registry: cache-store resolutions during the capture
@@ -904,41 +1323,7 @@ async function attemptCapture(
904
1323
  };
905
1324
  }
906
1325
 
907
- return runWithRequestContext(derivedCtx, async () => {
908
- const match = await ctx.router.match(request, { env });
909
- // A route that redirects has no shell to capture — bail (no store write, no
910
- // retry: a redirect is deterministic).
911
- if (match.redirect) return "redirect";
912
-
913
- setRequestContextParams(match.params, match.routeName);
914
-
915
- const payload = buildFullPayload(
916
- match,
917
- ctx,
918
- url,
919
- derivedCtx,
920
- freshHandleStore,
921
- );
922
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
923
- onError: (error: unknown) => {
924
- ctx.callOnError(error, "rendering", { request, url, env });
925
- },
926
- });
927
-
928
- // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
929
- // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
930
- // captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
931
- // content (and tags propagated by async cache()/"use cache" reads) lands after
932
- // this synchronous construction point, so snapshotting here dropped it — the
933
- // shell-tag snapshot must sit behind the quiesce gate (issue #676).
934
- return captureAndStoreShell(
935
- ssrModule,
936
- rscStream,
937
- freshHandleStore,
938
- derivedCtx,
939
- descriptor,
940
- );
941
- });
1326
+ return { derivedCtx, freshHandleStore };
942
1327
  }
943
1328
 
944
1329
  /**
@@ -960,6 +1345,7 @@ async function captureAndStoreShell(
960
1345
  handleStore: HandleStore,
961
1346
  reqCtx: RequestContext<any>,
962
1347
  capture: ShellCaptureDescriptor,
1348
+ stats?: CaptureAttemptStats,
963
1349
  ): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
964
1350
  const captureShellHTML = ssrModule.captureShellHTML!;
965
1351
 
@@ -1048,10 +1434,12 @@ async function captureAndStoreShell(
1048
1434
  // captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
1049
1435
  let result: Awaited<ReturnType<typeof captureShellHTML>>;
1050
1436
  try {
1437
+ // One deadline for the whole capture — semantics spec'd on the option
1438
+ // (PartialPrerenderProps.captureTimeout, urls/pattern-types.ts).
1051
1439
  result = await observePhase(PHASES.ssr, () =>
1052
1440
  captureShellHTML(gate.stream, {
1053
1441
  quiesce,
1054
- maxWaitMs: SHELL_CAPTURE_MAX_WAIT_MS,
1442
+ maxWaitMs: capture.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS,
1055
1443
  }),
1056
1444
  );
1057
1445
  } catch (error) {
@@ -1087,6 +1475,7 @@ async function captureAndStoreShell(
1087
1475
  if (result === null) {
1088
1476
  return "no-shell";
1089
1477
  }
1478
+ if (stats) stats.preludeBytes = result.prelude.length;
1090
1479
 
1091
1480
  // Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
1092
1481
  // threads the SAME store it resolved for its getShell read (options.store ??
@@ -1126,7 +1515,11 @@ async function captureAndStoreShell(
1126
1515
  const recording = getRecordingStore(reqCtx._cacheStore);
1127
1516
  let snapshot: ShellSnapshotRecord[] | undefined;
1128
1517
  if (recording) {
1518
+ const settleStart = performance.now();
1129
1519
  await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
1520
+ if (stats) {
1521
+ stats.writeSettleMs = Math.round(performance.now() - settleStart);
1522
+ }
1130
1523
  snapshot = recording.drainSnapshot();
1131
1524
  }
1132
1525
 
@@ -1185,6 +1578,29 @@ async function captureAndStoreShell(
1185
1578
  }
1186
1579
  }
1187
1580
 
1581
+ // Snapshot size guard (issue #651): the snapshot duplicates every pinned
1582
+ // cache value inside the shell entry, so a page over a large cache()
1583
+ // segment can push the stored envelope toward store value limits (KV caps
1584
+ // a value at 25 MiB) with no signal — the kv.put rejects deep inside
1585
+ // waitUntil. Measure the serialized snapshot (UTF-8 bytes of the JSON that
1586
+ // rides in the envelope) AFTER the loader family is appended, and over the
1587
+ // cap store the shell WITHOUT it: pinned reads then fall back to the live
1588
+ // store on a HIT (documented drift — hydration repairs a mismatch
1589
+ // client-side, the pre-snapshot behavior), which beats losing the whole
1590
+ // entry to a store-side write rejection. Reported once per key.
1591
+ if (snapshot && snapshot.length > 0) {
1592
+ const snapshotBytes = SNAPSHOT_BYTE_ENCODER.encode(
1593
+ JSON.stringify(snapshot),
1594
+ ).length;
1595
+ if (stats) stats.snapshotBytes = snapshotBytes;
1596
+ const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
1597
+ if (snapshotBytes > cap) {
1598
+ warnSnapshotOverCapOnce(capture.key, snapshotBytes, cap);
1599
+ snapshot = undefined;
1600
+ if (stats) stats.snapshotSkipped = true;
1601
+ }
1602
+ }
1603
+
1188
1604
  // Shell tags snapshot at the WRITE BARRIER, not at stream construction: by
1189
1605
  // here the capture has quiesced and the deferred cache writes were awaited, so
1190
1606
  // tags recorded AFTER an await in async shell content (and by async
@@ -1262,8 +1678,15 @@ async function captureAndStoreShell(
1262
1678
  }
1263
1679
  }
1264
1680
 
1265
- // Exported for unit tests that drive the capture core directly.
1266
- export { runShellCapture, captureAndStoreShell };
1681
+ // Exported for unit tests that drive the capture core directly, and — with the
1682
+ // cold-graph retry pieces — for producer B (prerender/build-shell-capture.ts),
1683
+ // which mirrors the runtime capture's retry-in-place with the same delay.
1684
+ export {
1685
+ runShellCapture,
1686
+ captureAndStoreShell,
1687
+ delay,
1688
+ SHELL_CAPTURE_RETRY_DELAY_MS,
1689
+ };
1267
1690
 
1268
1691
  // Exported for unit tests that pin the refused-capture backoff policy directly
1269
1692
  // (dev cap vs production exponential growth, stored-clears, cold-start re-probe).