@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.
- package/dist/types/browser/navigation-client.d.ts +1 -1
- package/dist/types/browser/prefetch/cache.d.ts +20 -8
- package/dist/types/cache/cf/cf-cache-store.d.ts +19 -8
- package/dist/types/cache/cf/cf-cache-types.d.ts +4 -2
- package/dist/types/cache/read-through-swr.d.ts +7 -6
- package/dist/types/cloudflare/tracing.d.ts +16 -12
- package/dist/types/router/instrument.d.ts +45 -15
- package/dist/types/router/telemetry-otel.d.ts +4 -4
- package/dist/types/router/timeout.d.ts +12 -1
- package/dist/types/router/tracing.d.ts +31 -17
- package/dist/types/rsc/capture-queue.d.ts +22 -1
- package/dist/types/rsc/shell-capture.d.ts +17 -9
- package/dist/types/rsc/stream-idle.d.ts +60 -0
- package/dist/types/segment-fragments.d.ts +27 -4
- package/dist/types/server/request-context.d.ts +28 -9
- package/dist/types/vercel/tracing.d.ts +9 -8
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/cloudflare/SKILL.md +4 -2
- package/skills/observability/SKILL.md +33 -4
- package/skills/ppr/SKILL.md +25 -9
- package/src/browser/navigation-client.ts +77 -27
- package/src/browser/prefetch/cache.ts +40 -10
- package/src/browser/prefetch/fetch.ts +5 -0
- package/src/browser/rsc-router.tsx +12 -6
- package/src/cache/cache-runtime.ts +39 -25
- package/src/cache/cache-scope.ts +5 -1
- package/src/cache/cf/cf-cache-store.ts +423 -90
- package/src/cache/cf/cf-cache-types.ts +4 -2
- package/src/cache/document-cache.ts +63 -25
- package/src/cache/read-through-swr.ts +22 -16
- package/src/cloudflare/tracing.ts +16 -12
- package/src/router/instrument.ts +61 -15
- package/src/router/segment-resolution/loader-cache.ts +12 -1
- package/src/router/segment-resolution/revalidation.ts +16 -1
- package/src/router/telemetry-otel.ts +4 -4
- package/src/router/timeout.ts +12 -1
- package/src/router/tracing.ts +37 -17
- package/src/rsc/capture-queue.ts +72 -17
- package/src/rsc/handler.ts +171 -73
- package/src/rsc/rsc-rendering.ts +61 -6
- package/src/rsc/shell-capture.ts +83 -31
- package/src/rsc/stream-idle.ts +137 -0
- package/src/segment-fragments.ts +45 -4
- package/src/server/request-context.ts +29 -8
- package/src/testing/dispatch.ts +55 -1
- package/src/vercel/tracing.ts +9 -8
package/src/rsc/capture-queue.ts
CHANGED
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
})();
|
package/src/rsc/handler.ts
CHANGED
|
@@ -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
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
635
|
-
|
|
735
|
+
isTimeoutEnabled(streamIdleMs) &&
|
|
736
|
+
!isWebSocketUpgradeResponse(guarded) &&
|
|
737
|
+
guarded.body
|
|
636
738
|
) {
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
metricsStore,
|
|
781
|
+
responseSpan.setAttribute(
|
|
782
|
+
"rango.response.mode",
|
|
783
|
+
requestContext._requestMode ?? "middleware-short-circuit",
|
|
682
784
|
);
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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),
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
}
|