@rangojs/router 0.4.1 → 0.4.3

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 (47) hide show
  1. package/dist/types/browser/navigation-client.d.ts +1 -1
  2. package/dist/types/browser/prefetch/cache.d.ts +20 -8
  3. package/dist/types/cache/cf/cf-cache-store.d.ts +19 -8
  4. package/dist/types/cache/cf/cf-cache-types.d.ts +4 -2
  5. package/dist/types/cache/read-through-swr.d.ts +7 -6
  6. package/dist/types/cloudflare/tracing.d.ts +16 -12
  7. package/dist/types/router/instrument.d.ts +45 -15
  8. package/dist/types/router/telemetry-otel.d.ts +4 -4
  9. package/dist/types/router/timeout.d.ts +12 -1
  10. package/dist/types/router/tracing.d.ts +31 -17
  11. package/dist/types/rsc/capture-queue.d.ts +22 -1
  12. package/dist/types/rsc/shell-capture.d.ts +17 -9
  13. package/dist/types/rsc/stream-idle.d.ts +60 -0
  14. package/dist/types/segment-fragments.d.ts +27 -4
  15. package/dist/types/server/request-context.d.ts +28 -9
  16. package/dist/types/vercel/tracing.d.ts +9 -8
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/cloudflare/SKILL.md +4 -2
  20. package/skills/observability/SKILL.md +33 -4
  21. package/skills/ppr/SKILL.md +25 -9
  22. package/src/browser/navigation-client.ts +77 -27
  23. package/src/browser/prefetch/cache.ts +40 -10
  24. package/src/browser/prefetch/fetch.ts +5 -0
  25. package/src/browser/rsc-router.tsx +12 -6
  26. package/src/cache/cache-runtime.ts +39 -25
  27. package/src/cache/cache-scope.ts +5 -1
  28. package/src/cache/cf/cf-cache-store.ts +423 -90
  29. package/src/cache/cf/cf-cache-types.ts +4 -2
  30. package/src/cache/document-cache.ts +63 -25
  31. package/src/cache/read-through-swr.ts +22 -16
  32. package/src/cloudflare/tracing.ts +16 -12
  33. package/src/router/instrument.ts +61 -15
  34. package/src/router/segment-resolution/loader-cache.ts +12 -1
  35. package/src/router/segment-resolution/revalidation.ts +16 -1
  36. package/src/router/telemetry-otel.ts +4 -4
  37. package/src/router/timeout.ts +12 -1
  38. package/src/router/tracing.ts +37 -17
  39. package/src/rsc/capture-queue.ts +72 -17
  40. package/src/rsc/handler.ts +171 -73
  41. package/src/rsc/rsc-rendering.ts +61 -6
  42. package/src/rsc/shell-capture.ts +83 -31
  43. package/src/rsc/stream-idle.ts +137 -0
  44. package/src/segment-fragments.ts +45 -4
  45. package/src/server/request-context.ts +29 -8
  46. package/src/testing/dispatch.ts +55 -1
  47. package/src/vercel/tracing.ts +9 -8
@@ -36,6 +36,34 @@ export class CaptureQueueFullError extends Error {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Budget on how long a capture may WAIT in the queue before it is dropped
41
+ * instead of run. On workerd a queued capture rides the scheduling request's
42
+ * waitUntil, whose post-response lifetime is bounded (~30s): a capture that
43
+ * already burned 15s+ parked behind a slow predecessor would start its own
44
+ * up-to-15s attempt (captureTimeout) with no budget left and risk being
45
+ * killed mid-store-write. Dropping at the budget is safe — captures are
46
+ * best-effort by contract, and a later request re-probes the key (the drop
47
+ * does NOT mark backoff: the route is not doomed, the isolate was busy).
48
+ * Matches one attempt's SHELL_CAPTURE_MAX_WAIT_MS so wait + attempt fits the
49
+ * platform budget. Field-observed: a navigation-shell capture parked ~24s
50
+ * behind a predecessor's two-attempt cycle.
51
+ */
52
+ export const CAPTURE_QUEUE_WAIT_BUDGET_MS: number = 15_000;
53
+
54
+ /** Thrown (async) when the queue wait exceeded the budget; the task never ran. */
55
+ export class CaptureQueueWaitTimeoutError extends Error {
56
+ readonly waitedMs: number;
57
+ constructor(waitedMs: number) {
58
+ super(
59
+ `shell capture waited ${Math.round(waitedMs)}ms in the queue ` +
60
+ `(budget ${CAPTURE_QUEUE_WAIT_BUDGET_MS}ms)`,
61
+ );
62
+ this.name = "CaptureQueueWaitTimeoutError";
63
+ this.waitedMs = waitedMs;
64
+ }
65
+ }
66
+
39
67
  /**
40
68
  * Upper bound on how long one queue link may hold the queue. A capture task
41
69
  * normally settles well inside this (attempt + in-place retry + writes), but
@@ -54,6 +82,7 @@ const QUEUE_LINK_CAP_MS = 60_000;
54
82
  */
55
83
  export function enqueueSerializedCapture(
56
84
  task: () => Promise<void>,
85
+ opts?: { maxQueueWaitMs?: number },
57
86
  ): Promise<void> {
58
87
  if (admittedCaptures >= MAX_ADMITTED_CAPTURES) {
59
88
  return Promise.reject(new CaptureQueueFullError());
@@ -65,25 +94,51 @@ export function enqueueSerializedCapture(
65
94
  releaseQueue = resolve;
66
95
  });
67
96
  return (async () => {
68
- await prior.catch(() => {});
69
- let capTimer: ReturnType<typeof setTimeout> | undefined;
70
- const taskPromise = Promise.resolve()
71
- .then(task)
72
- .finally(() => {
73
- // A timed-out link releases serialization, but its detached task still
74
- // counts against admission until it actually settles.
75
- admittedCaptures--;
76
- });
97
+ const waitStart = performance.now();
98
+ 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
+ let waitTimer: ReturnType<typeof setTimeout> | undefined;
104
+ const waitTimedOut = await Promise.race([
105
+ prior.catch(() => {}).then(() => false),
106
+ new Promise<boolean>((resolve) => {
107
+ waitTimer = setTimeout(() => resolve(true), budget);
108
+ (waitTimer as { unref?: () => void }).unref?.();
109
+ }),
110
+ ]);
111
+ if (waitTimer) clearTimeout(waitTimer);
112
+ if (waitTimedOut) {
113
+ // Never started: hand the admission slot back here (the taskPromise
114
+ // finally below does it for tasks that ran). Serialization stays
115
+ // intact: THIS link releases only once the predecessor settles —
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);
120
+ }
77
121
  try {
78
- await Promise.race([
79
- taskPromise,
80
- new Promise<void>((resolve) => {
81
- capTimer = setTimeout(resolve, QUEUE_LINK_CAP_MS);
82
- (capTimer as { unref?: () => void }).unref?.();
83
- }),
84
- ]);
122
+ let capTimer: ReturnType<typeof setTimeout> | undefined;
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
+ }
85
141
  } finally {
86
- if (capTimer) clearTimeout(capTimer);
87
142
  releaseQueue();
88
143
  }
89
144
  })();
@@ -77,6 +77,7 @@ import {
77
77
  type ActionContinuation,
78
78
  } from "./server-action.js";
79
79
  import { handleLoaderFetch } from "./loader-fetch.js";
80
+ import { applyStreamIdleTimeout } from "./stream-idle.js";
80
81
  import {
81
82
  checkRequestOrigin,
82
83
  ORIGIN_CHECK_PHASE_BY_MODE,
@@ -618,88 +619,179 @@ export function createRSCHandler<
618
619
  return coreRequestHandler(request, env, url, variables, nonce);
619
620
  };
620
621
 
621
- // Execute middleware chain if any, otherwise call core handler directly
622
- let response: Response;
623
- if (matchedMiddleware.length > 0) {
624
- const mwResponse = await executeMiddleware(
625
- matchedMiddleware,
626
- request,
627
- env,
628
- variables,
629
- coreHandler,
630
- createReverseFunction(getRequiredRouteMap()),
622
+ // Execute middleware chain if any, otherwise call core handler
623
+ // directly; the response is finalized below, inside the response span.
624
+ const hasMiddleware = matchedMiddleware.length > 0;
625
+ const downstream = hasMiddleware
626
+ ? await executeMiddleware(
627
+ matchedMiddleware,
628
+ request,
629
+ env,
630
+ variables,
631
+ coreHandler,
632
+ createReverseFunction(getRequiredRouteMap()),
633
+ )
634
+ : await coreHandler();
635
+
636
+ // Final response construction + host handoff, wrapped in rango.response
637
+ // — the explicit stream-handoff marker. The callback is synchronous, so
638
+ // the span ends immediately before the handler returns the response to
639
+ // the host; it never reads or wraps response.body. A downstream throw
640
+ // skips it entirely (no response exists to hand off).
641
+ return observePhase(PHASES.response, (responseSpan) => {
642
+ let response: Response;
643
+ if (hasMiddleware) {
644
+ if (
645
+ url.searchParams.has("_rsc_partial") ||
646
+ url.searchParams.has("_rsc_action")
647
+ ) {
648
+ const intercepted = interceptRedirectForPartial(
649
+ downstream,
650
+ createRedirectFlightResponse,
651
+ { requestOrigin: url.origin, basename: router.basename },
652
+ );
653
+ response = intercepted ?? finalizeResponse(downstream);
654
+ } else {
655
+ response = finalizeResponse(downstream);
656
+ }
657
+ } else {
658
+ response = downstream;
659
+ }
660
+
661
+ // Finalize metrics after all middleware (including post-next work)
662
+ // has completed so :post spans are captured in the timeline.
663
+ // Handler timing parts are always emitted (even without debug metrics)
664
+ // so non-debug requests still get bootstrap Server-Timing entries.
665
+ const handlerTimingArr: string[] = variables.__handlerTiming || [];
666
+ // Preserve any existing Server-Timing set by response routes or middleware
667
+ const existingTiming = response.headers.get("Server-Timing");
668
+ const timingParts = existingTiming
669
+ ? [existingTiming, ...handlerTimingArr]
670
+ : [...handlerTimingArr];
671
+
672
+ const metricsStore = requestContext._metricsStore;
673
+ if (metricsStore) {
674
+ // When the store was created at handler start (earlyMetricsStore),
675
+ // handler:total covers the full request. When ctx.debugPerformance()
676
+ // created the store mid-request its requestStart is now the threaded
677
+ // _handlerStart (== handlerStart), so both branches yield the true
678
+ // request entry; reading the store's own anchor keeps this correct even
679
+ // if a store ever lands without the threading (falls back to its start).
680
+ const totalStart = earlyMetricsStore
681
+ ? handlerStart
682
+ : metricsStore.requestStart;
683
+ appendMetric(
684
+ metricsStore,
685
+ "handler:total",
686
+ totalStart,
687
+ performance.now() - totalStart,
688
+ );
689
+ const metricsTiming = buildMetricsTiming(
690
+ request.method,
691
+ url.pathname,
692
+ metricsStore,
693
+ );
694
+ if (metricsTiming) timingParts.push(metricsTiming);
695
+ }
696
+
697
+ const fullTiming = timingParts.join(", ");
698
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
699
+ try {
700
+ response.headers.set("Server-Timing", fullTiming);
701
+ } catch {
702
+ // Immutable headers (e.g. a passed-through platform Response) — drop
703
+ // the timing header, never the response. Instrumentation must not
704
+ // 500 a request.
705
+ }
706
+ }
707
+
708
+ // Single open-redirect chokepoint: every response (PE, full-page,
709
+ // middleware short-circuit, response-route) funnels through here, so
710
+ // guarding browser-followed (3xx) redirects once covers them all and any
711
+ // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
712
+ // through untouched (validated client-side instead).
713
+ const guarded = guardOutgoingRedirect(
714
+ response,
715
+ url.origin,
716
+ router.basename,
631
717
  );
632
718
 
719
+ // Stream-idle watchdog (opt-in via timeouts.streamIdleMs): bounds
720
+ // end-to-end idle flow on the streamed body — see rsc/stream-idle.ts
721
+ // for the semantics. Applied at this finalization chokepoint so every
722
+ // streaming exit is covered; websocket upgrades must never be
723
+ // reconstructed and bodiless responses have nothing to bound. The
724
+ // trip fires POST-handoff (the request ALS may be gone), so it
725
+ // reports via the eagerly captured surfaces — callOnError +
726
+ // router.telemetry directly — mirroring handleStore.onError.
727
+ // onTimeout does NOT apply: the response already left the handler,
728
+ // so no replacement Response can be served mid-stream.
729
+ let finalResponse = guarded;
730
+ const streamIdleMs = router.timeouts.streamIdleMs;
731
+ // Websocket check FIRST: a workerd upgrade response must never have
732
+ // its body getter poked (same invariant as the body_kind attribute
733
+ // below).
633
734
  if (
634
- url.searchParams.has("_rsc_partial") ||
635
- url.searchParams.has("_rsc_action")
735
+ isTimeoutEnabled(streamIdleMs) &&
736
+ !isWebSocketUpgradeResponse(guarded) &&
737
+ guarded.body
636
738
  ) {
637
- const intercepted = interceptRedirectForPartial(
638
- mwResponse,
639
- createRedirectFlightResponse,
640
- { requestOrigin: url.origin, basename: router.basename },
739
+ const routeKey = requestContext._routeName;
740
+ finalResponse = applyStreamIdleTimeout(
741
+ guarded,
742
+ streamIdleMs!,
743
+ (tripInfo) => {
744
+ callOnError(tripInfo.error, "handler", {
745
+ request,
746
+ url,
747
+ env,
748
+ routeKey,
749
+ handledByBoundary: false,
750
+ metadata: {
751
+ timeout: true,
752
+ phase: "stream-idle",
753
+ durationMs: tripInfo.totalMs,
754
+ },
755
+ });
756
+ if (router.telemetry) {
757
+ safeEmit(resolveSink(router.telemetry), {
758
+ type: "request.timeout",
759
+ timestamp: performance.now(),
760
+ requestId: getRequestId(request),
761
+ phase: "stream-idle",
762
+ pathname: url.pathname,
763
+ routeKey,
764
+ durationMs: tripInfo.totalMs,
765
+ customHandler: false,
766
+ });
767
+ }
768
+ },
641
769
  );
642
- response = intercepted ?? finalizeResponse(mwResponse);
643
- } else {
644
- response = finalizeResponse(mwResponse);
645
770
  }
646
- } else {
647
- response = await coreHandler();
648
- }
649
771
 
650
- // Finalize metrics after all middleware (including post-next work)
651
- // has completed so :post spans are captured in the timeline.
652
- // Handler timing parts are always emitted (even without debug metrics)
653
- // so non-debug requests still get bootstrap Server-Timing entries.
654
- const handlerTimingArr: string[] = variables.__handlerTiming || [];
655
- // Preserve any existing Server-Timing set by response routes or middleware
656
- const existingTiming = response.headers.get("Server-Timing");
657
- const timingParts = existingTiming
658
- ? [existingTiming, ...handlerTimingArr]
659
- : [...handlerTimingArr];
660
-
661
- const metricsStore = requestContext._metricsStore;
662
- if (metricsStore) {
663
- // When the store was created at handler start (earlyMetricsStore),
664
- // handler:total covers the full request. When ctx.debugPerformance()
665
- // created the store mid-request its requestStart is now the threaded
666
- // _handlerStart (== handlerStart), so both branches yield the true
667
- // request entry; reading the store's own anchor keeps this correct even
668
- // if a store ever lands without the threading (falls back to its start).
669
- const totalStart = earlyMetricsStore
670
- ? handlerStart
671
- : metricsStore.requestStart;
672
- appendMetric(
673
- metricsStore,
674
- "handler:total",
675
- totalStart,
676
- performance.now() - totalStart,
772
+ // Attributes describe the response actually handed to the host (after
773
+ // unsafe-redirect replacement), low-cardinality only. body_kind checks
774
+ // the websocket marker before the body getter so a workerd upgrade
775
+ // Response is never poked; `.body` is a getter access, not a read of
776
+ // the stream.
777
+ responseSpan.setAttribute(
778
+ "http.response.status_code",
779
+ finalResponse.status,
677
780
  );
678
- const metricsTiming = buildMetricsTiming(
679
- request.method,
680
- url.pathname,
681
- metricsStore,
781
+ responseSpan.setAttribute(
782
+ "rango.response.mode",
783
+ requestContext._requestMode ?? "middleware-short-circuit",
682
784
  );
683
- if (metricsTiming) timingParts.push(metricsTiming);
684
- }
685
-
686
- const fullTiming = timingParts.join(", ");
687
- if (fullTiming && !isWebSocketUpgradeResponse(response)) {
688
- try {
689
- response.headers.set("Server-Timing", fullTiming);
690
- } catch {
691
- // Immutable headers (e.g. a passed-through platform Response) — drop
692
- // the timing header, never the response. Instrumentation must not
693
- // 500 a request.
694
- }
695
- }
696
-
697
- // Single open-redirect chokepoint: every response (PE, full-page,
698
- // middleware short-circuit, response-route) funnels through here, so
699
- // guarding browser-followed (3xx) redirects once covers them all and any
700
- // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
701
- // through untouched (validated client-side instead).
702
- return guardOutgoingRedirect(response, url.origin, router.basename);
785
+ responseSpan.setAttribute(
786
+ "rango.response.body_kind",
787
+ isWebSocketUpgradeResponse(finalResponse)
788
+ ? "websocket"
789
+ : finalResponse.body === null
790
+ ? "empty"
791
+ : "stream",
792
+ );
793
+ return finalResponse;
794
+ });
703
795
  }),
704
796
  );
705
797
  };
@@ -755,6 +847,12 @@ export function createRSCHandler<
755
847
  const classifyDur = performance.now() - classifyStart;
756
848
  handlerTiming.push(`handler-classify;dur=${classifyDur.toFixed(2)}`);
757
849
 
850
+ // Stash the classified mode for the rango.response span (rango.response.mode)
851
+ // — the outer handler tail cannot see the plan. Stays unset when middleware
852
+ // short-circuits before core execution runs (reported as
853
+ // "middleware-short-circuit").
854
+ getRequestContext()._requestMode = plan.mode;
855
+
758
856
  // ---- 2. Terminal plans (no execution needed) ----
759
857
  if (plan.mode === "redirect") {
760
858
  // Redirects are handled by the pipeline (match/matchPartial),
@@ -83,6 +83,10 @@ import type {
83
83
  ShellCacheEntry,
84
84
  ShellSnapshotRecord,
85
85
  } from "../cache/types.js";
86
+ import {
87
+ SEGMENT_FRAGMENT_CAPABILITY_HEADER,
88
+ SEGMENT_FRAGMENT_RECOVERY_HEADER,
89
+ } from "../segment-fragments.js";
86
90
 
87
91
  type PprReplayBypassReason =
88
92
  | "method"
@@ -285,6 +289,7 @@ async function handleRscRenderingInner<TEnv>(
285
289
  let hasInterceptSlots = false;
286
290
  let pprReplayStatus: PprReplayStatus | undefined;
287
291
  let partialCaptureNeeded = false;
292
+ let partialCaptureKey: string | undefined;
288
293
 
289
294
  // --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
290
295
  //
@@ -494,6 +499,7 @@ async function handleRscRenderingInner<TEnv>(
494
499
  pprReplayStatus = replay.status;
495
500
  partialCaptureNeeded =
496
501
  "captureNeeded" in replay && replay.captureNeeded === true;
502
+ partialCaptureKey = "captureKey" in replay ? replay.captureKey : undefined;
497
503
 
498
504
  if (!result) {
499
505
  // Fall back to full render
@@ -612,7 +618,7 @@ async function handleRscRenderingInner<TEnv>(
612
618
 
613
619
  const rscHeaders: Record<string, string> = {
614
620
  "content-type": "text/x-component;charset=utf-8",
615
- vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
621
+ vary: `accept, X-Rango-State, X-RSC-Router-Client-Path, ${SEGMENT_FRAGMENT_CAPABILITY_HEADER}, ${SEGMENT_FRAGMENT_RECOVERY_HEADER}`,
616
622
  // Router identity, so the client can verify pre-decode (before importing
617
623
  // chunks) that this content payload belongs to its app and refuse a
618
624
  // foreign one (cache/proxy/bug). Control-only reload/redirect responses
@@ -721,7 +727,8 @@ async function handleRscRenderingInner<TEnv>(
721
727
  },
722
728
  createShellCaptureDescriptor(
723
729
  ctx,
724
- buildNavigationShellKey(url, reqCtx._searchParamsFilter),
730
+ partialCaptureKey ??
731
+ buildNavigationShellKey(url, reqCtx._searchParamsFilter),
725
732
  pprConfig,
726
733
  store,
727
734
  true,
@@ -765,8 +772,30 @@ async function matchPartialWithPprReplay<TEnv>(
765
772
  recordReplayStatus(finalStatus);
766
773
  return finalStatus;
767
774
  };
775
+ let armFragments = false;
776
+ /**
777
+ * Every matchPartial in this function runs through this helper so the
778
+ * fragment flag (#700 passthrough, armed below the navigation-context gate)
779
+ * can never outlive a match or miss a lane — a thrown-Response redirect
780
+ * exit included. Mutate-restore on the SHARED reqCtx, never a derived
781
+ * context: the pipeline writes ambient state during the match that must
782
+ * land on reqCtx (_pprReplayPostMatchReason, location state,
783
+ * _treeHasStreaming read by the render-barrier closure). Assign-back
784
+ * restore leaves an own `undefined` after the first arm, matching the
785
+ * _shellImplicitCache idiom.
786
+ */
787
+ const matchPartialForReplay = async () => {
788
+ if (!armFragments) return ctx.router.matchPartial(request, { env });
789
+ const prevFragmentPayload = reqCtx._shellFragmentPayload;
790
+ reqCtx._shellFragmentPayload = true;
791
+ try {
792
+ return await ctx.router.matchPartial(request, { env });
793
+ } finally {
794
+ reqCtx._shellFragmentPayload = prevFragmentPayload;
795
+ }
796
+ };
768
797
  const runMatch = async (status?: PprReplayStatus) => {
769
- const result = await ctx.router.matchPartial(request, { env });
798
+ const result = await matchPartialForReplay();
770
799
  // The match may have settled a fact the pre-match gate could only guess
771
800
  // at (prerender serve, intercept resolution) — report the truth.
772
801
  const finalStatus = status ? finalizeReplayStatus(status) : undefined;
@@ -803,6 +832,14 @@ async function matchPartialWithPprReplay<TEnv>(
803
832
  if (!getNavigationContextHeader(request)) {
804
833
  return runMatch({ outcome: "BYPASS", reason: "no-navigation-context" });
805
834
  }
835
+ // Fragment passthrough (#700), extended to partial replay. Navigation
836
+ // context proves the request can be matched; the separate capability header
837
+ // proves its payload consumer can expand envelopes. Older clients and the
838
+ // one-shot corruption recovery request still replay the same segment record,
839
+ // but through the server decode path.
840
+ armFragments =
841
+ request.headers.get(SEGMENT_FRAGMENT_RECOVERY_HEADER) !== "1" &&
842
+ request.headers.get(SEGMENT_FRAGMENT_CAPABILITY_HEADER) === "1";
806
843
  // The prerender probe and the cache-opt-out gate below both need the
807
844
  // classified snapshot; resolve (and persist) it once.
808
845
  const routeSnapshot = classifiedRouteSnapshot(reqCtx);
@@ -863,11 +900,13 @@ async function matchPartialWithPprReplay<TEnv>(
863
900
 
864
901
  let bypassReason: PprReplayBypassReason | undefined;
865
902
  let snapshot: ShellSnapshotRecord[] | undefined;
903
+ let snapshotStoreKey: string | undefined;
866
904
  let freshness: "fresh" | "stale" = "fresh";
867
905
  if (cached) {
868
906
  const decision = replayableShellSnapshot(cached.entry, ctx.version);
869
907
  if ("snapshot" in decision) {
870
908
  snapshot = decision.snapshot;
909
+ snapshotStoreKey = key;
871
910
  freshness = cached.shouldRevalidate ? "stale" : "fresh";
872
911
  } else {
873
912
  bypassReason = decision.reason;
@@ -895,6 +934,7 @@ async function matchPartialWithPprReplay<TEnv>(
895
934
  );
896
935
  if ("snapshot" in decision) {
897
936
  snapshot = decision.snapshot;
937
+ snapshotStoreKey = navigationKey;
898
938
  freshness = navigationCached.shouldRevalidate ? "stale" : "fresh";
899
939
  } else {
900
940
  bypassReason = decision.reason;
@@ -954,7 +994,7 @@ async function matchPartialWithPprReplay<TEnv>(
954
994
  },
955
995
  };
956
996
  try {
957
- const result = await ctx.router.matchPartial(request, { env });
997
+ const result = await matchPartialForReplay();
958
998
  const provisionalStatus: PprReplayStatus = explicitCacheBypass
959
999
  ? { outcome: "BYPASS", reason: "cache-disabled" }
960
1000
  : explicitCacheHit
@@ -999,6 +1039,7 @@ async function matchPartialWithPprReplay<TEnv>(
999
1039
 
1000
1040
  const previousImplicitCache = reqCtx._shellImplicitCache;
1001
1041
  let segmentReplayHit = false;
1042
+ let segmentReplayCorrupt = false;
1002
1043
  let explicitCacheHit = false;
1003
1044
  let explicitCacheBypass = false;
1004
1045
  reqCtx._shellImplicitCache = {
@@ -1011,6 +1052,9 @@ async function matchPartialWithPprReplay<TEnv>(
1011
1052
  onHit: () => {
1012
1053
  segmentReplayHit = true;
1013
1054
  },
1055
+ onCorrupt: () => {
1056
+ segmentReplayCorrupt = true;
1057
+ },
1014
1058
  // Arms the explicit-scope composition in withCacheLookup: a route-derived
1015
1059
  // cache() scope stays authoritative, the seeded doc record supplies the
1016
1060
  // match only on its miss. An explicit-tier hit must NOT report a replay
@@ -1027,7 +1071,7 @@ async function matchPartialWithPprReplay<TEnv>(
1027
1071
  };
1028
1072
 
1029
1073
  try {
1030
- const result = await ctx.router.matchPartial(request, { env });
1074
+ const result = await matchPartialForReplay();
1031
1075
  const provisionalStatus: PprReplayStatus = segmentReplayHit
1032
1076
  ? { outcome: "HIT", freshness }
1033
1077
  : explicitCacheHit
@@ -1040,7 +1084,18 @@ async function matchPartialWithPprReplay<TEnv>(
1040
1084
  // keep snapshot.cacheScope) — the "snapshot-miss" guess would blame the
1041
1085
  // capture for a lane that was never in play.
1042
1086
  const status = finalizeReplayStatus(provisionalStatus);
1043
- return { result, status };
1087
+ return {
1088
+ result,
1089
+ status,
1090
+ captureNeeded:
1091
+ segmentReplayCorrupt && reqCtx._pprReplayPostMatchReason === undefined,
1092
+ // A corrupt document snapshot must be overwritten at the document key;
1093
+ // writing only the secondary navigation key would leave the preferred
1094
+ // document entry shadowing the repair forever. The capture remains marked
1095
+ // navigationOnly, so a document request ignores it and recaptures a
1096
+ // document-safe shell under the same key.
1097
+ captureKey: segmentReplayCorrupt ? snapshotStoreKey : undefined,
1098
+ };
1044
1099
  } finally {
1045
1100
  reqCtx._shellImplicitCache = previousImplicitCache;
1046
1101
  }