@rangojs/router 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/types/browser/event-controller.d.ts +3 -3
- package/dist/types/browser/history-state.d.ts +2 -0
- package/dist/types/browser/navigation-transaction.d.ts +20 -2
- package/dist/types/browser/partial-update.d.ts +16 -1
- package/dist/types/browser/prefetch/cache.d.ts +6 -6
- package/dist/types/browser/react/Link.d.ts +19 -9
- package/dist/types/browser/react/context.d.ts +2 -2
- package/dist/types/browser/react/location-state-shared.d.ts +95 -56
- package/dist/types/browser/react/location-state.d.ts +1 -1
- package/dist/types/browser/transition-when.d.ts +80 -0
- package/dist/types/browser/types.d.ts +104 -9
- package/dist/types/build/generate-manifest.d.ts +8 -0
- package/dist/types/cache/background-task.d.ts +24 -2
- package/dist/types/cache/cache-exec-scope.d.ts +31 -0
- package/dist/types/cache/cache-key-utils.d.ts +44 -13
- package/dist/types/cache/cache-policy.d.ts +1 -1
- package/dist/types/cache/cache-runtime.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +117 -35
- package/dist/types/cache/cache-tag.d.ts +24 -4
- package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
- package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
- package/dist/types/cache/document-cache.d.ts +10 -0
- package/dist/types/cache/handle-snapshot.d.ts +40 -8
- package/dist/types/cache/invalidation-order.d.ts +23 -0
- package/dist/types/cache/request-tag-mask.d.ts +54 -0
- package/dist/types/cache/shell-snapshot.d.ts +88 -141
- package/dist/types/cache/tag-invalidation.d.ts +38 -14
- package/dist/types/cache/types.d.ts +68 -61
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
- package/dist/types/client-urls/navigation.d.ts +28 -2
- package/dist/types/client-urls/server-projection.d.ts +5 -2
- package/dist/types/client-urls/types.d.ts +5 -5
- package/dist/types/client.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/route-definition/helpers-types.d.ts +11 -11
- package/dist/types/route-name.d.ts +7 -0
- package/dist/types/router/error-handling.d.ts +1 -1
- package/dist/types/router/intercept-resolution.d.ts +2 -0
- package/dist/types/router/match-context.d.ts +5 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
- package/dist/types/router/router-options.d.ts +4 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
- package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
- package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
- package/dist/types/router/transition-when.d.ts +24 -10
- package/dist/types/rsc/attach-transition-when.d.ts +18 -0
- package/dist/types/rsc/full-payload.d.ts +14 -7
- package/dist/types/rsc/response-cache-serve.d.ts +15 -12
- package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
- package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
- package/dist/types/rsc/shell-capture.d.ts +78 -81
- package/dist/types/rsc/shell-serve.d.ts +19 -48
- package/dist/types/rsc/types.d.ts +2 -0
- package/dist/types/segment-system.d.ts +7 -0
- package/dist/types/server/context.d.ts +89 -61
- package/dist/types/server/cookie-store.d.ts +30 -18
- package/dist/types/server/handle-store.d.ts +52 -1
- package/dist/types/server/request-context.d.ts +66 -45
- package/dist/types/server/request-headers.d.ts +44 -0
- package/dist/types/server.d.ts +1 -0
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/index.d.ts +3 -2
- package/dist/types/testing/internal/action-id.d.ts +7 -0
- package/dist/types/testing/location-state-key.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +17 -1
- package/dist/types/testing/run-transition-when.d.ts +44 -69
- package/dist/types/testing/serve-shell-request.d.ts +7 -6
- package/dist/types/testing/shell-status.d.ts +34 -22
- package/dist/types/transition-when-ref.d.ts +79 -0
- package/dist/types/types/cache-types.d.ts +15 -3
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/types/segments.d.ts +86 -71
- package/dist/types/urls/path-helper-types.d.ts +3 -4
- package/dist/types/urls/pattern-types.d.ts +3 -4
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
- package/dist/vite/index.js +960 -97
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +76 -49
- package/skills/caching/SKILL.md +167 -69
- package/skills/client-urls/SKILL.md +1 -1
- package/skills/comparison/references/framework-comparison.md +8 -5
- package/skills/document-cache/SKILL.md +24 -0
- package/skills/hooks/SKILL.md +3 -1
- package/skills/hooks/navigation.md +23 -2
- package/skills/hooks/state.md +98 -6
- package/skills/intercept/SKILL.md +29 -12
- package/skills/loader/SKILL.md +52 -21
- package/skills/middleware/SKILL.md +18 -0
- package/skills/migrate-nextjs/SKILL.md +3 -3
- package/skills/observability/SKILL.md +6 -5
- package/skills/parallel/SKILL.md +1 -1
- package/skills/ppr/SKILL.md +129 -77
- package/skills/prerender/SKILL.md +3 -3
- package/skills/rango/SKILL.md +25 -6
- package/skills/route/SKILL.md +2 -0
- package/skills/testing/SKILL.md +27 -25
- package/skills/testing/cache-prerender.md +9 -9
- package/skills/testing/client-components.md +45 -3
- package/skills/testing/loader.md +3 -2
- package/skills/testing/middleware.md +1 -0
- package/skills/testing/predicates.md +49 -49
- package/skills/testing/render-handler.md +1 -0
- package/skills/testing/serve-shell-request.md +14 -0
- package/skills/testing/server-actions.md +9 -3
- package/skills/theme/SKILL.md +8 -4
- package/skills/typesafety/env-and-bindings.md +8 -0
- package/skills/use-cache/SKILL.md +64 -19
- package/skills/view-transitions/SKILL.md +150 -29
- package/src/__augment-tests__/augmented.check.ts +23 -0
- package/src/browser/event-controller.ts +4 -4
- package/src/browser/history-state.ts +52 -1
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +77 -18
- package/src/browser/navigation-client.ts +4 -3
- package/src/browser/navigation-store.ts +43 -1
- package/src/browser/navigation-transaction.ts +31 -11
- package/src/browser/partial-update.ts +146 -35
- package/src/browser/prefetch/cache.ts +6 -6
- package/src/browser/prefetch/fetch.ts +2 -2
- package/src/browser/react/Link.tsx +38 -33
- package/src/browser/react/NavigationProvider.tsx +2 -2
- package/src/browser/react/context.ts +2 -2
- package/src/browser/react/location-state-shared.ts +241 -49
- package/src/browser/react/location-state.ts +5 -2
- package/src/browser/rsc-router.tsx +4 -0
- package/src/browser/server-action-bridge.ts +84 -15
- package/src/browser/transition-when.ts +205 -0
- package/src/browser/types.ts +124 -10
- package/src/build/generate-manifest.ts +77 -1
- package/src/cache/background-task.ts +55 -2
- package/src/cache/cache-exec-scope.ts +48 -1
- package/src/cache/cache-key-utils.ts +56 -16
- package/src/cache/cache-policy.ts +9 -4
- package/src/cache/cache-runtime.ts +197 -144
- package/src/cache/cache-scope.ts +304 -112
- package/src/cache/cache-tag.ts +105 -13
- package/src/cache/cf/cf-cache-store.ts +152 -76
- package/src/cache/cf/cf-shell-frame.ts +2 -2
- package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
- package/src/cache/document-cache.ts +53 -7
- package/src/cache/handle-snapshot.ts +52 -9
- package/src/cache/invalidation-order.ts +84 -0
- package/src/cache/request-tag-mask.ts +147 -0
- package/src/cache/shell-snapshot.ts +130 -348
- package/src/cache/tag-invalidation.ts +57 -51
- package/src/cache/types.ts +71 -67
- package/src/cache/vercel/vercel-cache-store.ts +61 -58
- package/src/client-urls/client-root.tsx +8 -4
- package/src/client-urls/client-urls.ts +8 -6
- package/src/client-urls/navigation.ts +55 -4
- package/src/client-urls/server-projection.ts +31 -10
- package/src/client-urls/types.ts +12 -5
- package/src/client.tsx +2 -0
- package/src/host/cookie-handler.ts +2 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/loader-redirect.tsx +7 -3
- package/src/prerender/build-shell-capture.ts +155 -146
- package/src/route-definition/dsl-helpers.ts +2 -0
- package/src/route-definition/helpers-types.ts +11 -11
- package/src/route-name.ts +12 -0
- package/src/router/content-negotiation.ts +4 -1
- package/src/router/error-handling.ts +5 -2
- package/src/router/handler-context.ts +10 -7
- package/src/router/intercept-resolution.ts +11 -1
- package/src/router/loader-resolution.ts +28 -3
- package/src/router/logging.ts +5 -3
- package/src/router/match-api.ts +62 -34
- package/src/router/match-context.ts +5 -0
- package/src/router/match-handlers.ts +9 -29
- package/src/router/match-middleware/background-revalidation.ts +3 -5
- package/src/router/match-middleware/cache-lookup.ts +35 -19
- package/src/router/match-middleware/cache-store.ts +15 -9
- package/src/router/match-result.ts +1 -0
- package/src/router/middleware.ts +2 -5
- package/src/router/navigation-snapshot.ts +6 -5
- package/src/router/prerender-match.ts +4 -0
- package/src/router/request-classification.ts +7 -4
- package/src/router/router-options.ts +4 -1
- package/src/router/segment-resolution/fresh.ts +0 -4
- package/src/router/segment-resolution/loader-cache.ts +150 -25
- package/src/router/segment-resolution/loader-mask.ts +3 -4
- package/src/router/segment-resolution/loader-snapshot.ts +15 -78
- package/src/router/segment-resolution/mask-nested.ts +47 -22
- package/src/router/segment-resolution/revalidation.ts +0 -3
- package/src/router/segment-resolution/view-transition-default.ts +6 -21
- package/src/router/telemetry.ts +6 -3
- package/src/router/transition-when.ts +34 -52
- package/src/router.ts +2 -1
- package/src/rsc/attach-transition-when.ts +47 -0
- package/src/rsc/full-payload.ts +30 -10
- package/src/rsc/handler.ts +12 -10
- package/src/rsc/loader-fetch.ts +2 -1
- package/src/rsc/manifest-init.ts +4 -0
- package/src/rsc/origin-guard.ts +8 -5
- package/src/rsc/progressive-enhancement.ts +7 -30
- package/src/rsc/response-cache-serve.ts +68 -80
- package/src/rsc/response-route-handler.ts +2 -2
- package/src/rsc/rsc-rendering.ts +206 -252
- package/src/rsc/server-action.ts +8 -20
- package/src/rsc/shell-build-manifest.ts +22 -20
- package/src/rsc/shell-capture-constants.ts +136 -20
- package/src/rsc/shell-capture.ts +352 -589
- package/src/rsc/shell-serve.ts +73 -109
- package/src/rsc/ssr-setup.ts +3 -2
- package/src/rsc/types.ts +2 -0
- package/src/segment-system.tsx +40 -7
- package/src/server/context.ts +189 -124
- package/src/server/cookie-store.ts +153 -127
- package/src/server/handle-store.ts +245 -27
- package/src/server/request-context.ts +123 -84
- package/src/server/request-headers.ts +134 -0
- package/src/server.ts +4 -0
- package/src/testing/dispatch.ts +4 -7
- package/src/testing/e2e/index.ts +1 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/internal/action-id.ts +25 -0
- package/src/testing/internal/context.ts +1 -1
- package/src/testing/location-state-key.ts +45 -0
- package/src/testing/render-handler.ts +1 -1
- package/src/testing/render-route.tsx +109 -16
- package/src/testing/run-client-revalidate.ts +3 -19
- package/src/testing/run-loader.ts +5 -1
- package/src/testing/run-transition-when.ts +109 -172
- package/src/testing/serve-shell-request.ts +10 -7
- package/src/testing/shell-status.ts +60 -56
- package/src/transition-when-ref.ts +220 -0
- package/src/types/cache-types.ts +15 -3
- package/src/types/index.ts +3 -0
- package/src/types/segments.ts +94 -91
- package/src/urls/path-helper-types.ts +3 -4
- package/src/urls/pattern-types.ts +3 -4
- package/src/vite/discovery/discover-routers.ts +3 -0
- package/src/vite/discovery/shell-prerender-phase.ts +0 -2
- package/src/vite/discovery/virtual-module-codegen.ts +16 -6
- package/src/vite/plugins/transition-when-hoist.ts +1064 -0
- package/src/vite/rango.ts +2 -0
- package/src/vite/router-discovery.ts +75 -5
- package/dist/types/rsc/transition-gate.d.ts +0 -23
- package/src/rsc/transition-gate.ts +0 -86
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -6,14 +6,17 @@
|
|
|
6
6
|
* from the route's `ppr` path option after the served response is built and calls
|
|
7
7
|
* scheduleShellCapture. The capture then runs as a background task that re-derives
|
|
8
8
|
* the page via `ctx.router.match()` under its OWN derived request context — fresh
|
|
9
|
-
* handle store, `_shellCaptureRun: true` so loaders mask (loader-mask.ts)
|
|
10
|
-
* loading()
|
|
11
|
-
* from ring 3, uncached segments execute
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
9
|
+
* handle store, `_shellCaptureRun: true` so live-lane loaders mask (loader-mask.ts)
|
|
10
|
+
* and postpone at their loading() or inline <Suspense> boundary. The match is
|
|
11
|
+
* MIXED-CHAIN: cache()'d segments replay from ring 3, uncached segments execute
|
|
12
|
+
* their handlers. The record-first step (settleCaptureRecord) waits for the
|
|
13
|
+
* handler layer, writes the doc record every HIT replays, and renders the
|
|
14
|
+
* capture's Flight payload from it; the static prerender runs to a quiescent
|
|
15
|
+
* shell, aborts to freeze the prelude + postponed state, and putShell stores the
|
|
16
|
+
* pair with the snapshot. Because it uses match() rather than the HTTP pipeline,
|
|
17
|
+
* the middleware chain (auth, logging) never re-runs — it already ran for the
|
|
18
|
+
* triggering request, and the derived context inherits its post-middleware state
|
|
19
|
+
* (variables, cache store). Guarding is serve-time.
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
import React from "react";
|
|
@@ -21,7 +24,11 @@ import { isLoaderDataResult, type MatchResult } from "../types.js";
|
|
|
21
24
|
import { bytesToBase64 } from "../cache/cf/cf-base64.js";
|
|
22
25
|
import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
|
|
23
26
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
raceDeadline,
|
|
29
|
+
runBackground,
|
|
30
|
+
settleGrowing,
|
|
31
|
+
} from "../cache/background-task.js";
|
|
25
32
|
import {
|
|
26
33
|
CaptureQueueFullError,
|
|
27
34
|
CaptureQueueWaitTimeoutError,
|
|
@@ -32,6 +39,8 @@ import {
|
|
|
32
39
|
PPR_LANE_HINT,
|
|
33
40
|
SHELL_CAPTURE_MAX_WAIT_MS,
|
|
34
41
|
SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
42
|
+
resetShellWarningsForTests,
|
|
43
|
+
warnOnce,
|
|
35
44
|
} from "./shell-capture-constants.js";
|
|
36
45
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
37
46
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
@@ -56,10 +65,12 @@ import {
|
|
|
56
65
|
type MaskReport,
|
|
57
66
|
} from "../router/segment-resolution/mask-nested.js";
|
|
58
67
|
import {
|
|
68
|
+
findEnclosingLoaderBody,
|
|
59
69
|
getCurrentLoaderBodyId,
|
|
60
70
|
isInsideLoaderScope,
|
|
61
71
|
} from "../server/context.js";
|
|
62
72
|
import { isThenable } from "../handles/is-thenable.js";
|
|
73
|
+
import { requestHeaders } from "../server/request-headers.js";
|
|
63
74
|
import type {
|
|
64
75
|
CachedEntryData,
|
|
65
76
|
ShellCacheEntry,
|
|
@@ -78,7 +89,6 @@ import {
|
|
|
78
89
|
getRecordingStore,
|
|
79
90
|
hasDocRecord,
|
|
80
91
|
pruneShellSnapshot,
|
|
81
|
-
type ShellSnapshotReaders,
|
|
82
92
|
} from "../cache/shell-snapshot.js";
|
|
83
93
|
import type { HandlerContext } from "./handler-context.js";
|
|
84
94
|
import type { SSRModule } from "./types.js";
|
|
@@ -103,21 +113,13 @@ import { stripInternalParams } from "../router/handler-context.js";
|
|
|
103
113
|
*
|
|
104
114
|
* K=2 gives a race window of ~two event-loop turns: shell work still producing
|
|
105
115
|
* bytes keeps resetting the counter; anything not producing bytes within the
|
|
106
|
-
* window (the masked loaders
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
116
|
+
* window (the masked loaders) becomes a hole. Handler output never races it: the
|
|
117
|
+
* capture's Flight input is the doc record's fragments, rendered after
|
|
118
|
+
* settleCaptureRecord waited for the handler layer. See
|
|
119
|
+
* docs/design/ppr-shell-resume.md.
|
|
110
120
|
*/
|
|
111
121
|
const FLIGHT_QUIET_HOPS = 2;
|
|
112
122
|
|
|
113
|
-
/**
|
|
114
|
-
* Default capture budget. Canonical value, raise rationale, and ceiling math
|
|
115
|
-
* live on the leaf module (shell-capture-constants.ts, importable from the ssr
|
|
116
|
-
* graph too). Re-exported here so shell-build-manifest.ts's envelope math
|
|
117
|
-
* keeps its existing import site and cannot drift from the capture.
|
|
118
|
-
*/
|
|
119
|
-
export { SHELL_CAPTURE_MAX_WAIT_MS };
|
|
120
|
-
|
|
121
123
|
/**
|
|
122
124
|
* Upper bound on waiting for the capture's DEFERRED cache writes to settle before
|
|
123
125
|
* draining the snapshot. Cache writes run under waitUntil (fire-and-forget on
|
|
@@ -154,33 +156,17 @@ const SHELL_CAPTURE_WRITE_BARRIER_MS = 1500;
|
|
|
154
156
|
|
|
155
157
|
/**
|
|
156
158
|
* Settle the tracked background tasks on `reqCtx._pendingBackgroundTasks`,
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* (UNTRACKED_BACKGROUND_TASK), so the loop terminates.
|
|
159
|
+
* including nested ones a settled task scheduled (cache-store's cacheRoute
|
|
160
|
+
* outer task schedules the actual store.set in a second waitUntil), until the
|
|
161
|
+
* list stops growing or the timeout passes. The capture's own task never
|
|
162
|
+
* enters the list (UNTRACKED_BACKGROUND_TASK), so the wait terminates.
|
|
162
163
|
*/
|
|
163
164
|
async function settleTrackedBackgroundTasks(
|
|
164
165
|
reqCtx: RequestContext<any>,
|
|
165
166
|
timeoutMs: number,
|
|
166
167
|
): Promise<void> {
|
|
167
168
|
const tasks = reqCtx._pendingBackgroundTasks;
|
|
168
|
-
if (
|
|
169
|
-
const deadline = Date.now() + timeoutMs;
|
|
170
|
-
let seen = 0;
|
|
171
|
-
while (tasks.length > seen) {
|
|
172
|
-
const remaining = deadline - Date.now();
|
|
173
|
-
if (remaining <= 0) return;
|
|
174
|
-
const batch = tasks.slice(seen);
|
|
175
|
-
seen = tasks.length;
|
|
176
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
177
|
-
const guard = new Promise<void>((resolve) => {
|
|
178
|
-
timer = setTimeout(resolve, remaining);
|
|
179
|
-
(timer as { unref?: () => void }).unref?.();
|
|
180
|
-
});
|
|
181
|
-
await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
|
|
182
|
-
if (timer) clearTimeout(timer);
|
|
183
|
-
}
|
|
169
|
+
if (tasks) await settleGrowing(tasks, Date.now() + timeoutMs);
|
|
184
170
|
}
|
|
185
171
|
|
|
186
172
|
/**
|
|
@@ -210,31 +196,18 @@ function delay(ms: number): Promise<void> {
|
|
|
210
196
|
* Bound one capture task at {@link SHELL_CAPTURE_TASK_HARD_CAP_MS}. Rejects on
|
|
211
197
|
* expiry so the caller's existing catch applies backoff + reporting and its
|
|
212
198
|
* settle path releases the stampede guard and queue slot; resolves/rejects
|
|
213
|
-
* transparently otherwise.
|
|
214
|
-
* Node dev process open.
|
|
199
|
+
* transparently otherwise.
|
|
215
200
|
*/
|
|
216
|
-
function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
(timer as { unref?: () => void }).unref?.();
|
|
227
|
-
task.then(
|
|
228
|
-
(value) => {
|
|
229
|
-
clearTimeout(timer);
|
|
230
|
-
resolve(value);
|
|
231
|
-
},
|
|
232
|
-
(error: unknown) => {
|
|
233
|
-
clearTimeout(timer);
|
|
234
|
-
reject(error);
|
|
235
|
-
},
|
|
236
|
-
);
|
|
237
|
-
});
|
|
201
|
+
async function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
|
|
202
|
+
const raced = await raceDeadline(
|
|
203
|
+
task,
|
|
204
|
+
Date.now() + SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
205
|
+
);
|
|
206
|
+
if (raced.done) return raced.value;
|
|
207
|
+
throw new Error(
|
|
208
|
+
`capture task for ${key} exceeded the ${SHELL_CAPTURE_TASK_HARD_CAP_MS}ms hard cap; ` +
|
|
209
|
+
`a handler is likely wedged on a never-settling await`,
|
|
210
|
+
);
|
|
238
211
|
}
|
|
239
212
|
|
|
240
213
|
/**
|
|
@@ -254,6 +227,13 @@ function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
|
|
|
254
227
|
* keeps a late-settling stale task from releasing its replacement's entry.
|
|
255
228
|
*/
|
|
256
229
|
interface CaptureGuardToken {
|
|
230
|
+
/**
|
|
231
|
+
* The schedule time while the capture waits in the queue (which drops it at
|
|
232
|
+
* CAPTURE_QUEUE_WAIT_BUDGET_MS, under the cap), then the time its hard cap
|
|
233
|
+
* started. Stamped only at schedule time, a capture that had waited in the
|
|
234
|
+
* queue read as older than its cap while still inside it, and the next
|
|
235
|
+
* request scheduled a duplicate capture of the key.
|
|
236
|
+
*/
|
|
257
237
|
startedAt: number;
|
|
258
238
|
}
|
|
259
239
|
|
|
@@ -327,7 +307,7 @@ function refusedCaptureCeilingMs(): number {
|
|
|
327
307
|
*/
|
|
328
308
|
const refusedCaptures = new Map<string, { failures: number; until: number }>();
|
|
329
309
|
|
|
330
|
-
/**
|
|
310
|
+
/** A bake-lane container that settled with redirect() or notFound(). */
|
|
331
311
|
function isSettledLoaderSignal(value: unknown): boolean {
|
|
332
312
|
return (
|
|
333
313
|
isLoaderDataResult(value) &&
|
|
@@ -336,6 +316,7 @@ function isSettledLoaderSignal(value: unknown): boolean {
|
|
|
336
316
|
);
|
|
337
317
|
}
|
|
338
318
|
|
|
319
|
+
/** True iff `key` is still inside its (exponential) backoff window. */
|
|
339
320
|
function isCaptureBackedOff(key: string): boolean {
|
|
340
321
|
const entry = refusedCaptures.get(key);
|
|
341
322
|
if (entry === undefined) return false;
|
|
@@ -366,16 +347,11 @@ function clearCaptureBackoff(key: string): void {
|
|
|
366
347
|
refusedCaptures.delete(key);
|
|
367
348
|
}
|
|
368
349
|
|
|
369
|
-
/**
|
|
370
|
-
* Keys already warned about a refused (null) capture, so the eternal-MISS shape
|
|
371
|
-
* logs once per key per isolate instead of on every request.
|
|
372
|
-
*/
|
|
373
|
-
const warnedNullCaptures = new Set<string>();
|
|
374
|
-
|
|
375
350
|
/**
|
|
376
351
|
* Warn once per key that a capture produced no usable shell: after the
|
|
377
352
|
* in-place retry (runShellCapture attempt 2), or after one attempt that ran
|
|
378
|
-
* out of its deadline (no retry:
|
|
353
|
+
* out of its deadline (no retry: CaptureAttemptStats.noShellCause), with the
|
|
354
|
+
* terminal attempt's cause and pending component stacks. Naming both causes with the
|
|
379
355
|
* distinguishing signal — does the route ever flip to HIT — is the whole point:
|
|
380
356
|
* the pre-retry version blamed "a loader route without loading()" unconditionally
|
|
381
357
|
* and misled users whose route DID have loading() and was merely cold. Because the
|
|
@@ -386,15 +362,18 @@ const warnedNullCaptures = new Set<string>();
|
|
|
386
362
|
* The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
|
|
387
363
|
* tarball, so the path resolves for consumers (a05c8251 convention).
|
|
388
364
|
*/
|
|
389
|
-
function warnNullCaptureOnce(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
365
|
+
function warnNullCaptureOnce(
|
|
366
|
+
key: string,
|
|
367
|
+
retried: boolean,
|
|
368
|
+
attempt: CaptureAttemptStats,
|
|
369
|
+
): void {
|
|
370
|
+
const cause = attempt.noShellCause;
|
|
371
|
+
const pendingStacks = attempt.pendingStacks;
|
|
372
|
+
warnOnce(
|
|
373
|
+
"capture-no-shell",
|
|
374
|
+
key,
|
|
375
|
+
() =>
|
|
376
|
+
`[rango] Shell capture for "${key}" produced no usable shell` +
|
|
398
377
|
(retried ? " after an in-place retry" : "") +
|
|
399
378
|
"; nothing was stored, so this request stays on MISS. " +
|
|
400
379
|
(cause
|
|
@@ -421,22 +400,6 @@ function warnNullCaptureOnce(key: string, retried: boolean): void {
|
|
|
421
400
|
);
|
|
422
401
|
}
|
|
423
402
|
|
|
424
|
-
/**
|
|
425
|
-
* Why the latest no-shell attempt per key ran out of ppr.captureTimeout (the
|
|
426
|
-
* match, the handler output, or the prerender after them). Set only for a
|
|
427
|
-
* deadline outcome, so runShellCapture also reads it to skip the in-place
|
|
428
|
-
* retry. Printed (and cleared) by warnNullCaptureOnce.
|
|
429
|
-
*/
|
|
430
|
-
const noShellCauses = new Map<string, string>();
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Dev only: component stacks of the tasks still pending at the latest
|
|
434
|
-
* no-shell attempt's abort, per key, printed (and cleared) by
|
|
435
|
-
* warnNullCaptureOnce. React reports them through prerender's onError
|
|
436
|
-
* (captureShellHTML's onAbortedTask).
|
|
437
|
-
*/
|
|
438
|
-
const noShellPendingStacks = new Map<string, string[]>();
|
|
439
|
-
|
|
440
403
|
/** Stacks kept per attempt: the root pin is almost always among the first. */
|
|
441
404
|
const MAX_PENDING_STACKS = 3;
|
|
442
405
|
|
|
@@ -451,9 +414,6 @@ function formatPendingStack(stack: string): string {
|
|
|
451
414
|
.join("\n");
|
|
452
415
|
}
|
|
453
416
|
|
|
454
|
-
/** Keys already warned about a deterministic capture refusal (once per key). */
|
|
455
|
-
const warnedRefusedCaptures = new Set<string>();
|
|
456
|
-
|
|
457
417
|
/**
|
|
458
418
|
* Warn once per key that the capture was REFUSED for a deterministic reason
|
|
459
419
|
* (identity-guard trip, a rejected or signal-settled bake-lane loader, a store
|
|
@@ -467,10 +427,11 @@ function warnCaptureRefusedOnce(
|
|
|
467
427
|
reason: string,
|
|
468
428
|
pointer: string = "See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
|
|
469
429
|
): void {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
430
|
+
warnOnce(
|
|
431
|
+
"capture-refused",
|
|
432
|
+
key,
|
|
433
|
+
() =>
|
|
434
|
+
`[rango] Shell capture for "${key}" was refused: ${reason}\n` +
|
|
474
435
|
"The route stays on MISS (axis 1) — the page keeps working, only the shell " +
|
|
475
436
|
`cache is off. ${pointer}`,
|
|
476
437
|
);
|
|
@@ -483,7 +444,7 @@ function warnCaptureRefusedOnce(
|
|
|
483
444
|
* an async server component, a bake-lane loader. The read throws where it
|
|
484
445
|
* happens, but the code can catch that throw, so the flag the guard sets on
|
|
485
446
|
* the capture context is what decides (server/context.ts
|
|
486
|
-
*
|
|
447
|
+
* guardIdentityRead). Returns true when it refused (and warned).
|
|
487
448
|
*/
|
|
488
449
|
function refuseOnCaptureGuard(
|
|
489
450
|
key: string,
|
|
@@ -506,7 +467,7 @@ function refuseOnCaptureGuard(
|
|
|
506
467
|
// Name the recorded source instead of hardcoding a lane: a trip can come
|
|
507
468
|
// from a bake-lane SEGMENT loader or from handler/render code, including a
|
|
508
469
|
// loader a handler awaits (issue #672). The guard recorded the read and its
|
|
509
|
-
// fix (server/context.ts
|
|
470
|
+
// fix (server/context.ts guardIdentityRead): cookies()/headers(), a
|
|
510
471
|
// { cache: false } variable, and the theme reads (#971).
|
|
511
472
|
const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
|
|
512
473
|
const origin = loaderId
|
|
@@ -559,9 +520,6 @@ export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
|
559
520
|
/** Cached encoder for the snapshot byte measurement (one per module, not per capture). */
|
|
560
521
|
const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
561
522
|
|
|
562
|
-
/** Keys already warned about an over-cap snapshot (once per key per isolate). */
|
|
563
|
-
const warnedOverCapSnapshots = new Set<string>();
|
|
564
|
-
|
|
565
523
|
/**
|
|
566
524
|
* Warn once per key that the capture's loader pins exceeded the route's
|
|
567
525
|
* `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
|
|
@@ -576,10 +534,11 @@ function warnSnapshotOverCapOnce(
|
|
|
576
534
|
snapshotBytes: number,
|
|
577
535
|
capBytes: number,
|
|
578
536
|
): void {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
537
|
+
warnOnce(
|
|
538
|
+
"snapshot-over-cap",
|
|
539
|
+
key,
|
|
540
|
+
() =>
|
|
541
|
+
`[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
|
|
583
542
|
`loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
|
|
584
543
|
"the shell was stored with its doc record only. HITs still replay the " +
|
|
585
544
|
"handler layer, but ssr: false loaders now read the live store: if " +
|
|
@@ -592,30 +551,28 @@ function warnSnapshotOverCapOnce(
|
|
|
592
551
|
}
|
|
593
552
|
|
|
594
553
|
/**
|
|
595
|
-
* Dev warning threshold (ms) for the
|
|
596
|
-
* time is by-design shell latency
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
* ladder.
|
|
554
|
+
* Dev warning threshold (ms) for the record-first settle
|
|
555
|
+
* (CaptureAttemptStats.recordSettleMs). Bake time is by-design shell latency
|
|
556
|
+
* (handler output, top-level pushed handle promises and bake-lane loader
|
|
557
|
+
* containers settle before the freeze), but it recurs on EVERY capture of the
|
|
558
|
+
* route and occupies the per-isolate serialized capture queue — silently: the
|
|
559
|
+
* served response's own timing never shows it (a route can read 13ms TTFB
|
|
560
|
+
* while each of its captures holds the queue for seconds). Past this
|
|
561
|
+
* threshold the cost gets named once per key with the remedy ladder.
|
|
603
562
|
*/
|
|
604
563
|
const SHELL_CAPTURE_BAKE_WARN_MS = 2_000;
|
|
605
564
|
|
|
606
|
-
/** Keys already warned about an expensive bake source (once per key). */
|
|
607
|
-
const warnedBakeCosts = new Set<string>();
|
|
608
|
-
|
|
609
565
|
/**
|
|
610
|
-
* Dev-only, once per key: name the
|
|
611
|
-
*
|
|
566
|
+
* Dev-only, once per key: name the bake cost and the three remedies in
|
|
567
|
+
* preference order. Handler output and bake-lane values are shell material
|
|
612
568
|
* by design, so this is a cost report with an exit, not a deprecation.
|
|
613
569
|
*/
|
|
614
570
|
function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
571
|
+
warnOnce(
|
|
572
|
+
"bake-cost",
|
|
573
|
+
key,
|
|
574
|
+
() =>
|
|
575
|
+
`[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
|
|
619
576
|
"settle before the shell could freeze. This cost recurs on every capture " +
|
|
620
577
|
"of the route and occupies the per-isolate capture queue; the served " +
|
|
621
578
|
"response never shows it. To keep it baked but cheap, wrap the work in " +
|
|
@@ -714,15 +671,6 @@ export interface ShellCaptureDebugEvent {
|
|
|
714
671
|
* same-or-higher priority. 0 = this capture starts immediately.
|
|
715
672
|
*/
|
|
716
673
|
queueAhead?: number;
|
|
717
|
-
/**
|
|
718
|
-
* Ms the capture gate was HELD waiting for the slowest bake source — a
|
|
719
|
-
* top-level pushed handle promise or a bake-lane loader container — to
|
|
720
|
-
* settle, measured from capture start. Bake time is by-design shell latency
|
|
721
|
-
* (the hole doctrine: top-level promises bake), but it recurs on EVERY
|
|
722
|
-
* capture of the route and occupies the serialized queue; this field makes
|
|
723
|
-
* it attributable. Absent when nothing held the gate.
|
|
724
|
-
*/
|
|
725
|
-
bakeWaitMs?: number;
|
|
726
674
|
/**
|
|
727
675
|
* How long the capture waited for its doc record: the handler layer's
|
|
728
676
|
* promises, async server components and handle pushes settling, then the
|
|
@@ -786,9 +734,6 @@ export function describeShellCaptureEvent(
|
|
|
786
734
|
if (event.queueAhead !== undefined) {
|
|
787
735
|
parts.push(`queue-ahead=${event.queueAhead}`);
|
|
788
736
|
}
|
|
789
|
-
if (event.bakeWaitMs !== undefined) {
|
|
790
|
-
parts.push(`bake=${event.bakeWaitMs}ms`);
|
|
791
|
-
}
|
|
792
737
|
if (event.recordSettleMs !== undefined) {
|
|
793
738
|
parts.push(`record=${event.recordSettleMs}ms`);
|
|
794
739
|
}
|
|
@@ -936,22 +881,13 @@ export interface FlightCaptureGate {
|
|
|
936
881
|
* closing (no holes) fires quiesce immediately for the DATA variant — the
|
|
937
882
|
* TransformStream then closes the readable, so fizz completes with postponed null.
|
|
938
883
|
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
941
|
-
*
|
|
942
|
-
*
|
|
943
|
-
* out — the handles row would never reach fizz and the prelude would come back
|
|
944
|
-
* trivial. While `holdUntil` is pending, byte-quiet detection keeps running but the
|
|
945
|
-
* gate neither fires nor freezes; once it resolves, the quiet counter restarts so a
|
|
946
|
-
* burst of rows unblocked by it (the resolved handles row) is still captured. It
|
|
947
|
-
* never delays a HOLE from postponing: holes are pending promises that emit no
|
|
948
|
-
* bytes, so holding the gate open longer only ever admits shell rows. Bounded by
|
|
949
|
-
* captureShellHTML's maxWaitMs like every other quiesce input.
|
|
884
|
+
* Nothing the shell bakes can still be pending here: settleCaptureRecord already
|
|
885
|
+
* waited for the top-level handle promises and the bake-lane loader containers
|
|
886
|
+
* before the capture's Flight render began, so the handles row and the baked
|
|
887
|
+
* loader rows emit at once and only the holes stay silent.
|
|
950
888
|
*/
|
|
951
889
|
export function gateFlightForCapture(
|
|
952
890
|
source: ReadableStream<Uint8Array>,
|
|
953
|
-
quietHops: number = FLIGHT_QUIET_HOPS,
|
|
954
|
-
holdUntil?: Promise<unknown>,
|
|
955
891
|
): FlightCaptureGate {
|
|
956
892
|
let resolveQuiet!: () => void;
|
|
957
893
|
const quiesce = new Promise<void>((resolve) => {
|
|
@@ -963,32 +899,9 @@ export function gateFlightForCapture(
|
|
|
963
899
|
let settled = false;
|
|
964
900
|
let disposed = false;
|
|
965
901
|
let frozen = false;
|
|
966
|
-
let held = holdUntil !== undefined;
|
|
967
|
-
let heldFirePending = false;
|
|
968
|
-
|
|
969
|
-
if (holdUntil !== undefined) {
|
|
970
|
-
const release = (): void => {
|
|
971
|
-
held = false;
|
|
972
|
-
if (heldFirePending && !settled && !disposed) {
|
|
973
|
-
// Quiet elapsed while held: restart the quiet count instead of firing
|
|
974
|
-
// immediately, so rows unblocked by the hold (the baked handles row)
|
|
975
|
-
// still flow before the freeze.
|
|
976
|
-
heldFirePending = false;
|
|
977
|
-
armed = false;
|
|
978
|
-
arm();
|
|
979
|
-
}
|
|
980
|
-
};
|
|
981
|
-
// Resolve OR reject releases the hold (a rejected handle value is dropped by
|
|
982
|
-
// resolveDeferredHandleValues; the capture must not hang on it).
|
|
983
|
-
holdUntil.then(release, release);
|
|
984
|
-
}
|
|
985
902
|
|
|
986
903
|
const fire = (): void => {
|
|
987
904
|
if (settled) return;
|
|
988
|
-
if (held) {
|
|
989
|
-
heldFirePending = true;
|
|
990
|
-
return;
|
|
991
|
-
}
|
|
992
905
|
settled = true;
|
|
993
906
|
frozen = true;
|
|
994
907
|
resolveQuiet();
|
|
@@ -1012,7 +925,7 @@ export function gateFlightForCapture(
|
|
|
1012
925
|
if (settled || disposed) return;
|
|
1013
926
|
if (bytesSeen === lastSeen) {
|
|
1014
927
|
quiet += 1;
|
|
1015
|
-
if (quiet >=
|
|
928
|
+
if (quiet >= FLIGHT_QUIET_HOPS) {
|
|
1016
929
|
fire();
|
|
1017
930
|
return;
|
|
1018
931
|
}
|
|
@@ -1098,16 +1011,13 @@ export interface ShellCaptureDescriptor {
|
|
|
1098
1011
|
tags?: string[];
|
|
1099
1012
|
/**
|
|
1100
1013
|
* Per-route capture settle budget in ms (`ppr.captureTimeout`, resolved by
|
|
1101
|
-
* resolvePprConfig)
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
* pending). Undefined = SHELL_CAPTURE_MAX_WAIT_MS (15_000).
|
|
1014
|
+
* resolvePprConfig): the ONE deadline bounding the whole capture — the match,
|
|
1015
|
+
* the record-first settle (settleCaptureRecord), then the fizz prerender with
|
|
1016
|
+
* what is left (captureShellHTML's maxWaitMs). Undefined =
|
|
1017
|
+
* SHELL_CAPTURE_MAX_WAIT_MS (15_000).
|
|
1106
1018
|
*/
|
|
1107
1019
|
captureTimeout?: number;
|
|
1108
1020
|
store?: SegmentCacheStore<any>;
|
|
1109
|
-
/** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
|
|
1110
|
-
debug?: boolean;
|
|
1111
1021
|
/**
|
|
1112
1022
|
* Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
|
|
1113
1023
|
* the snapshot is skipped and the shell stored without it (reported once per
|
|
@@ -1219,16 +1129,23 @@ export function scheduleShellCapture(
|
|
|
1219
1129
|
const guardToken: CaptureGuardToken = { startedAt: Date.now() };
|
|
1220
1130
|
inFlightCaptures.set(key, guardToken);
|
|
1221
1131
|
const captureTask = async (span: TraceSpan) => {
|
|
1132
|
+
// The guard's age counts from the task's start, not its scheduling: a
|
|
1133
|
+
// capture queued behind others (up to CAPTURE_QUEUE_WAIT_BUDGET_MS) is
|
|
1134
|
+
// not abandoned while its SSR setup loads.
|
|
1135
|
+
guardToken.startedAt = Date.now();
|
|
1222
1136
|
try {
|
|
1223
|
-
|
|
1137
|
+
// A navigation-only capture renders under document request identity:
|
|
1138
|
+
// its transport parameters and headers are stripped once, here, for the
|
|
1139
|
+
// SSR setup and every attempt.
|
|
1140
|
+
const captureUrl = descriptor.navigationOnly
|
|
1224
1141
|
? stripInternalParams(url)
|
|
1225
1142
|
: url;
|
|
1226
|
-
const
|
|
1227
|
-
? createNavigationCaptureRequest(request,
|
|
1143
|
+
const captureRequest = descriptor.navigationOnly
|
|
1144
|
+
? createNavigationCaptureRequest(request, captureUrl)
|
|
1228
1145
|
: request;
|
|
1229
1146
|
const resolvedSsrModule =
|
|
1230
1147
|
typeof ssrModule === "function"
|
|
1231
|
-
? await ssrModule(
|
|
1148
|
+
? await ssrModule(captureRequest, captureUrl)
|
|
1232
1149
|
: ssrModule;
|
|
1233
1150
|
if (
|
|
1234
1151
|
!resolvedSsrModule ||
|
|
@@ -1237,19 +1154,21 @@ export function scheduleShellCapture(
|
|
|
1237
1154
|
) {
|
|
1238
1155
|
return;
|
|
1239
1156
|
}
|
|
1240
|
-
// Hard-capped:
|
|
1241
|
-
//
|
|
1242
|
-
// wedged on a never-settling upstream await
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1157
|
+
// Hard-capped: ppr.captureTimeout bounds the attempt, but a match that
|
|
1158
|
+
// loses its deadline keeps running (no abort signal), and a handler
|
|
1159
|
+
// wedged on a never-settling upstream await would strand the stampede
|
|
1160
|
+
// guard and the queue slot (production pilot). The cap rejects, riding
|
|
1161
|
+
// the existing catch: backoff + reportCacheError + token-guarded
|
|
1162
|
+
// release. The abandoned attempt keeps running until its context dies;
|
|
1163
|
+
// nothing awaits it. From here the guard's age counts from the cap's
|
|
1164
|
+
// start, so a slow SSR setup does not shorten the capped run.
|
|
1165
|
+
guardToken.startedAt = Date.now();
|
|
1247
1166
|
const outcome = await raceTaskHardCap(
|
|
1248
1167
|
runShellCapture(
|
|
1249
1168
|
ctx,
|
|
1250
|
-
|
|
1169
|
+
captureRequest,
|
|
1251
1170
|
env,
|
|
1252
|
-
|
|
1171
|
+
captureUrl,
|
|
1253
1172
|
reqCtx,
|
|
1254
1173
|
resolvedSsrModule,
|
|
1255
1174
|
descriptor,
|
|
@@ -1260,11 +1179,13 @@ export function scheduleShellCapture(
|
|
|
1260
1179
|
// Update the negative cache off the terminal outcome. A stored shell clears
|
|
1261
1180
|
// any prior backoff; a `no-shell` (after the in-place retry) backs the key
|
|
1262
1181
|
// off so the next requests don't re-probe it. A `redirect` has no shell but
|
|
1263
|
-
// is not a doomed render
|
|
1264
|
-
// the same race — leave the backoff untouched.
|
|
1182
|
+
// is not a doomed render — leave the backoff untouched.
|
|
1265
1183
|
if (outcome === "stored") {
|
|
1266
1184
|
clearCaptureBackoff(key);
|
|
1267
|
-
|
|
1185
|
+
// Only a document entry is the shell a document MISS read the store
|
|
1186
|
+
// for: document serving reads a navigation-only entry as a MISS, even
|
|
1187
|
+
// one a corrupt-snapshot heal stored under the document key.
|
|
1188
|
+
if (!descriptor.navigationOnly) noteCaptureStored(key);
|
|
1268
1189
|
} else if (outcome === "no-shell") {
|
|
1269
1190
|
markCaptureBackoff(key);
|
|
1270
1191
|
publishCaptureDebugEvent(descriptor, {
|
|
@@ -1408,9 +1329,6 @@ export function capShellWindow(
|
|
|
1408
1329
|
return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
|
|
1409
1330
|
}
|
|
1410
1331
|
|
|
1411
|
-
/** Routes already warned that their cache() entry expires before a capture. */
|
|
1412
|
-
const warnedExpiredRecordRoutes = new Set<string>();
|
|
1413
|
-
|
|
1414
1332
|
/**
|
|
1415
1333
|
* Warn once per route (dev and production) that no shell was stored because
|
|
1416
1334
|
* the route cache() entry ran out before the capture could store one
|
|
@@ -1424,10 +1342,11 @@ function warnRecordRanOutOnce(
|
|
|
1424
1342
|
record: RouteRecordWindow,
|
|
1425
1343
|
detail: string,
|
|
1426
1344
|
): void {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1345
|
+
warnOnce(
|
|
1346
|
+
"record-ran-out",
|
|
1347
|
+
route,
|
|
1348
|
+
() =>
|
|
1349
|
+
`[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
|
|
1431
1350
|
`${record.swr}) ran out before the shell capture could store a shell ` +
|
|
1432
1351
|
`(${detail}), so no shell is stored and the route renders without ` +
|
|
1433
1352
|
"one. A shell never outlives the route cache() entry it replays: give " +
|
|
@@ -1437,22 +1356,14 @@ function warnRecordRanOutOnce(
|
|
|
1437
1356
|
}
|
|
1438
1357
|
|
|
1439
1358
|
/**
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
* within one capture task.
|
|
1443
|
-
*/
|
|
1444
|
-
const expiredRecords = new Map<
|
|
1445
|
-
string,
|
|
1446
|
-
{ route: string; record: RouteRecordWindow; captureMs: number }
|
|
1447
|
-
>();
|
|
1448
|
-
|
|
1449
|
-
/**
|
|
1450
|
-
* A terminal `expired`: warn once per route with the record it ran out on,
|
|
1451
|
-
* and return `no-shell` so the caller backs the key off.
|
|
1359
|
+
* A terminal `expired`: warn once per route with the record the attempt ran
|
|
1360
|
+
* out on, and return `no-shell` so the caller backs the key off.
|
|
1452
1361
|
*/
|
|
1453
|
-
function expiredTerminal(
|
|
1454
|
-
|
|
1455
|
-
|
|
1362
|
+
function expiredTerminal(
|
|
1363
|
+
attempt: CaptureAttemptStats,
|
|
1364
|
+
reason: string,
|
|
1365
|
+
): "no-shell" {
|
|
1366
|
+
const expired = attempt.expired;
|
|
1456
1367
|
if (expired) {
|
|
1457
1368
|
warnRecordRanOutOnce(
|
|
1458
1369
|
expired.route,
|
|
@@ -1472,11 +1383,11 @@ function expiredTerminal(key: string, reason: string): "no-shell" {
|
|
|
1472
1383
|
* - `no-shell`: captureShellHTML returned null because the prelude was unusable
|
|
1473
1384
|
* or its private capture abort landed before the shell completed, or the
|
|
1474
1385
|
* capture ran out of ppr.captureTimeout. Retried once in place, when no
|
|
1475
|
-
* deadline ran out (
|
|
1386
|
+
* deadline ran out (CaptureAttemptStats.noShellCause).
|
|
1476
1387
|
* - `expired`: the route cache() record the capture read (not wrote) ran out
|
|
1477
1388
|
* before the store (capShellWindow null): it was near its end (its age,
|
|
1478
1389
|
* queue wait). Retried once in place, where a fresh match reads or renders
|
|
1479
|
-
* a newer record;
|
|
1390
|
+
* a newer record; an `expired` retry, or no hard-cap budget left for the
|
|
1480
1391
|
* retry, is terminal `no-shell` (backoff, warned once per route).
|
|
1481
1392
|
*/
|
|
1482
1393
|
type CaptureAttemptOutcome =
|
|
@@ -1486,8 +1397,14 @@ type CaptureAttemptOutcome =
|
|
|
1486
1397
|
| "refused"
|
|
1487
1398
|
| "expired";
|
|
1488
1399
|
|
|
1400
|
+
/**
|
|
1401
|
+
* What a capture task ends with (runShellCapture): a refusal and a terminal
|
|
1402
|
+
* `expired` are `no-shell`, which the scheduler backs off.
|
|
1403
|
+
*/
|
|
1404
|
+
type CaptureTaskOutcome = "stored" | "redirect" | "no-shell";
|
|
1405
|
+
|
|
1489
1406
|
function createNavigationCaptureRequest(request: Request, url: URL): Request {
|
|
1490
|
-
const headers = new Headers(request
|
|
1407
|
+
const headers = new Headers(requestHeaders(request));
|
|
1491
1408
|
headers.set("accept", "text/html");
|
|
1492
1409
|
for (const name of [
|
|
1493
1410
|
"rsc-action",
|
|
@@ -1503,11 +1420,12 @@ function createNavigationCaptureRequest(request: Request, url: URL): Request {
|
|
|
1503
1420
|
}
|
|
1504
1421
|
|
|
1505
1422
|
/**
|
|
1506
|
-
*
|
|
1507
|
-
* attemptCapture, the rest in captureAndStoreShell)
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1423
|
+
* One attempt's record, filled along the capture path (barrier and deadline
|
|
1424
|
+
* causes in attemptCapture, the rest in captureAndStoreShell). runShellCapture
|
|
1425
|
+
* folds the observability fields into the attempt's
|
|
1426
|
+
* {@link ShellCaptureDebugEvent} and reads the rest to decide the retry and
|
|
1427
|
+
* the warning. A plain mutable bag, not a return value: captureAndStoreShell's
|
|
1428
|
+
* outcome type stays a string union producer B consumes unchanged.
|
|
1511
1429
|
*/
|
|
1512
1430
|
type CaptureAttemptStats = Pick<
|
|
1513
1431
|
ShellCaptureDebugEvent,
|
|
@@ -1518,10 +1436,24 @@ type CaptureAttemptStats = Pick<
|
|
|
1518
1436
|
| "snapshotSkipped"
|
|
1519
1437
|
| "untaggedBake"
|
|
1520
1438
|
| "storeWrite"
|
|
1521
|
-
| "bakeWaitMs"
|
|
1522
1439
|
| "recordSettleMs"
|
|
1523
1440
|
| "entryBytes"
|
|
1524
|
-
|
|
1441
|
+
> & {
|
|
1442
|
+
/**
|
|
1443
|
+
* Why a no-shell attempt ran out of ppr.captureTimeout (the match, the
|
|
1444
|
+
* handler output, or the prerender after them). A set cause skips the
|
|
1445
|
+
* in-place retry: the attempt's match may still be running.
|
|
1446
|
+
*/
|
|
1447
|
+
noShellCause?: string;
|
|
1448
|
+
/**
|
|
1449
|
+
* Dev only: component stacks of the tasks still pending at a no-shell
|
|
1450
|
+
* attempt's abort, reported through prerender's onError (captureShellHTML's
|
|
1451
|
+
* onAbortedTask).
|
|
1452
|
+
*/
|
|
1453
|
+
pendingStacks?: string[];
|
|
1454
|
+
/** The route cache() record an `expired` attempt ran out on. */
|
|
1455
|
+
expired?: { route: string; record: RouteRecordWindow; captureMs: number };
|
|
1456
|
+
};
|
|
1525
1457
|
|
|
1526
1458
|
/**
|
|
1527
1459
|
* Run the shell capture with a single in-place retry, then store the result.
|
|
@@ -1535,12 +1467,14 @@ type CaptureAttemptStats = Pick<
|
|
|
1535
1467
|
* attempt a short beat later usually completes the shell in the SAME background
|
|
1536
1468
|
* task. That kills the old multi-request warmup where the caller had to re-issue
|
|
1537
1469
|
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
|
|
1538
|
-
* that did not run out of ppr.captureTimeout (
|
|
1539
|
-
* error is NOT retried — it propagates to
|
|
1540
|
-
* reportCacheError. See docs/design/ppr-shell-resume.md.
|
|
1470
|
+
* that did not run out of ppr.captureTimeout (CaptureAttemptStats.noShellCause),
|
|
1471
|
+
* and on `expired`; a genuine render error is NOT retried — it propagates to
|
|
1472
|
+
* scheduleShellCapture's reportCacheError. See docs/design/ppr-shell-resume.md.
|
|
1541
1473
|
*
|
|
1542
|
-
* `
|
|
1543
|
-
*
|
|
1474
|
+
* `request` and `url` are the capture's own identity (scheduleShellCapture
|
|
1475
|
+
* strips a navigation-only capture's transport parameters). `retryDelayMs` is
|
|
1476
|
+
* a parameter (defaulting to the module const) so unit tests can drive the
|
|
1477
|
+
* retry without a real 400ms wall-clock wait.
|
|
1544
1478
|
*/
|
|
1545
1479
|
async function runShellCapture(
|
|
1546
1480
|
ctx: HandlerContext<any>,
|
|
@@ -1551,14 +1485,8 @@ async function runShellCapture(
|
|
|
1551
1485
|
ssrModule: SSRModule,
|
|
1552
1486
|
descriptor: ShellCaptureDescriptor,
|
|
1553
1487
|
retryDelayMs: number = SHELL_CAPTURE_RETRY_DELAY_MS,
|
|
1554
|
-
): Promise<
|
|
1555
|
-
const
|
|
1556
|
-
const captureRequest = descriptor.navigationOnly
|
|
1557
|
-
? createNavigationCaptureRequest(request, captureUrl)
|
|
1558
|
-
: request;
|
|
1559
|
-
const log = descriptor.debug
|
|
1560
|
-
? (message: string) => console.log(message)
|
|
1561
|
-
: () => {};
|
|
1488
|
+
): Promise<CaptureTaskOutcome> {
|
|
1489
|
+
const key = descriptor.key;
|
|
1562
1490
|
|
|
1563
1491
|
// One attempt + its structured debug event: the stats object rides through
|
|
1564
1492
|
// attemptCapture/captureAndStoreShell collecting the observability fields
|
|
@@ -1567,27 +1495,31 @@ async function runShellCapture(
|
|
|
1567
1495
|
// event — scheduleShellCapture's catch publishes the terminal `error` event.
|
|
1568
1496
|
const timedAttempt = async (
|
|
1569
1497
|
attempt: number,
|
|
1570
|
-
): Promise<
|
|
1498
|
+
): Promise<{
|
|
1499
|
+
outcome: CaptureAttemptOutcome;
|
|
1500
|
+
stats: CaptureAttemptStats;
|
|
1501
|
+
}> => {
|
|
1571
1502
|
const stats: CaptureAttemptStats = {};
|
|
1572
1503
|
const start = performance.now();
|
|
1573
1504
|
const outcome = await attemptCapture(
|
|
1574
1505
|
ctx,
|
|
1575
|
-
|
|
1506
|
+
request,
|
|
1576
1507
|
env,
|
|
1577
|
-
|
|
1508
|
+
url,
|
|
1578
1509
|
reqCtx,
|
|
1579
1510
|
ssrModule,
|
|
1580
1511
|
descriptor,
|
|
1581
1512
|
stats,
|
|
1582
1513
|
);
|
|
1514
|
+
const { noShellCause, pendingStacks, expired, ...eventFields } = stats;
|
|
1583
1515
|
publishCaptureDebugEvent(descriptor, {
|
|
1584
|
-
key
|
|
1516
|
+
key,
|
|
1585
1517
|
outcome,
|
|
1586
1518
|
attempt,
|
|
1587
1519
|
attemptMs: Math.round(performance.now() - start),
|
|
1588
|
-
...
|
|
1520
|
+
...eventFields,
|
|
1589
1521
|
});
|
|
1590
|
-
return outcome;
|
|
1522
|
+
return { outcome, stats };
|
|
1591
1523
|
};
|
|
1592
1524
|
|
|
1593
1525
|
const taskStartedAt = performance.now();
|
|
@@ -1595,9 +1527,11 @@ async function runShellCapture(
|
|
|
1595
1527
|
// "refused" is deterministic (identity guard / rejected bake-lane loader —
|
|
1596
1528
|
// its own warning already fired): no retry, and the caller backs the key off
|
|
1597
1529
|
// exactly like a structural no-shell.
|
|
1598
|
-
if (first === "refused") return "no-shell";
|
|
1599
|
-
|
|
1600
|
-
|
|
1530
|
+
if (first.outcome === "refused") return "no-shell";
|
|
1531
|
+
if (first.outcome === "stored" || first.outcome === "redirect") {
|
|
1532
|
+
return first.outcome;
|
|
1533
|
+
}
|
|
1534
|
+
if (first.outcome === "expired") {
|
|
1601
1535
|
// The record attempt 1 read ran out mid-capture: a fresh match reads or
|
|
1602
1536
|
// renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
1603
1537
|
// so a retry that could not finish inside it (barrier + captureTimeout)
|
|
@@ -1608,42 +1542,35 @@ async function runShellCapture(
|
|
|
1608
1542
|
SHELL_CAPTURE_WRITE_BARRIER_MS +
|
|
1609
1543
|
(descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1610
1544
|
if (retryBudgetMs < retryNeedsMs) {
|
|
1611
|
-
return expiredTerminal(
|
|
1545
|
+
return expiredTerminal(first.stats, "no time was left to retry");
|
|
1612
1546
|
}
|
|
1613
|
-
log(
|
|
1614
|
-
`[ShellCache] capture attempt 1/2 for ${descriptor.key} outlived the route cache() record it read — retrying`,
|
|
1615
|
-
);
|
|
1616
|
-
second = await timedAttempt(2);
|
|
1617
|
-
if (second === "expired") {
|
|
1618
|
-
return expiredTerminal(descriptor.key, "twice in a row");
|
|
1619
|
-
}
|
|
1620
|
-
expiredRecords.delete(descriptor.key);
|
|
1621
1547
|
} else {
|
|
1622
|
-
// "stored" (success) or "redirect" (no shell exists): nothing to retry.
|
|
1623
|
-
if (first !== "no-shell") return first;
|
|
1624
1548
|
// Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
|
|
1625
1549
|
// handlers may still be running (a match is not cancellable), and a retry
|
|
1626
1550
|
// would start the same work beside them. A cold-module abort, the retry's
|
|
1627
1551
|
// reason, ends well inside the deadline.
|
|
1628
|
-
if (
|
|
1629
|
-
warnNullCaptureOnce(
|
|
1552
|
+
if (first.stats.noShellCause !== undefined) {
|
|
1553
|
+
warnNullCaptureOnce(key, false, first.stats);
|
|
1630
1554
|
return "no-shell";
|
|
1631
1555
|
}
|
|
1632
|
-
|
|
1633
1556
|
// Attempt 1 produced no usable shell. Retry ONCE in place — the first
|
|
1634
1557
|
// attempt warmed the dev transform graph / cold worker, so attempt 2
|
|
1635
|
-
// typically completes the shell without another HTTP request.
|
|
1636
|
-
// line is gated on the middleware's debug flag (threaded via the
|
|
1637
|
-
// descriptor) so it replaces the old full DOMException dump with one
|
|
1638
|
-
// readable breadcrumb.
|
|
1639
|
-
log(
|
|
1640
|
-
`[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
|
|
1641
|
-
);
|
|
1558
|
+
// typically completes the shell without another HTTP request.
|
|
1642
1559
|
await delay(retryDelayMs);
|
|
1643
|
-
second = await timedAttempt(2);
|
|
1644
1560
|
}
|
|
1645
|
-
|
|
1646
|
-
|
|
1561
|
+
|
|
1562
|
+
// The retry is the last attempt, so none of its outcomes is retried: an
|
|
1563
|
+
// `expired` retry is terminal like a no-shell one, not handed back to the
|
|
1564
|
+
// scheduler (which would neither back the key off nor warn).
|
|
1565
|
+
const second = await timedAttempt(2);
|
|
1566
|
+
if (second.outcome === "expired") {
|
|
1567
|
+
return expiredTerminal(
|
|
1568
|
+
second.stats,
|
|
1569
|
+
first.outcome === "expired" ? "twice in a row" : "on the in-place retry",
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
if (second.outcome === "refused") return "no-shell";
|
|
1573
|
+
if (second.outcome !== "no-shell") return second.outcome;
|
|
1647
1574
|
|
|
1648
1575
|
// Both attempts came back with no usable shell. Cold-start would have healed by
|
|
1649
1576
|
// now, so the eternal-MISS structural shape (a boundary-less live-loader read;
|
|
@@ -1651,31 +1578,10 @@ async function runShellCapture(
|
|
|
1651
1578
|
// warn once per key. Ordering matters: because the retry
|
|
1652
1579
|
// absorbs cold-start, cold-start routes almost never reach this warning. The
|
|
1653
1580
|
// caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
|
|
1654
|
-
|
|
1655
|
-
`[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
|
|
1656
|
-
);
|
|
1657
|
-
warnNullCaptureOnce(descriptor.key, true);
|
|
1581
|
+
warnNullCaptureOnce(key, true, second.stats);
|
|
1658
1582
|
return "no-shell";
|
|
1659
1583
|
}
|
|
1660
1584
|
|
|
1661
|
-
/**
|
|
1662
|
-
* Who reads the snapshot this capture stores besides its doc record
|
|
1663
|
-
* (docs/design/shell-entry-layout.md §2): partial replay alone for a
|
|
1664
|
-
* navigation-only entry ("segments"), the loaders a document HIT re-runs
|
|
1665
|
-
* otherwise ("loaders"). Undefined when the capture recorded no doc record,
|
|
1666
|
-
* which only a prerender-served capture stores (its HIT tail takes the handler
|
|
1667
|
-
* layer from the prerender store and reads nothing else from the snapshot
|
|
1668
|
-
* beyond the loader records, so nothing is pruned).
|
|
1669
|
-
*/
|
|
1670
|
-
function snapshotReaders(
|
|
1671
|
-
capture: ShellCaptureDescriptor,
|
|
1672
|
-
snapshot: readonly ShellSnapshotRecord[],
|
|
1673
|
-
docKey: string | undefined,
|
|
1674
|
-
): ShellSnapshotReaders | undefined {
|
|
1675
|
-
if (!hasDocRecord(snapshot, docKey)) return undefined;
|
|
1676
|
-
return capture.navigationOnly ? "segments" : "loaders";
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
1585
|
/**
|
|
1680
1586
|
* What the record-first step produced ({@link settleCaptureRecord}):
|
|
1681
1587
|
* - `record`: the doc record settled; `match` carries its fragments in place
|
|
@@ -1728,10 +1634,14 @@ export async function settleCaptureRecord(
|
|
|
1728
1634
|
const { derivedCtx, freshHandleStore } = derivation;
|
|
1729
1635
|
const recording = getRecordingStore(derivedCtx._cacheStore);
|
|
1730
1636
|
|
|
1731
|
-
//
|
|
1637
|
+
// Seal so handleStore.settled resolves once the tracked handlers settle:
|
|
1638
|
+
// cacheRoute waits for it, and so does the capture's quiesce. It gates only
|
|
1639
|
+
// on tracked handlers, not on deferred handle values
|
|
1640
|
+
// (ctx.use(Handle).defer()), so a defer whose resolver depends on a masked
|
|
1641
|
+
// loader never settles and the wait below runs into the deadline instead.
|
|
1732
1642
|
freshHandleStore.seal();
|
|
1733
1643
|
const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
|
|
1734
|
-
const settled = await
|
|
1644
|
+
const settled = await raceDeadline(
|
|
1735
1645
|
Promise.all([
|
|
1736
1646
|
freshHandleStore.getData().then(resolveDeferredHandleValues),
|
|
1737
1647
|
derivation.handlerPushesSettled(),
|
|
@@ -1770,9 +1680,7 @@ export async function settleCaptureRecord(
|
|
|
1770
1680
|
|
|
1771
1681
|
const docKey = derivedCtx._shellImplicitCache?.docKey;
|
|
1772
1682
|
const record =
|
|
1773
|
-
recording && docKey !== undefined
|
|
1774
|
-
? (recording.getRecord("segment", docKey) as CachedEntryData | undefined)
|
|
1775
|
-
: undefined;
|
|
1683
|
+
recording && docKey !== undefined ? recording.getRecord(docKey) : undefined;
|
|
1776
1684
|
if (
|
|
1777
1685
|
!record ||
|
|
1778
1686
|
!Array.isArray(record.segments) ||
|
|
@@ -1858,7 +1766,6 @@ function recordSegmentsHaveHandles(
|
|
|
1858
1766
|
* - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
|
|
1859
1767
|
* non-loader tags only, since loaders are masked — which is exactly the tag
|
|
1860
1768
|
* set a shell entry should be invalidatable by (loader tags belong to holes).
|
|
1861
|
-
* - _transitionWhen: a fresh [] so the capture's transition gating is its own.
|
|
1862
1769
|
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
1863
1770
|
* - _dynamic / dynamic(): an own latch the capture reads.
|
|
1864
1771
|
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
@@ -1908,25 +1815,26 @@ async function attemptCapture(
|
|
|
1908
1815
|
descriptor.navigationOnly ? { request, url } : undefined,
|
|
1909
1816
|
);
|
|
1910
1817
|
const { derivedCtx, freshHandleStore } = derivation;
|
|
1818
|
+
// The capture generation starts before matching or any snapshot read
|
|
1819
|
+
// (ShellCacheEntry.createdAt): a tag invalidated after it wins.
|
|
1911
1820
|
const captureStartedAt = Date.now();
|
|
1912
1821
|
|
|
1913
1822
|
return runWithRequestContext(derivedCtx, async () => {
|
|
1914
1823
|
// One deadline for the whole capture (see
|
|
1915
1824
|
// PartialPrerenderProps.captureTimeout): the match (handlers, and the
|
|
1916
1825
|
// loaders they await), then the record, then the prerender with what is
|
|
1917
|
-
// left.
|
|
1826
|
+
// left. A match that loses it keeps running (no abort signal), so the
|
|
1827
|
+
// attempt records a cause and runShellCapture does not retry it.
|
|
1918
1828
|
const deadline =
|
|
1919
1829
|
Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1920
|
-
const matched = await
|
|
1830
|
+
const matched = await raceDeadline(
|
|
1921
1831
|
ctx.router.match(request, { env }),
|
|
1922
1832
|
deadline,
|
|
1923
1833
|
);
|
|
1924
1834
|
if (!matched.done) {
|
|
1925
|
-
|
|
1926
|
-
descriptor.key,
|
|
1835
|
+
stats.noShellCause =
|
|
1927
1836
|
"the handlers (or a loader a handler awaits) did not return within " +
|
|
1928
|
-
|
|
1929
|
-
);
|
|
1837
|
+
"ppr.captureTimeout";
|
|
1930
1838
|
return "no-shell";
|
|
1931
1839
|
}
|
|
1932
1840
|
const match = matched.value;
|
|
@@ -1957,7 +1865,7 @@ async function attemptCapture(
|
|
|
1957
1865
|
}
|
|
1958
1866
|
if (settled.kind === "refused") return "refused";
|
|
1959
1867
|
if (settled.kind === "timeout") {
|
|
1960
|
-
|
|
1868
|
+
stats.noShellCause = settled.reason;
|
|
1961
1869
|
return "no-shell";
|
|
1962
1870
|
}
|
|
1963
1871
|
|
|
@@ -1989,55 +1897,23 @@ async function attemptCapture(
|
|
|
1989
1897
|
const outcome = await captureAndStoreShell(
|
|
1990
1898
|
ssrModule,
|
|
1991
1899
|
flightStage.stream,
|
|
1992
|
-
freshHandleStore,
|
|
1993
1900
|
derivedCtx,
|
|
1994
1901
|
{
|
|
1995
1902
|
...descriptor,
|
|
1996
1903
|
captureTimeout: Math.max(1, deadline - Date.now()),
|
|
1997
1904
|
},
|
|
1998
|
-
stats,
|
|
1999
1905
|
captureStartedAt,
|
|
1906
|
+
stats,
|
|
2000
1907
|
);
|
|
2001
1908
|
if (outcome === "no-shell" && Date.now() >= deadline) {
|
|
2002
|
-
|
|
2003
|
-
descriptor.key,
|
|
1909
|
+
stats.noShellCause =
|
|
2004
1910
|
"the shell render did not finish within what ppr.captureTimeout left " +
|
|
2005
|
-
|
|
2006
|
-
);
|
|
1911
|
+
`after the handler output settled (${stats.recordSettleMs}ms)`;
|
|
2007
1912
|
}
|
|
2008
1913
|
return outcome;
|
|
2009
1914
|
});
|
|
2010
1915
|
}
|
|
2011
1916
|
|
|
2012
|
-
/**
|
|
2013
|
-
* Race `work` against an absolute deadline, keeping its value. The losing
|
|
2014
|
-
* work is not cancelled (a router match has no abort signal); callers that
|
|
2015
|
-
* lose do not start it again (runShellCapture skips its in-place retry).
|
|
2016
|
-
*/
|
|
2017
|
-
export async function withinDeadline<T>(
|
|
2018
|
-
work: Promise<T>,
|
|
2019
|
-
deadline: number,
|
|
2020
|
-
): Promise<{ done: true; value: T } | { done: false }> {
|
|
2021
|
-
// The losing branch keeps running; a late rejection must not surface.
|
|
2022
|
-
work.catch(() => {});
|
|
2023
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2024
|
-
const expired = new Promise<{ done: false }>((resolve) => {
|
|
2025
|
-
timer = setTimeout(
|
|
2026
|
-
() => resolve({ done: false }),
|
|
2027
|
-
Math.max(0, deadline - Date.now()),
|
|
2028
|
-
);
|
|
2029
|
-
(timer as { unref?: () => void }).unref?.();
|
|
2030
|
-
});
|
|
2031
|
-
try {
|
|
2032
|
-
return await Promise.race([
|
|
2033
|
-
work.then((value) => ({ done: true as const, value })),
|
|
2034
|
-
expired,
|
|
2035
|
-
]);
|
|
2036
|
-
} finally {
|
|
2037
|
-
if (timer) clearTimeout(timer);
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
1917
|
/**
|
|
2042
1918
|
* The derived capture context and its fresh (mask-funneled) handle store,
|
|
2043
1919
|
* shared by BOTH shell producers: the runtime background capture
|
|
@@ -2086,7 +1962,7 @@ export function deriveShellCaptureContext(
|
|
|
2086
1962
|
// before the doc record encodes the handles a HIT replays. A route
|
|
2087
1963
|
// cache() record's restore (handle-snapshot.ts restoreHandles) lands
|
|
2088
1964
|
// here too: a value it restores through pushReplayed keeps that loader
|
|
2089
|
-
// as owner, so a HIT
|
|
1965
|
+
// as owner, so a HIT restores it as that loader's (pushRestored).
|
|
2090
1966
|
// - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
|
|
2091
1967
|
// loaders they await via ctx.use execute at capture (live loaders are
|
|
2092
1968
|
// masked), their pushes are in the captured HTML (<head> title/meta,
|
|
@@ -2095,10 +1971,14 @@ export function deriveShellCaptureContext(
|
|
|
2095
1971
|
// settled, thenable-free push from a loader body keeps that loader as
|
|
2096
1972
|
// `owner` (HandleStore.push), the record stores it
|
|
2097
1973
|
// (CachedEntryData.handleOwners), and the HIT restores it through
|
|
2098
|
-
//
|
|
2099
|
-
//
|
|
2100
|
-
//
|
|
2101
|
-
// the
|
|
1974
|
+
// pushRestored. Those copies stand: a promise-carrying bake-lane loader
|
|
1975
|
+
// that runs on the HIT reads the store, so its settled pushes (and those
|
|
1976
|
+
// replayed inside its body) are dropped and only its thenable ones are
|
|
1977
|
+
// added. A loader the route also runs on the live lane is a hole: its
|
|
1978
|
+
// live run replaces its copies (pushReplayed, #936), those of the
|
|
1979
|
+
// dependencies it awaits included (they are credited to it). Nested
|
|
1980
|
+
// thenables stay masked (the promise shape is the liveness
|
|
1981
|
+
// declaration, mask-nested.ts). A deferred (thenable) push,
|
|
2102
1982
|
// one with masked nested promises, or one outside a loader body (a
|
|
2103
1983
|
// loader-cache replay names its loader: pushReplayed below) stays
|
|
2104
1984
|
// tagged (HandleStore.push loaderPush) so the
|
|
@@ -2113,6 +1993,24 @@ export function deriveShellCaptureContext(
|
|
|
2113
1993
|
// loader body: name that loader for the funnel so the record keeps them
|
|
2114
1994
|
// under it too.
|
|
2115
1995
|
let replayOwner: string | undefined;
|
|
1996
|
+
// The live-lane loader whose body a push is made in, at any depth, unless
|
|
1997
|
+
// a bake-lane loader's body is nearer (createMatchContextForFull sets the
|
|
1998
|
+
// lanes before any loader runs).
|
|
1999
|
+
// A replay of a live-lane loader's push (a "use cache" or loader cache()
|
|
2000
|
+
// hit a recapture makes inside a bake-lane body) is that hole's too.
|
|
2001
|
+
const liveLaneOwner = (): string | undefined => {
|
|
2002
|
+
const lanes = derivedCtx._shellCaptureLoaderLanes;
|
|
2003
|
+
if (!lanes) return undefined;
|
|
2004
|
+
return (
|
|
2005
|
+
findEnclosingLoaderBody(
|
|
2006
|
+
(id) => lanes.get(id) === "live",
|
|
2007
|
+
(id) => lanes.get(id) === "bake",
|
|
2008
|
+
) ??
|
|
2009
|
+
(replayOwner !== undefined && lanes.get(replayOwner) === "live"
|
|
2010
|
+
? replayOwner
|
|
2011
|
+
: undefined)
|
|
2012
|
+
);
|
|
2013
|
+
};
|
|
2116
2014
|
const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
|
|
2117
2015
|
freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
|
|
2118
2016
|
replayOwner = loaderId;
|
|
@@ -2154,8 +2052,16 @@ export function deriveShellCaptureContext(
|
|
|
2154
2052
|
// material (see the funnel comment above): only bake-lane loaders
|
|
2155
2053
|
// and the loaders they await run at capture, and a HIT does not run
|
|
2156
2054
|
// a promise-free bake-lane loader (loader-cache.ts), so the record
|
|
2157
|
-
// keeps the push under the loader that made it.
|
|
2158
|
-
|
|
2055
|
+
// keeps the push under the loader that made it. A push under a
|
|
2056
|
+
// live-lane loader's body, at any depth, is that hole's output: it
|
|
2057
|
+
// is credited to the hole, whose live run replaces it on a HIT and
|
|
2058
|
+
// a navigation. The first loader around the push that the route
|
|
2059
|
+
// registers decides: a bake-lane one keeps the push under the
|
|
2060
|
+
// innermost body, and a dependency on neither lane is walked past. A
|
|
2061
|
+
// replay of a hole's push is that hole's, unless a live-lane body
|
|
2062
|
+
// around it takes it first.
|
|
2063
|
+
const bodyLoaderId =
|
|
2064
|
+
liveLaneOwner() ?? getCurrentLoaderBodyId() ?? replayOwner;
|
|
2159
2065
|
loaderPush = bodyLoaderId === undefined;
|
|
2160
2066
|
if (!loaderPush) owner = bodyLoaderId;
|
|
2161
2067
|
}
|
|
@@ -2203,7 +2109,6 @@ export function deriveShellCaptureContext(
|
|
|
2203
2109
|
// the whole capture snapshot in memory. Capture registrations die with this
|
|
2204
2110
|
// context; module-singleton stores stay registered by normal renders.
|
|
2205
2111
|
derivedCtx._explicitTaggedStores = new Set();
|
|
2206
|
-
derivedCtx._transitionWhen = [];
|
|
2207
2112
|
// Own list: the capture's render errors refuse the capture only, and the
|
|
2208
2113
|
// foreground's never reach it.
|
|
2209
2114
|
derivedCtx._renderErrors = [];
|
|
@@ -2228,31 +2133,32 @@ export function deriveShellCaptureContext(
|
|
|
2228
2133
|
// foreground rango.request span ended (orphan spans in the trace).
|
|
2229
2134
|
// _tracing is otherwise inherited through Object.create(reqCtx).
|
|
2230
2135
|
derivedCtx._tracing = undefined;
|
|
2231
|
-
// Bake-lane loader containers
|
|
2232
|
-
//
|
|
2233
|
-
//
|
|
2234
|
-
// captureAndStoreShell elides + pins them into the snapshot's loader
|
|
2136
|
+
// Bake-lane (`ssr: false`) loader containers, which execute during capture
|
|
2137
|
+
// (docs/design/loader-container-bake.md). resolveLoaderData registers each
|
|
2138
|
+
// container promise here; settleCaptureRecord waits for them, and the drain
|
|
2139
|
+
// in captureAndStoreShell elides + pins them into the snapshot's loader
|
|
2140
|
+
// family.
|
|
2235
2141
|
derivedCtx._shellCaptureLoaderRecords = new Map();
|
|
2236
|
-
// Own onResponse list so the capture's match-middleware callbacks (the
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
//
|
|
2240
|
-
// without
|
|
2241
|
-
// prelude but is never written, so it is never recorded and drifts on a HIT.
|
|
2142
|
+
// Own onResponse list so the capture's match-middleware callbacks (the doc
|
|
2143
|
+
// record registers here) are ISOLATED from the foreground's shared array AND
|
|
2144
|
+
// can be fired by settleCaptureRecord. The segment write is gated behind
|
|
2145
|
+
// onResponse, which the capture never triggers (it builds no Response):
|
|
2146
|
+
// without the synthetic fire, no doc record is written.
|
|
2242
2147
|
derivedCtx._onResponseCallbacks = [];
|
|
2243
2148
|
|
|
2244
|
-
// Capture data snapshot:
|
|
2245
|
-
// wrapper on the DERIVED context's store (own property, so the
|
|
2246
|
-
// reqCtx._cacheStore is untouched — the snapshot is per-capture).
|
|
2247
|
-
//
|
|
2248
|
-
//
|
|
2149
|
+
// Capture data snapshot: route the capture's cache-store calls through a
|
|
2150
|
+
// recording wrapper on the DERIVED context's store (own property, so the
|
|
2151
|
+
// shared reqCtx._cacheStore is untouched — the snapshot is per-capture). It
|
|
2152
|
+
// records the doc segment record, which rides inside the ShellCacheEntry
|
|
2153
|
+
// with the bake-lane loader pins; no cache read is recorded. See
|
|
2154
|
+
// cache/shell-snapshot.ts and the design doc.
|
|
2249
2155
|
//
|
|
2250
|
-
// Cache writes are deferred (waitUntil):
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2156
|
+
// Cache writes are deferred (waitUntil): the doc record's write would
|
|
2157
|
+
// otherwise land after the match returns. Override the derived context's
|
|
2158
|
+
// waitUntil to COLLECT those write promises (still forwarding to the parent
|
|
2159
|
+
// so the write persists and the worker stays alive), then
|
|
2160
|
+
// settleCaptureRecord and captureAndStoreShell await them before reading
|
|
2161
|
+
// the record and draining.
|
|
2256
2162
|
if (reqCtx._cacheStore) {
|
|
2257
2163
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
2258
2164
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -2285,13 +2191,17 @@ export function deriveShellCaptureContext(
|
|
|
2285
2191
|
}
|
|
2286
2192
|
|
|
2287
2193
|
/**
|
|
2288
|
-
*
|
|
2289
|
-
* captureShellHTML, and store the result.
|
|
2290
|
-
*
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2293
|
-
*
|
|
2294
|
-
*
|
|
2194
|
+
* Derive the quiesce signal, prerender + abort via the SSR module's
|
|
2195
|
+
* captureShellHTML, and store the result. Runs after settleCaptureRecord (both
|
|
2196
|
+
* producers), which sealed the capture's handle store (`reqCtx._handleStore`)
|
|
2197
|
+
* and waited for everything the shell bakes. Returns the attempt outcome (the
|
|
2198
|
+
* caller owns retry/warn decisions). Never throws out of the store write: a
|
|
2199
|
+
* failed putShell is routed through reportCacheError so the background task
|
|
2200
|
+
* stays best-effort, and the attempt still counts as `stored` (the capture
|
|
2201
|
+
* worked; only the store I/O failed). `ssrModule.captureShellHTML` MUST be
|
|
2202
|
+
* present (eligibility is checked before scheduling). `captureStartedAt` is the
|
|
2203
|
+
* capture generation's start, stamped before the match (ShellCacheEntry
|
|
2204
|
+
* .createdAt).
|
|
2295
2205
|
*
|
|
2296
2206
|
* A `no-shell` result from captureShellHTML is the only retryable outcome. Every
|
|
2297
2207
|
* captureShellHTML error propagates to reportCacheError and is NOT retried. The
|
|
@@ -2301,87 +2211,17 @@ export function deriveShellCaptureContext(
|
|
|
2301
2211
|
async function captureAndStoreShell(
|
|
2302
2212
|
ssrModule: SSRModule,
|
|
2303
2213
|
rscStream: ReadableStream<Uint8Array>,
|
|
2304
|
-
handleStore: HandleStore,
|
|
2305
2214
|
reqCtx: RequestContext<any>,
|
|
2306
2215
|
capture: ShellCaptureDescriptor,
|
|
2307
|
-
|
|
2308
|
-
|
|
2216
|
+
captureStartedAt: number,
|
|
2217
|
+
stats: CaptureAttemptStats = {},
|
|
2309
2218
|
): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
|
|
2310
2219
|
const captureShellHTML = ssrModule.captureShellHTML!;
|
|
2311
2220
|
|
|
2312
|
-
|
|
2313
|
-
// -> handleStore.stream()) converges and completes even though masked loaders
|
|
2314
|
-
// never resolve. handleStore.settled gates ONLY on tracked HANDLER promises
|
|
2315
|
-
// (handleStore.track, via trackHandler) — NOT on deferred handle VALUES pushed
|
|
2316
|
-
// through ctx.use(Handle).defer(), which are plain pushed promises. So seal()
|
|
2317
|
-
// does not reject or hang on outstanding defers: settled resolves once the
|
|
2318
|
-
// handlers settle, and each deferred slot resolves on its own createDeferred
|
|
2319
|
-
// timeout (defer.ts, default 10s) or when its resolver fires. A defer whose
|
|
2320
|
-
// resolver depends on a masked loader can never fire, so it stays pending until
|
|
2321
|
-
// that 10s timeout — longer than maxWaitMs (5s). At the abort the handles
|
|
2322
|
-
// generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
|
|
2323
|
-
// sits above every boundary), the prelude comes back trivial, and
|
|
2324
|
-
// captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
|
|
2325
|
-
// an error. This mirrors the matchForPrerender (router/prerender-match.ts)
|
|
2326
|
-
// seal+settled regime, which also excludes loaders. See
|
|
2327
|
-
// docs/design/ppr-shell-resume.md ("Loaders and handles").
|
|
2328
|
-
handleStore.seal();
|
|
2329
|
-
|
|
2330
|
-
// TOP-LEVEL pushed handle promises are BAKED into the shell —
|
|
2331
|
-
// resolvedHandleStream awaits them before the payload's handles row emits. A
|
|
2332
|
-
// pushed promise with real latency would lose the byte-quiet race (the
|
|
2333
|
-
// pending handles row emits no bytes, the gate freezes, the row is dropped,
|
|
2334
|
-
// SsrRoot suspends at the root), so the gate is HELD open until the same
|
|
2335
|
-
// await completes: handlesBaked mirrors resolvedHandleStream's resolution
|
|
2336
|
-
// (getData waits the tracked-handler barrier; resolveDeferredHandleValues
|
|
2337
|
-
// awaits the top-level thenables). On the runtime path settleCaptureRecord
|
|
2338
|
-
// already waited for these (and for handler pushes' nested promises), so the
|
|
2339
|
-
// hold is normally released at once. A DSL loader push's nested promises are
|
|
2340
|
-
// masked and never hold the gate — they stay holes. Bounded by maxWaitMs like
|
|
2341
|
-
// every quiesce input (a defer hanging on a masked loader still ends in the
|
|
2342
|
-
// sanity-gate refusal).
|
|
2343
|
-
const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
|
|
2344
|
-
// Bake-lane loader containers hold the gate the same way (loader-container-
|
|
2345
|
-
// bake): a boundary-less container with real latency (a 100ms layout loader)
|
|
2346
|
-
// would otherwise lose the 2-hop byte-quiet race — the pending loaderData row
|
|
2347
|
-
// emits no bytes, the gate freezes, and the awaiting tree pins above <body>.
|
|
2348
|
-
// The records map is fully populated before this point (loader promises are
|
|
2349
|
-
// created during the capture's match()), so the hold covers every bake-lane
|
|
2350
|
-
// container. allSettled: a REJECTED container releases the hold (the drain
|
|
2351
|
-
// below refuses the capture); nested promises INSIDE a container never hold
|
|
2352
|
-
// the gate — they stay holes. Bounded by maxWaitMs like every quiesce input.
|
|
2353
|
-
const loaderRecordsForHold = reqCtx._shellCaptureLoaderRecords;
|
|
2354
|
-
const holdUntil =
|
|
2355
|
-
loaderRecordsForHold && loaderRecordsForHold.size > 0
|
|
2356
|
-
? Promise.allSettled([handlesBaked, ...loaderRecordsForHold.values()])
|
|
2357
|
-
: handlesBaked;
|
|
2358
|
-
// Bake-cost attribution: record how long each bake source held the gate,
|
|
2359
|
-
// measured from capture start. Side-channel observers only — holdUntil and
|
|
2360
|
-
// its consumers are untouched, and a rejected source is recorded the same
|
|
2361
|
-
// (allSettled swallows it; the drain below owns the refusal). Folded into
|
|
2362
|
-
// stats.bakeWaitMs (+ a once-per-key dev warning) after a successful
|
|
2363
|
-
// capture; on a refusal the other warnings own the story.
|
|
2364
|
-
const bakeStart = performance.now();
|
|
2365
|
-
const bakeWaits: { source: string; ms: number }[] = [];
|
|
2366
|
-
const observeBake = (source: string, promise: Promise<unknown>): void => {
|
|
2367
|
-
const record = (): void => {
|
|
2368
|
-
bakeWaits.push({
|
|
2369
|
-
source,
|
|
2370
|
-
ms: Math.round(performance.now() - bakeStart),
|
|
2371
|
-
});
|
|
2372
|
-
};
|
|
2373
|
-
promise.then(record, record);
|
|
2374
|
-
};
|
|
2375
|
-
observeBake("top-level pushed handle promises", handlesBaked);
|
|
2376
|
-
if (loaderRecordsForHold) {
|
|
2377
|
-
for (const [loaderId, promise] of loaderRecordsForHold) {
|
|
2378
|
-
observeBake(`bake-lane segment loader "${loaderId}"`, promise);
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
const gate = gateFlightForCapture(rscStream, undefined, holdUntil);
|
|
2221
|
+
const gate = gateFlightForCapture(rscStream);
|
|
2382
2222
|
// Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
|
|
2383
2223
|
// half stalling is bounded by captureShellHTML's maxWaitMs.
|
|
2384
|
-
const quiesce = Promise.all([
|
|
2224
|
+
const quiesce = Promise.all([reqCtx._handleStore.settled, gate.quiesce]).then(
|
|
2385
2225
|
() => {},
|
|
2386
2226
|
);
|
|
2387
2227
|
|
|
@@ -2400,7 +2240,6 @@ async function captureAndStoreShell(
|
|
|
2400
2240
|
| ((errorInfo: { componentStack?: string } | undefined) => void)
|
|
2401
2241
|
| undefined;
|
|
2402
2242
|
if (process.env.NODE_ENV !== "production") {
|
|
2403
|
-
noShellPendingStacks.delete(capture.key);
|
|
2404
2243
|
const stacks: string[] = [];
|
|
2405
2244
|
pendingStacks = stacks;
|
|
2406
2245
|
onAbortedTask = (errorInfo) => {
|
|
@@ -2457,72 +2296,24 @@ async function captureAndStoreShell(
|
|
|
2457
2296
|
if (refused) return refused;
|
|
2458
2297
|
|
|
2459
2298
|
if (result === null) {
|
|
2460
|
-
|
|
2299
|
+
stats.pendingStacks = pendingStacks;
|
|
2461
2300
|
return "no-shell";
|
|
2462
2301
|
}
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
//
|
|
2466
|
-
//
|
|
2467
|
-
//
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
}
|
|
2472
|
-
if (slowestBake && slowestBake.ms > 0) {
|
|
2473
|
-
if (stats) stats.bakeWaitMs = slowestBake.ms;
|
|
2474
|
-
if (
|
|
2475
|
-
process.env.NODE_ENV !== "production" &&
|
|
2476
|
-
slowestBake.ms >= SHELL_CAPTURE_BAKE_WARN_MS
|
|
2477
|
-
) {
|
|
2478
|
-
warnBakeCostOnce(capture.key, slowestBake.source, slowestBake.ms);
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
|
|
2482
|
-
// Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
|
|
2483
|
-
// threads the SAME store it resolved for its getShell read (options.store ??
|
|
2484
|
-
// _cacheStore), so a store-attached middleware writes captures where it reads
|
|
2485
|
-
// them. The _cacheStore fallback covers a flag armed without a store (tests).
|
|
2486
|
-
// reactVersion is read from the same React.version import the middleware
|
|
2487
|
-
// validates reads against, so capture and serve always agree.
|
|
2488
|
-
// Fire the capture's isolated onResponse callbacks with a synthetic 200 so
|
|
2489
|
-
// the ring-3 segment cache write (cacheScope.cacheRoute, registered via
|
|
2490
|
-
// onResponse by the cache-store match-middleware and gated on a 200) runs
|
|
2491
|
-
// DURING capture, routed through the recording store. The foreground path
|
|
2492
|
-
// never fires for the capture — it builds no Response — so without this a
|
|
2493
|
-
// cache() SEGMENT that MISSED at capture would be rendered fresh into the
|
|
2494
|
-
// prelude yet never written, hence never recorded, and would drift on a HIT
|
|
2495
|
-
// (an item-family "use cache" write already runs inline during the render, so
|
|
2496
|
-
// it needs none of this; only segment writes are onResponse-gated). The
|
|
2497
|
-
// derived context's own _onResponseCallbacks holds only capture match-
|
|
2498
|
-
// middleware callbacks (HTTP middleware never runs for a capture), so firing
|
|
2499
|
-
// them is safe. Best-effort: a throwing callback must not fail the capture.
|
|
2500
|
-
const responseCallbacks = reqCtx._onResponseCallbacks;
|
|
2501
|
-
if (responseCallbacks && responseCallbacks.length > 0) {
|
|
2502
|
-
const synthetic = new Response(null, { status: 200 });
|
|
2503
|
-
for (const cb of responseCallbacks) {
|
|
2504
|
-
try {
|
|
2505
|
-
cb(synthetic);
|
|
2506
|
-
} catch {
|
|
2507
|
-
// A capture-time cache write that throws is degradation, not failure.
|
|
2508
|
-
}
|
|
2509
|
-
}
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
// Drain the capture data snapshot from the recording store on the derived
|
|
2513
|
-
// context. Await the deferred cache writes first so a MISS-at-capture value
|
|
2514
|
-
// (setItem/set scheduled under waitUntil, including the segment write just
|
|
2515
|
-
// fired) is pinned, not just read-hits. When no recording store is installed
|
|
2516
|
-
// (unit tests that call this directly), there is simply no snapshot.
|
|
2302
|
+
stats.preludeBytes = result.prelude.length;
|
|
2303
|
+
|
|
2304
|
+
// Drain the doc record from the recording store on the derived context.
|
|
2305
|
+
// The capture's own deferred cache writes ("use cache" and loader
|
|
2306
|
+
// cache() misses made while it rendered) settle first, bounded, so the
|
|
2307
|
+
// next HIT's holes find them in the store. When no recording store is
|
|
2308
|
+
// installed (unit tests that call this directly), the snapshot starts
|
|
2309
|
+
// empty.
|
|
2517
2310
|
const recording = getRecordingStore(reqCtx._cacheStore);
|
|
2518
|
-
let snapshot: ShellSnapshotRecord[]
|
|
2311
|
+
let snapshot: ShellSnapshotRecord[] = [];
|
|
2519
2312
|
if (recording) {
|
|
2520
2313
|
const settleStart = performance.now();
|
|
2521
2314
|
await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
}
|
|
2525
|
-
snapshot = recording.drainSnapshot();
|
|
2315
|
+
stats.writeSettleMs = Math.round(performance.now() - settleStart);
|
|
2316
|
+
snapshot = recording.drainSnapshot() ?? [];
|
|
2526
2317
|
}
|
|
2527
2318
|
|
|
2528
2319
|
// Pin the bake-lane loader containers (loader family). Settled containers
|
|
@@ -2591,7 +2382,7 @@ async function captureAndStoreShell(
|
|
|
2591
2382
|
reqCtx._renderErrors?.push(error);
|
|
2592
2383
|
});
|
|
2593
2384
|
if (serialized !== null) {
|
|
2594
|
-
|
|
2385
|
+
snapshot.push({
|
|
2595
2386
|
family: "loader",
|
|
2596
2387
|
key: segmentKey,
|
|
2597
2388
|
// The hole bit rides with the record so the HIT overlay knows
|
|
@@ -2622,36 +2413,28 @@ async function captureAndStoreShell(
|
|
|
2622
2413
|
const renderErrors = reqCtx._renderErrors;
|
|
2623
2414
|
if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
|
|
2624
2415
|
|
|
2625
|
-
//
|
|
2626
|
-
// the bake-lane
|
|
2627
|
-
// what is stored.
|
|
2416
|
+
// Store only what a HIT reads (issue #941): the doc record and, for a
|
|
2417
|
+
// document entry, the bake-lane loader pins. Before the size guards, so
|
|
2418
|
+
// they measure what is stored.
|
|
2628
2419
|
const docKey = reqCtx._shellImplicitCache?.docKey;
|
|
2629
2420
|
let prunedRecords: string | undefined;
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
);
|
|
2639
|
-
if (pruned.length > 0) {
|
|
2640
|
-
prunedRecords = countSnapshotFamilies(pruned);
|
|
2641
|
-
snapshot = kept.length > 0 ? kept : undefined;
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2421
|
+
const { kept, pruned } = pruneShellSnapshot(
|
|
2422
|
+
snapshot,
|
|
2423
|
+
capture.navigationOnly === true,
|
|
2424
|
+
docKey,
|
|
2425
|
+
);
|
|
2426
|
+
if (pruned.length > 0) {
|
|
2427
|
+
prunedRecords = countSnapshotFamilies(pruned);
|
|
2428
|
+
snapshot = kept;
|
|
2644
2429
|
}
|
|
2645
2430
|
|
|
2646
|
-
// Snapshot size guard (issue #651):
|
|
2647
|
-
//
|
|
2648
|
-
//
|
|
2649
|
-
//
|
|
2650
|
-
// HIT still replays the handler layer (the pins' loaders then read the
|
|
2651
|
-
// live store: documented drift, repaired client-side). The doc record is
|
|
2431
|
+
// Snapshot size guard (issue #651): `maxSnapshotBytes` bounds the loader
|
|
2432
|
+
// pins; over it they are dropped and the entry keeps its doc record, so
|
|
2433
|
+
// every HIT still replays the handler layer (the pins' loaders then run
|
|
2434
|
+
// on the HIT: documented drift, repaired client-side). The doc record is
|
|
2652
2435
|
// exempt: without it a HIT could not serve at all. The whole entry is
|
|
2653
2436
|
// bounded separately below, by the store's value limit.
|
|
2654
|
-
if (snapshot
|
|
2437
|
+
if (snapshot.length > 0) {
|
|
2655
2438
|
const docRecord =
|
|
2656
2439
|
docKey !== undefined
|
|
2657
2440
|
? snapshot.find((r) => r.family === "segment" && r.key === docKey)
|
|
@@ -2663,28 +2446,20 @@ async function captureAndStoreShell(
|
|
|
2663
2446
|
pins.length > 0
|
|
2664
2447
|
? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
|
|
2665
2448
|
: 0;
|
|
2666
|
-
|
|
2449
|
+
stats.snapshotBytes = pinBytes;
|
|
2667
2450
|
const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
|
|
2668
2451
|
if (pinBytes > cap) {
|
|
2669
2452
|
warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
|
|
2670
|
-
snapshot = docRecord ? [docRecord] :
|
|
2671
|
-
|
|
2453
|
+
snapshot = docRecord ? [docRecord] : [];
|
|
2454
|
+
stats.snapshotSkipped = true;
|
|
2672
2455
|
}
|
|
2673
2456
|
}
|
|
2674
2457
|
|
|
2675
|
-
//
|
|
2676
|
-
//
|
|
2677
|
-
//
|
|
2678
|
-
//
|
|
2458
|
+
// settleCaptureRecord refused every capture without a doc record except a
|
|
2459
|
+
// prerender-served one (the prerender store supplies its handler layer on
|
|
2460
|
+
// every HIT), and the record survives the drain, the pruning and the size
|
|
2461
|
+
// cap above.
|
|
2679
2462
|
const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
|
|
2680
|
-
if (
|
|
2681
|
-
storedDocKey === undefined &&
|
|
2682
|
-
!capture.navigationOnly &&
|
|
2683
|
-
reqCtx._pprReplayPostMatchReason !== "prerender-store"
|
|
2684
|
-
) {
|
|
2685
|
-
warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
|
|
2686
|
-
return "refused";
|
|
2687
|
-
}
|
|
2688
2463
|
|
|
2689
2464
|
// Shell tags: what the shell renders from, and nothing else. The handler
|
|
2690
2465
|
// layer is the doc record, whose tags are what its content recorded (the
|
|
@@ -2701,7 +2476,7 @@ async function captureAndStoreShell(
|
|
|
2701
2476
|
// without a doc record (the prerender store served its match) keeps the
|
|
2702
2477
|
// request-level set. Union with the route's static ppr.tags.
|
|
2703
2478
|
const docRecordValue = storedDocKey
|
|
2704
|
-
? (snapshot
|
|
2479
|
+
? (snapshot.find((r) => r.family === "segment" && r.key === storedDocKey)
|
|
2705
2480
|
?.value as CachedEntryData | undefined)
|
|
2706
2481
|
: undefined;
|
|
2707
2482
|
const collected = docRecordValue
|
|
@@ -2726,9 +2501,9 @@ async function captureAndStoreShell(
|
|
|
2726
2501
|
snapshot,
|
|
2727
2502
|
tags: shellTags,
|
|
2728
2503
|
docKey: storedDocKey,
|
|
2729
|
-
prunedRecords: snapshot ? prunedRecords : undefined,
|
|
2504
|
+
prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
|
|
2730
2505
|
});
|
|
2731
|
-
|
|
2506
|
+
stats.entryBytes = entryBytes;
|
|
2732
2507
|
const entryLimit =
|
|
2733
2508
|
(capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
|
|
2734
2509
|
DEFAULT_SHELL_ENTRY_MAX_BYTES;
|
|
@@ -2745,12 +2520,7 @@ async function captureAndStoreShell(
|
|
|
2745
2520
|
|
|
2746
2521
|
// Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
|
|
2747
2522
|
// that choice only to operators who enabled structured capture diagnostics.
|
|
2748
|
-
if (
|
|
2749
|
-
capture.debugSink &&
|
|
2750
|
-
stats &&
|
|
2751
|
-
bakedLoaderMaterial &&
|
|
2752
|
-
shellTags === undefined
|
|
2753
|
-
) {
|
|
2523
|
+
if (capture.debugSink && bakedLoaderMaterial && shellTags === undefined) {
|
|
2754
2524
|
stats.untaggedBake = true;
|
|
2755
2525
|
}
|
|
2756
2526
|
|
|
@@ -2779,7 +2549,7 @@ async function captureAndStoreShell(
|
|
|
2779
2549
|
// matches the frozen prelude — see ShellCacheEntry.initialTheme.
|
|
2780
2550
|
initialTheme: payloadInitialTheme(reqCtx),
|
|
2781
2551
|
snapshot,
|
|
2782
|
-
prunedRecords: snapshot ? prunedRecords : undefined,
|
|
2552
|
+
prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
|
|
2783
2553
|
// The canonical doc segment record's key, published by the doc
|
|
2784
2554
|
// scope's cacheRoute during this capture's match. Every HIT tail
|
|
2785
2555
|
// looks the record up by this key (serveShellHit fixedDocKey);
|
|
@@ -2819,11 +2589,11 @@ async function captureAndStoreShell(
|
|
|
2819
2589
|
);
|
|
2820
2590
|
return "refused";
|
|
2821
2591
|
}
|
|
2822
|
-
|
|
2592
|
+
stats.expired = {
|
|
2823
2593
|
route,
|
|
2824
2594
|
record,
|
|
2825
2595
|
captureMs: Date.now() - captureStartedAt,
|
|
2826
|
-
}
|
|
2596
|
+
};
|
|
2827
2597
|
return "expired";
|
|
2828
2598
|
}
|
|
2829
2599
|
window = capped;
|
|
@@ -2835,7 +2605,7 @@ async function captureAndStoreShell(
|
|
|
2835
2605
|
window.swr,
|
|
2836
2606
|
shellTags,
|
|
2837
2607
|
);
|
|
2838
|
-
if (
|
|
2608
|
+
if (storeWrite) stats.storeWrite = storeWrite;
|
|
2839
2609
|
if (storeWrite === "invalidated") {
|
|
2840
2610
|
warnCaptureRefusedOnce(
|
|
2841
2611
|
capture.key,
|
|
@@ -2908,22 +2678,15 @@ export {
|
|
|
2908
2678
|
|
|
2909
2679
|
/**
|
|
2910
2680
|
* @internal Reset this module's per-isolate state: the capture stampede
|
|
2911
|
-
* guard, the refused-capture backoff, the
|
|
2912
|
-
*
|
|
2913
|
-
*
|
|
2914
|
-
*
|
|
2681
|
+
* guard, the refused-capture backoff, the once-per-key warnings (the shell
|
|
2682
|
+
* path's one registry), the stored-capture sequence and the buffered debug
|
|
2683
|
+
* events. Tests only (testing/serve-shell-request.ts resetShellTestState),
|
|
2684
|
+
* between requests, never while a capture runs.
|
|
2915
2685
|
*/
|
|
2916
2686
|
export function resetShellCaptureStateForTests(): void {
|
|
2917
2687
|
inFlightCaptures.clear();
|
|
2918
2688
|
refusedCaptures.clear();
|
|
2919
|
-
|
|
2920
|
-
noShellCauses.clear();
|
|
2921
|
-
noShellPendingStacks.clear();
|
|
2922
|
-
warnedRefusedCaptures.clear();
|
|
2923
|
-
warnedOverCapSnapshots.clear();
|
|
2924
|
-
warnedBakeCosts.clear();
|
|
2925
|
-
warnedExpiredRecordRoutes.clear();
|
|
2926
|
-
expiredRecords.clear();
|
|
2689
|
+
resetShellWarningsForTests();
|
|
2927
2690
|
storedCaptureSeqs.clear();
|
|
2928
2691
|
lastCaptureEventsForTiming.clear();
|
|
2929
2692
|
}
|