@rangojs/router 0.17.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/README.md +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/event-controller.d.ts +3 -3
- package/dist/types/browser/history-state.d.ts +18 -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 +48 -2
- package/dist/types/cache/cache-policy.d.ts +18 -1
- package/dist/types/cache/cache-runtime.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +192 -26
- package/dist/types/cache/cache-tag.d.ts +116 -11
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +224 -72
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +19 -0
- package/dist/types/cache/document-cache.d.ts +10 -0
- package/dist/types/cache/handle-snapshot.d.ts +41 -8
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/invalidation-order.d.ts +23 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/request-tag-mask.d.ts +54 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +141 -105
- package/dist/types/cache/tag-invalidation.d.ts +54 -3
- package/dist/types/cache/types.d.ts +267 -96
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
- 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/prerender/build-shell-capture.d.ts +11 -4
- 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/instrument.d.ts +8 -0
- 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/middleware-types.d.ts +6 -1
- package/dist/types/router/router-options.d.ts +4 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
- package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
- 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 +21 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/response-cache-serve.d.ts +15 -12
- package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
- package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
- package/dist/types/rsc/shell-capture.d.ts +202 -74
- package/dist/types/rsc/shell-serve.d.ts +144 -50
- package/dist/types/rsc/types.d.ts +9 -0
- package/dist/types/segment-system.d.ts +7 -0
- package/dist/types/server/context.d.ts +155 -33
- package/dist/types/server/cookie-store.d.ts +33 -0
- package/dist/types/server/handle-store.d.ts +52 -1
- package/dist/types/server/request-context.d.ts +210 -63
- package/dist/types/server/request-headers.d.ts +44 -0
- package/dist/types/server.d.ts +1 -0
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +7 -0
- 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-loader.d.ts +18 -3
- package/dist/types/testing/run-transition-when.d.ts +44 -69
- package/dist/types/testing/serve-shell-request.d.ts +133 -0
- package/dist/types/testing/shell-status.d.ts +44 -19
- package/dist/types/transition-when-ref.d.ts +79 -0
- package/dist/types/types/cache-types.d.ts +15 -3
- package/dist/types/types/handler-context.d.ts +14 -1
- 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 +21 -18
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +1005 -105
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +175 -76
- package/skills/caching/SKILL.md +361 -33
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +12 -7
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +52 -37
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +32 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/SKILL.md +3 -1
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/navigation.md +23 -2
- package/skills/hooks/state.md +101 -7
- package/skills/intercept/SKILL.md +29 -12
- package/skills/loader/SKILL.md +197 -44
- package/skills/middleware/SKILL.md +34 -9
- package/skills/migrate-nextjs/SKILL.md +68 -37
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +65 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +715 -371
- package/skills/prerender/SKILL.md +30 -17
- package/skills/rango/SKILL.md +72 -34
- package/skills/route/SKILL.md +14 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +49 -43
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/client-components.md +45 -3
- package/skills/testing/loader.md +19 -1
- 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 +202 -0
- package/skills/testing/server-actions.md +9 -3
- package/skills/theme/SKILL.md +56 -4
- package/skills/typesafety/env-and-bindings.md +18 -2
- package/skills/use-cache/SKILL.md +91 -23
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +150 -29
- package/src/__augment-tests__/augmented.check.ts +23 -0
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/event-controller.ts +4 -4
- package/src/browser/history-state.ts +75 -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 +9 -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 +62 -2
- package/src/cache/cache-policy.ts +31 -4
- package/src/cache/cache-runtime.ts +209 -129
- package/src/cache/cache-scope.ts +573 -69
- package/src/cache/cache-tag.ts +475 -19
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +1094 -376
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +55 -40
- package/src/cache/document-cache.ts +53 -7
- package/src/cache/handle-snapshot.ts +53 -9
- package/src/cache/index.ts +2 -0
- package/src/cache/invalidation-order.ts +84 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/request-tag-mask.ts +147 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +249 -277
- package/src/cache/tag-invalidation.ts +109 -18
- package/src/cache/types.ts +274 -102
- package/src/cache/vercel/vercel-cache-store.ts +561 -74
- 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 +173 -110
- package/src/route-definition/dsl-helpers.ts +6 -3
- 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 +42 -5
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +30 -8
- package/src/router/loader-resolution.ts +89 -33
- package/src/router/logging.ts +5 -3
- package/src/router/match-api.ts +89 -42
- package/src/router/match-context.ts +5 -0
- package/src/router/match-handlers.ts +14 -29
- package/src/router/match-middleware/background-revalidation.ts +3 -5
- package/src/router/match-middleware/cache-lookup.ts +52 -19
- package/src/router/match-middleware/cache-store.ts +36 -10
- package/src/router/match-result.ts +1 -0
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +20 -4
- package/src/router/navigation-snapshot.ts +6 -5
- package/src/router/prerender-match.ts +10 -0
- package/src/router/request-classification.ts +7 -4
- package/src/router/router-options.ts +4 -1
- package/src/router/segment-resolution/fresh.ts +18 -13
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +366 -70
- package/src/router/segment-resolution/loader-mask.ts +3 -4
- package/src/router/segment-resolution/loader-snapshot.ts +215 -126
- package/src/router/segment-resolution/mask-nested.ts +161 -42
- package/src/router/segment-resolution/revalidation.ts +27 -13
- 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 +47 -3
- package/src/rsc/handler.ts +24 -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 +10 -32
- package/src/rsc/response-cache-serve.ts +69 -78
- package/src/rsc/response-route-handler.ts +2 -2
- package/src/rsc/rsc-rendering.ts +712 -338
- package/src/rsc/server-action.ts +8 -20
- package/src/rsc/shell-build-manifest.ts +49 -29
- package/src/rsc/shell-capture-constants.ts +182 -12
- package/src/rsc/shell-capture.ts +1126 -612
- package/src/rsc/shell-serve.ts +331 -85
- package/src/rsc/ssr-setup.ts +3 -2
- package/src/rsc/types.ts +9 -0
- package/src/segment-system.tsx +40 -7
- package/src/server/context.ts +328 -72
- package/src/server/cookie-store.ts +184 -133
- package/src/server/handle-store.ts +245 -27
- package/src/server/request-context.ts +364 -100
- package/src/server/request-headers.ts +134 -0
- package/src/server.ts +4 -0
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/dispatch.ts +4 -7
- package/src/testing/e2e/index.ts +1 -0
- package/src/testing/flight.entry.ts +14 -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 +67 -5
- package/src/testing/run-transition-when.ts +109 -172
- package/src/testing/serve-shell-request.ts +381 -0
- package/src/testing/shell-status.ts +73 -47
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/transition-when-ref.ts +220 -0
- package/src/types/cache-types.ts +15 -3
- package/src/types/handler-context.ts +14 -1
- 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 +21 -18
- package/src/vite/discovery/discover-routers.ts +3 -0
- package/src/vite/discovery/shell-prerender-phase.ts +9 -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 +89 -9
- 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,21 +6,29 @@
|
|
|
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";
|
|
20
|
-
import { isLoaderDataResult } from "../types.js";
|
|
21
|
-
import {
|
|
23
|
+
import { isLoaderDataResult, type MatchResult } from "../types.js";
|
|
24
|
+
import { bytesToBase64 } from "../cache/cf/cf-base64.js";
|
|
25
|
+
import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
|
|
22
26
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
23
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
raceDeadline,
|
|
29
|
+
runBackground,
|
|
30
|
+
settleGrowing,
|
|
31
|
+
} from "../cache/background-task.js";
|
|
24
32
|
import {
|
|
25
33
|
CaptureQueueFullError,
|
|
26
34
|
CaptureQueueWaitTimeoutError,
|
|
@@ -31,6 +39,8 @@ import {
|
|
|
31
39
|
PPR_LANE_HINT,
|
|
32
40
|
SHELL_CAPTURE_MAX_WAIT_MS,
|
|
33
41
|
SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
42
|
+
resetShellWarningsForTests,
|
|
43
|
+
warnOnce,
|
|
34
44
|
} from "./shell-capture-constants.js";
|
|
35
45
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
36
46
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
@@ -41,18 +51,28 @@ import {
|
|
|
41
51
|
wireRenderBarrier,
|
|
42
52
|
UNTRACKED_BACKGROUND_TASK,
|
|
43
53
|
type RequestContext,
|
|
54
|
+
type RouteRecordWindow,
|
|
44
55
|
} from "../server/request-context.js";
|
|
56
|
+
import {
|
|
57
|
+
DEFAULT_FUNCTION_TTL,
|
|
58
|
+
resolveSwrWindow,
|
|
59
|
+
resolveTtl,
|
|
60
|
+
} from "../cache/cache-policy.js";
|
|
45
61
|
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
46
62
|
import {
|
|
47
63
|
maskNestedContainerThenables,
|
|
64
|
+
settleNestedThenables,
|
|
48
65
|
type MaskReport,
|
|
49
66
|
} from "../router/segment-resolution/mask-nested.js";
|
|
50
67
|
import {
|
|
68
|
+
findEnclosingLoaderBody,
|
|
51
69
|
getCurrentLoaderBodyId,
|
|
52
70
|
isInsideLoaderScope,
|
|
53
71
|
} from "../server/context.js";
|
|
54
72
|
import { isThenable } from "../handles/is-thenable.js";
|
|
73
|
+
import { requestHeaders } from "../server/request-headers.js";
|
|
55
74
|
import type {
|
|
75
|
+
CachedEntryData,
|
|
56
76
|
ShellCacheEntry,
|
|
57
77
|
SegmentCacheStore,
|
|
58
78
|
ShellSnapshotRecord,
|
|
@@ -64,11 +84,15 @@ import {
|
|
|
64
84
|
import {
|
|
65
85
|
RecordingShellStore,
|
|
66
86
|
SnapshotOnlySegmentStore,
|
|
87
|
+
countSnapshotFamilies,
|
|
88
|
+
estimateShellEntryBytes,
|
|
67
89
|
getRecordingStore,
|
|
90
|
+
hasDocRecord,
|
|
91
|
+
pruneShellSnapshot,
|
|
68
92
|
} from "../cache/shell-snapshot.js";
|
|
69
93
|
import type { HandlerContext } from "./handler-context.js";
|
|
70
94
|
import type { SSRModule } from "./types.js";
|
|
71
|
-
import { buildFullPayload } from "./full-payload.js";
|
|
95
|
+
import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
|
|
72
96
|
import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
|
|
73
97
|
import { renderRscFlightStage } from "./render-pipeline.js";
|
|
74
98
|
import { stripInternalParams } from "../router/handler-context.js";
|
|
@@ -89,21 +113,13 @@ import { stripInternalParams } from "../router/handler-context.js";
|
|
|
89
113
|
*
|
|
90
114
|
* K=2 gives a race window of ~two event-loop turns: shell work still producing
|
|
91
115
|
* bytes keeps resetting the counter; anything not producing bytes within the
|
|
92
|
-
* window (the masked loaders
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
96
120
|
*/
|
|
97
121
|
const FLIGHT_QUIET_HOPS = 2;
|
|
98
122
|
|
|
99
|
-
/**
|
|
100
|
-
* Default capture budget. Canonical value, raise rationale, and ceiling math
|
|
101
|
-
* live on the leaf module (shell-capture-constants.ts, importable from the ssr
|
|
102
|
-
* graph too). Re-exported here so shell-build-manifest.ts's envelope math
|
|
103
|
-
* keeps its existing import site and cannot drift from the capture.
|
|
104
|
-
*/
|
|
105
|
-
export { SHELL_CAPTURE_MAX_WAIT_MS };
|
|
106
|
-
|
|
107
123
|
/**
|
|
108
124
|
* Upper bound on waiting for the capture's DEFERRED cache writes to settle before
|
|
109
125
|
* draining the snapshot. Cache writes run under waitUntil (fire-and-forget on
|
|
@@ -140,33 +156,17 @@ const SHELL_CAPTURE_WRITE_BARRIER_MS = 1500;
|
|
|
140
156
|
|
|
141
157
|
/**
|
|
142
158
|
* Settle the tracked background tasks on `reqCtx._pendingBackgroundTasks`,
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
* (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.
|
|
148
163
|
*/
|
|
149
164
|
async function settleTrackedBackgroundTasks(
|
|
150
165
|
reqCtx: RequestContext<any>,
|
|
151
166
|
timeoutMs: number,
|
|
152
167
|
): Promise<void> {
|
|
153
168
|
const tasks = reqCtx._pendingBackgroundTasks;
|
|
154
|
-
if (
|
|
155
|
-
const deadline = Date.now() + timeoutMs;
|
|
156
|
-
let seen = 0;
|
|
157
|
-
while (tasks.length > seen) {
|
|
158
|
-
const remaining = deadline - Date.now();
|
|
159
|
-
if (remaining <= 0) return;
|
|
160
|
-
const batch = tasks.slice(seen);
|
|
161
|
-
seen = tasks.length;
|
|
162
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
163
|
-
const guard = new Promise<void>((resolve) => {
|
|
164
|
-
timer = setTimeout(resolve, remaining);
|
|
165
|
-
(timer as { unref?: () => void }).unref?.();
|
|
166
|
-
});
|
|
167
|
-
await Promise.race([Promise.allSettled(batch).then(() => {}), guard]);
|
|
168
|
-
if (timer) clearTimeout(timer);
|
|
169
|
-
}
|
|
169
|
+
if (tasks) await settleGrowing(tasks, Date.now() + timeoutMs);
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/**
|
|
@@ -196,31 +196,18 @@ function delay(ms: number): Promise<void> {
|
|
|
196
196
|
* Bound one capture task at {@link SHELL_CAPTURE_TASK_HARD_CAP_MS}. Rejects on
|
|
197
197
|
* expiry so the caller's existing catch applies backoff + reporting and its
|
|
198
198
|
* settle path releases the stampede guard and queue slot; resolves/rejects
|
|
199
|
-
* transparently otherwise.
|
|
200
|
-
* Node dev process open.
|
|
199
|
+
* transparently otherwise.
|
|
201
200
|
*/
|
|
202
|
-
function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
(timer as { unref?: () => void }).unref?.();
|
|
213
|
-
task.then(
|
|
214
|
-
(value) => {
|
|
215
|
-
clearTimeout(timer);
|
|
216
|
-
resolve(value);
|
|
217
|
-
},
|
|
218
|
-
(error: unknown) => {
|
|
219
|
-
clearTimeout(timer);
|
|
220
|
-
reject(error);
|
|
221
|
-
},
|
|
222
|
-
);
|
|
223
|
-
});
|
|
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
|
+
);
|
|
224
211
|
}
|
|
225
212
|
|
|
226
213
|
/**
|
|
@@ -240,6 +227,13 @@ function raceTaskHardCap<T>(task: Promise<T>, key: string): Promise<T> {
|
|
|
240
227
|
* keeps a late-settling stale task from releasing its replacement's entry.
|
|
241
228
|
*/
|
|
242
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
|
+
*/
|
|
243
237
|
startedAt: number;
|
|
244
238
|
}
|
|
245
239
|
|
|
@@ -313,7 +307,7 @@ function refusedCaptureCeilingMs(): number {
|
|
|
313
307
|
*/
|
|
314
308
|
const refusedCaptures = new Map<string, { failures: number; until: number }>();
|
|
315
309
|
|
|
316
|
-
/**
|
|
310
|
+
/** A bake-lane container that settled with redirect() or notFound(). */
|
|
317
311
|
function isSettledLoaderSignal(value: unknown): boolean {
|
|
318
312
|
return (
|
|
319
313
|
isLoaderDataResult(value) &&
|
|
@@ -322,6 +316,7 @@ function isSettledLoaderSignal(value: unknown): boolean {
|
|
|
322
316
|
);
|
|
323
317
|
}
|
|
324
318
|
|
|
319
|
+
/** True iff `key` is still inside its (exponential) backoff window. */
|
|
325
320
|
function isCaptureBackedOff(key: string): boolean {
|
|
326
321
|
const entry = refusedCaptures.get(key);
|
|
327
322
|
if (entry === undefined) return false;
|
|
@@ -353,14 +348,10 @@ function clearCaptureBackoff(key: string): void {
|
|
|
353
348
|
}
|
|
354
349
|
|
|
355
350
|
/**
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Warn once per key that a capture produced no usable shell EVEN AFTER the
|
|
363
|
-
* in-place retry (runShellCapture attempt 2). Naming both causes with the
|
|
351
|
+
* Warn once per key that a capture produced no usable shell: after the
|
|
352
|
+
* in-place retry (runShellCapture attempt 2), or after one attempt that ran
|
|
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
|
|
364
355
|
* distinguishing signal — does the route ever flip to HIT — is the whole point:
|
|
365
356
|
* the pre-retry version blamed "a loader route without loading()" unconditionally
|
|
366
357
|
* and misled users whose route DID have loading() and was merely cold. Because the
|
|
@@ -371,27 +362,57 @@ const warnedNullCaptures = new Set<string>();
|
|
|
371
362
|
* The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
|
|
372
363
|
* tarball, so the path resolves for consumers (a05c8251 convention).
|
|
373
364
|
*/
|
|
374
|
-
function warnNullCaptureOnce(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"
|
|
388
|
-
|
|
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` +
|
|
377
|
+
(retried ? " after an in-place retry" : "") +
|
|
378
|
+
"; nothing was stored, so this request stays on MISS. " +
|
|
379
|
+
(cause
|
|
380
|
+
? `Cause: ${cause}. Every capture waits for the handler layer to finish and ` +
|
|
381
|
+
'bakes it; make the slow part cheaper (cache() or "use cache"), raise ' +
|
|
382
|
+
"ppr.captureTimeout, or move it into a live loader (no ssr: false) read under " +
|
|
383
|
+
"loading() or an inline <Suspense>.\n"
|
|
384
|
+
: "Causes, told apart by whether the route ever flips to HIT:\n" +
|
|
385
|
+
" 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
|
|
386
|
+
"an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
|
|
387
|
+
"request warms the modules.\n" +
|
|
388
|
+
" 2. Something suspends above <body> with no Suspense boundary and never settles " +
|
|
389
|
+
"within the capture window: a live loader (no ssr: false) read without loading() or " +
|
|
390
|
+
"an inline <Suspense>, or a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
|
|
391
|
+
"slower than ppr.captureTimeout. The boundary belongs to the entry or component " +
|
|
392
|
+
"that owns the data.\n") +
|
|
393
|
+
(pendingStacks && pendingStacks.length > 0
|
|
394
|
+
? "Components still pending when the capture froze the shell (one of them " +
|
|
395
|
+
"suspended above <body>):\n" +
|
|
396
|
+
pendingStacks.map(formatPendingStack).join("\n") +
|
|
397
|
+
"\n"
|
|
398
|
+
: "") +
|
|
389
399
|
PPR_LANE_HINT,
|
|
390
400
|
);
|
|
391
401
|
}
|
|
392
402
|
|
|
393
|
-
/**
|
|
394
|
-
const
|
|
403
|
+
/** Stacks kept per attempt: the root pin is almost always among the first. */
|
|
404
|
+
const MAX_PENDING_STACKS = 3;
|
|
405
|
+
|
|
406
|
+
/** The first lines of one component stack, indented under the warning. */
|
|
407
|
+
function formatPendingStack(stack: string): string {
|
|
408
|
+
return stack
|
|
409
|
+
.split("\n")
|
|
410
|
+
.map((line) => line.trim())
|
|
411
|
+
.filter(Boolean)
|
|
412
|
+
.slice(0, 8)
|
|
413
|
+
.map((line) => ` ${line}`)
|
|
414
|
+
.join("\n");
|
|
415
|
+
}
|
|
395
416
|
|
|
396
417
|
/**
|
|
397
418
|
* Warn once per key that the capture was REFUSED for a deterministic reason
|
|
@@ -406,15 +427,81 @@ function warnCaptureRefusedOnce(
|
|
|
406
427
|
reason: string,
|
|
407
428
|
pointer: string = "See the /ppr skill (node_modules/@rangojs/router/skills/ppr/SKILL.md).",
|
|
408
429
|
): void {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
430
|
+
warnOnce(
|
|
431
|
+
"capture-refused",
|
|
432
|
+
key,
|
|
433
|
+
() =>
|
|
434
|
+
`[rango] Shell capture for "${key}" was refused: ${reason}\n` +
|
|
413
435
|
"The route stays on MISS (axis 1) — the page keeps working, only the shell " +
|
|
414
436
|
`cache is off. ${pointer}`,
|
|
415
437
|
);
|
|
416
438
|
}
|
|
417
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Refuse the capture when a capture guard tripped: a request-scoped read
|
|
442
|
+
* (cookies(), headers(), a { cache: false } variable) or ctx.dynamic() made
|
|
443
|
+
* anywhere the capture waited for — a handler, a promise it passes or pushes,
|
|
444
|
+
* an async server component, a bake-lane loader. The read throws where it
|
|
445
|
+
* happens, but the code can catch that throw, so the flag the guard sets on
|
|
446
|
+
* the capture context is what decides (server/context.ts
|
|
447
|
+
* guardIdentityRead). Returns true when it refused (and warned).
|
|
448
|
+
*/
|
|
449
|
+
function refuseOnCaptureGuard(
|
|
450
|
+
key: string,
|
|
451
|
+
derivedCtx: RequestContext<any>,
|
|
452
|
+
): boolean {
|
|
453
|
+
if (derivedCtx._dynamic) {
|
|
454
|
+
// A build-time capture reports dynamic() as its own outcome
|
|
455
|
+
// (build-shell-capture.ts): the URL keeps runtime capture.
|
|
456
|
+
if (derivedCtx.build) return true;
|
|
457
|
+
warnCaptureRefusedOnce(
|
|
458
|
+
key,
|
|
459
|
+
"ctx.dynamic() was called while capturing the shared shell (in a handler, or " +
|
|
460
|
+
"in a promise or async component the capture waited for). A dynamic render " +
|
|
461
|
+
"has no shell; the route keeps serving axis 1 for requests that opt out.",
|
|
462
|
+
);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
const trip = derivedCtx._shellCaptureGuardTripped;
|
|
466
|
+
if (!trip) return false;
|
|
467
|
+
// Name the recorded source instead of hardcoding a lane: a trip can come
|
|
468
|
+
// from a bake-lane SEGMENT loader or from handler/render code, including a
|
|
469
|
+
// loader a handler awaits (issue #672). The guard recorded the read and its
|
|
470
|
+
// fix (server/context.ts guardIdentityRead): cookies()/headers(), a
|
|
471
|
+
// { cache: false } variable, and the theme reads (#971).
|
|
472
|
+
const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
|
|
473
|
+
const origin = loaderId
|
|
474
|
+
? `the loader "${loaderId}"`
|
|
475
|
+
: "handler/render code (no loader body was executing)";
|
|
476
|
+
warnCaptureRefusedOnce(
|
|
477
|
+
key,
|
|
478
|
+
`${origin} read ${trip.surface} during capture; request-scoped data must not ` +
|
|
479
|
+
`bake into the shared shell. ${trip.fix}`,
|
|
480
|
+
PPR_LANE_HINT,
|
|
481
|
+
);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** The no-shell cause for handler output that missed the capture deadline. */
|
|
486
|
+
const HANDLER_OUTPUT_TIMEOUT_REASON: string =
|
|
487
|
+
"the handler output (a promise it passes or pushes, or an async server " +
|
|
488
|
+
"component) did not settle within ppr.captureTimeout";
|
|
489
|
+
|
|
490
|
+
/** The refusal for a capture that stored no doc record (a HIT needs it). */
|
|
491
|
+
const NO_DOC_RECORD_REASON: string =
|
|
492
|
+
"the capture produced no doc segment record, so a HIT could not replay the " +
|
|
493
|
+
"handler layer. Either the route's cache() refused the write (cache(false), or " +
|
|
494
|
+
"a condition() that returned false for the capture), or rendering the handler " +
|
|
495
|
+
"output failed (see the cache-write error reported for this key).";
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Default limit (bytes) on a whole stored shell entry — prelude, postponed
|
|
499
|
+
* state, and snapshot — when the store declares none
|
|
500
|
+
* (SegmentCacheStore.maxShellEntryBytes). Cloudflare KV's 25 MiB value limit:
|
|
501
|
+
* the tightest common store tier a shell entry lands in.
|
|
502
|
+
*/
|
|
503
|
+
export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
|
|
504
|
+
|
|
418
505
|
/**
|
|
419
506
|
* Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
|
|
420
507
|
* inside a shell entry, when the route's `ppr` option does not set
|
|
@@ -424,75 +511,75 @@ function warnCaptureRefusedOnce(
|
|
|
424
511
|
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
425
512
|
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
426
513
|
* the option through undefaulted), so every producer and direct caller gets
|
|
427
|
-
* the same policy.
|
|
428
|
-
*
|
|
514
|
+
* the same policy. The doc record is exempt; over the cap the other records
|
|
515
|
+
* are dropped (shell still stored and replayed; the pins' loaders read the
|
|
516
|
+
* live store — see PartialPrerenderProps.maxSnapshotBytes).
|
|
429
517
|
*/
|
|
430
518
|
export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
431
519
|
|
|
432
520
|
/** Cached encoder for the snapshot byte measurement (one per module, not per capture). */
|
|
433
521
|
const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
434
522
|
|
|
435
|
-
/** Keys already warned about an over-cap snapshot (once per key per isolate). */
|
|
436
|
-
const warnedOverCapSnapshots = new Set<string>();
|
|
437
|
-
|
|
438
523
|
/**
|
|
439
|
-
* Warn once per key that the capture
|
|
440
|
-
* `maxSnapshotBytes` cap and
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* repairs it client-side
|
|
444
|
-
* page recaptures on every TTL roll and would otherwise
|
|
524
|
+
* Warn once per key that the capture's loader pins exceeded the route's
|
|
525
|
+
* `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
|
|
526
|
+
* with its doc record and every HIT replays the handler layer; only the
|
|
527
|
+
* pinned loaders lose their captured values, so their cached data can drift
|
|
528
|
+
* from the frozen prelude and hydration repairs it client-side. Once per
|
|
529
|
+
* key: the same page recaptures on every TTL roll and would otherwise
|
|
530
|
+
* re-warn forever.
|
|
445
531
|
*/
|
|
446
532
|
function warnSnapshotOverCapOnce(
|
|
447
533
|
key: string,
|
|
448
534
|
snapshotBytes: number,
|
|
449
535
|
capBytes: number,
|
|
450
536
|
): void {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
`
|
|
456
|
-
|
|
457
|
-
"
|
|
458
|
-
"
|
|
459
|
-
"
|
|
460
|
-
"
|
|
461
|
-
"
|
|
537
|
+
warnOnce(
|
|
538
|
+
"snapshot-over-cap",
|
|
539
|
+
key,
|
|
540
|
+
() =>
|
|
541
|
+
`[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
|
|
542
|
+
`loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
|
|
543
|
+
"the shell was stored with its doc record only. HITs still replay the " +
|
|
544
|
+
"handler layer, but ssr: false loaders now read the live store: if " +
|
|
545
|
+
"their data drifts before the shell's TTL, hydration repairs the " +
|
|
546
|
+
"mismatch client-side. Raise the cap via the route's ppr option " +
|
|
547
|
+
"({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
|
|
548
|
+
"(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
|
|
549
|
+
"loaders read.",
|
|
462
550
|
);
|
|
463
551
|
}
|
|
464
552
|
|
|
465
553
|
/**
|
|
466
|
-
* Dev warning threshold (ms) for the
|
|
467
|
-
* time is by-design shell latency
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
* 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.
|
|
474
562
|
*/
|
|
475
563
|
const SHELL_CAPTURE_BAKE_WARN_MS = 2_000;
|
|
476
564
|
|
|
477
|
-
/** Keys already warned about an expensive bake source (once per key). */
|
|
478
|
-
const warnedBakeCosts = new Set<string>();
|
|
479
|
-
|
|
480
565
|
/**
|
|
481
|
-
* Dev-only, once per key: name the
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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
|
|
568
|
+
* by design, so this is a cost report with an exit, not a deprecation.
|
|
484
569
|
*/
|
|
485
570
|
function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
571
|
+
warnOnce(
|
|
572
|
+
"bake-cost",
|
|
573
|
+
key,
|
|
574
|
+
() =>
|
|
575
|
+
`[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
|
|
490
576
|
"settle before the shell could freeze. This cost recurs on every capture " +
|
|
491
577
|
"of the route and occupies the per-isolate capture queue; the served " +
|
|
492
|
-
"response never shows it. To
|
|
493
|
-
|
|
494
|
-
"
|
|
495
|
-
"
|
|
578
|
+
"response never shows it. To keep it baked but cheap, wrap the work in " +
|
|
579
|
+
'cache() or "use cache"; to make it per-request, load it in a live ' +
|
|
580
|
+
"loader (no ssr: false) read under loading() or an inline <Suspense>; for " +
|
|
581
|
+
"a loader(Def, { ssr: false }), return the slow part as a nested promise " +
|
|
582
|
+
"or drop ssr: false. " +
|
|
496
583
|
PPR_LANE_HINT,
|
|
497
584
|
);
|
|
498
585
|
}
|
|
@@ -509,10 +596,13 @@ export interface ShellCaptureDebugEvent {
|
|
|
509
596
|
key: string;
|
|
510
597
|
/**
|
|
511
598
|
* What happened:
|
|
512
|
-
* - stored / redirect / no-shell / refused: one capture
|
|
513
|
-
* (see CaptureAttemptOutcome for the semantics of each)
|
|
599
|
+
* - stored / redirect / no-shell / refused / expired: one capture
|
|
600
|
+
* ATTEMPT's outcome (see CaptureAttemptOutcome for the semantics of each)
|
|
514
601
|
* - error: the capture task failed with a genuine error (also routed through
|
|
515
602
|
* reportCacheError; the key is backed off)
|
|
603
|
+
* - skip-stored: a capture for the key stored after the scheduling request
|
|
604
|
+
* read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
|
|
605
|
+
* the shell that request missed exists now
|
|
516
606
|
* - skip-in-flight: scheduleShellCapture found a capture already running for
|
|
517
607
|
* the key (stampede guard) and scheduled nothing
|
|
518
608
|
* - skip-backoff: the key is inside its refused-capture backoff window and
|
|
@@ -535,8 +625,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
535
625
|
| "redirect"
|
|
536
626
|
| "no-shell"
|
|
537
627
|
| "refused"
|
|
628
|
+
| "expired"
|
|
538
629
|
| "error"
|
|
539
630
|
| "skip-in-flight"
|
|
631
|
+
| "skip-stored"
|
|
540
632
|
| "skip-backoff"
|
|
541
633
|
| "skip-capacity"
|
|
542
634
|
| "skip-inert-store"
|
|
@@ -580,14 +672,13 @@ export interface ShellCaptureDebugEvent {
|
|
|
580
672
|
*/
|
|
581
673
|
queueAhead?: number;
|
|
582
674
|
/**
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* (the hole doctrine: top-level promises bake), but it recurs on EVERY
|
|
587
|
-
* capture of the route and occupies the serialized queue; this field makes
|
|
588
|
-
* it attributable. Absent when nothing held the gate.
|
|
675
|
+
* How long the capture waited for its doc record: the handler layer's
|
|
676
|
+
* promises, async server components and handle pushes settling, then the
|
|
677
|
+
* record's encode (settleCaptureRecord). Part of the one capture deadline.
|
|
589
678
|
*/
|
|
590
|
-
|
|
679
|
+
recordSettleMs?: number;
|
|
680
|
+
/** Bytes of the stored entry: prelude, postponed state, and snapshot. */
|
|
681
|
+
entryBytes?: number;
|
|
591
682
|
}
|
|
592
683
|
|
|
593
684
|
/**
|
|
@@ -643,8 +734,11 @@ export function describeShellCaptureEvent(
|
|
|
643
734
|
if (event.queueAhead !== undefined) {
|
|
644
735
|
parts.push(`queue-ahead=${event.queueAhead}`);
|
|
645
736
|
}
|
|
646
|
-
if (event.
|
|
647
|
-
parts.push(`
|
|
737
|
+
if (event.recordSettleMs !== undefined) {
|
|
738
|
+
parts.push(`record=${event.recordSettleMs}ms`);
|
|
739
|
+
}
|
|
740
|
+
if (event.entryBytes !== undefined) {
|
|
741
|
+
parts.push(`entry=${event.entryBytes}b`);
|
|
648
742
|
}
|
|
649
743
|
return parts.join(" ");
|
|
650
744
|
}
|
|
@@ -681,6 +775,7 @@ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
|
|
|
681
775
|
"redirect",
|
|
682
776
|
"no-shell",
|
|
683
777
|
"refused",
|
|
778
|
+
"expired",
|
|
684
779
|
"error",
|
|
685
780
|
]);
|
|
686
781
|
|
|
@@ -786,22 +881,13 @@ export interface FlightCaptureGate {
|
|
|
786
881
|
* closing (no holes) fires quiesce immediately for the DATA variant — the
|
|
787
882
|
* TransformStream then closes the readable, so fizz completes with postponed null.
|
|
788
883
|
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* out — the handles row would never reach fizz and the prelude would come back
|
|
794
|
-
* trivial. While `holdUntil` is pending, byte-quiet detection keeps running but the
|
|
795
|
-
* gate neither fires nor freezes; once it resolves, the quiet counter restarts so a
|
|
796
|
-
* burst of rows unblocked by it (the resolved handles row) is still captured. It
|
|
797
|
-
* never delays a HOLE from postponing: holes are pending promises that emit no
|
|
798
|
-
* bytes, so holding the gate open longer only ever admits shell rows. Bounded by
|
|
799
|
-
* 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.
|
|
800
888
|
*/
|
|
801
889
|
export function gateFlightForCapture(
|
|
802
890
|
source: ReadableStream<Uint8Array>,
|
|
803
|
-
quietHops: number = FLIGHT_QUIET_HOPS,
|
|
804
|
-
holdUntil?: Promise<unknown>,
|
|
805
891
|
): FlightCaptureGate {
|
|
806
892
|
let resolveQuiet!: () => void;
|
|
807
893
|
const quiesce = new Promise<void>((resolve) => {
|
|
@@ -813,32 +899,9 @@ export function gateFlightForCapture(
|
|
|
813
899
|
let settled = false;
|
|
814
900
|
let disposed = false;
|
|
815
901
|
let frozen = false;
|
|
816
|
-
let held = holdUntil !== undefined;
|
|
817
|
-
let heldFirePending = false;
|
|
818
|
-
|
|
819
|
-
if (holdUntil !== undefined) {
|
|
820
|
-
const release = (): void => {
|
|
821
|
-
held = false;
|
|
822
|
-
if (heldFirePending && !settled && !disposed) {
|
|
823
|
-
// Quiet elapsed while held: restart the quiet count instead of firing
|
|
824
|
-
// immediately, so rows unblocked by the hold (the baked handles row)
|
|
825
|
-
// still flow before the freeze.
|
|
826
|
-
heldFirePending = false;
|
|
827
|
-
armed = false;
|
|
828
|
-
arm();
|
|
829
|
-
}
|
|
830
|
-
};
|
|
831
|
-
// Resolve OR reject releases the hold (a rejected handle value is dropped by
|
|
832
|
-
// resolveDeferredHandleValues; the capture must not hang on it).
|
|
833
|
-
holdUntil.then(release, release);
|
|
834
|
-
}
|
|
835
902
|
|
|
836
903
|
const fire = (): void => {
|
|
837
904
|
if (settled) return;
|
|
838
|
-
if (held) {
|
|
839
|
-
heldFirePending = true;
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
842
905
|
settled = true;
|
|
843
906
|
frozen = true;
|
|
844
907
|
resolveQuiet();
|
|
@@ -862,7 +925,7 @@ export function gateFlightForCapture(
|
|
|
862
925
|
if (settled || disposed) return;
|
|
863
926
|
if (bytesSeen === lastSeen) {
|
|
864
927
|
quiet += 1;
|
|
865
|
-
if (quiet >=
|
|
928
|
+
if (quiet >= FLIGHT_QUIET_HOPS) {
|
|
866
929
|
fire();
|
|
867
930
|
return;
|
|
868
931
|
}
|
|
@@ -948,16 +1011,13 @@ export interface ShellCaptureDescriptor {
|
|
|
948
1011
|
tags?: string[];
|
|
949
1012
|
/**
|
|
950
1013
|
* Per-route capture settle budget in ms (`ppr.captureTimeout`, resolved by
|
|
951
|
-
* resolvePprConfig)
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
* 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).
|
|
956
1018
|
*/
|
|
957
1019
|
captureTimeout?: number;
|
|
958
1020
|
store?: SegmentCacheStore<any>;
|
|
959
|
-
/** Gates the concise per-attempt capture breadcrumbs (INTERNAL_RANGO_DEBUG). */
|
|
960
|
-
debug?: boolean;
|
|
961
1021
|
/**
|
|
962
1022
|
* Cap (serialized UTF-8 bytes) on the entry's capture data snapshot; over it
|
|
963
1023
|
* the snapshot is skipped and the shell stored without it (reported once per
|
|
@@ -974,6 +1034,33 @@ export interface ShellCaptureDescriptor {
|
|
|
974
1034
|
debugSink?: (event: ShellCaptureDebugEvent) => void;
|
|
975
1035
|
/** Store the snapshot for navigation replay, never the captured HTML prelude. */
|
|
976
1036
|
navigationOnly?: true;
|
|
1037
|
+
/**
|
|
1038
|
+
* lastStoredCaptureSeq(key) when a document request read the store and
|
|
1039
|
+
* missed. The request schedules its capture only after rendering the MISS,
|
|
1040
|
+
* so a capture another request started can store in between; a later
|
|
1041
|
+
* sequence number then skips the redundant one (`skip-stored`), which would
|
|
1042
|
+
* re-render the page and drop the isolate's memo of the shell just stored.
|
|
1043
|
+
*/
|
|
1044
|
+
storedSeqAtRead?: number;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** Per key, the sequence number of its latest stored capture (bounded). */
|
|
1048
|
+
const storedCaptureSeqs = new Map<string, number>();
|
|
1049
|
+
const STORED_CAPTURE_SEQS_MAX = 1_000;
|
|
1050
|
+
let storedCaptureSeq = 0;
|
|
1051
|
+
|
|
1052
|
+
/** The latest stored capture's sequence number for `key` in this isolate, or 0. */
|
|
1053
|
+
export function lastStoredCaptureSeq(key: string): number {
|
|
1054
|
+
return storedCaptureSeqs.get(key) ?? 0;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function noteCaptureStored(key: string): void {
|
|
1058
|
+
storedCaptureSeqs.delete(key);
|
|
1059
|
+
if (storedCaptureSeqs.size >= STORED_CAPTURE_SEQS_MAX) {
|
|
1060
|
+
const oldest = storedCaptureSeqs.keys().next().value;
|
|
1061
|
+
if (oldest !== undefined) storedCaptureSeqs.delete(oldest);
|
|
1062
|
+
}
|
|
1063
|
+
storedCaptureSeqs.set(key, ++storedCaptureSeq);
|
|
977
1064
|
}
|
|
978
1065
|
|
|
979
1066
|
/**
|
|
@@ -1011,6 +1098,13 @@ export function scheduleShellCapture(
|
|
|
1011
1098
|
// new token, and the stale task's token-guarded release can no longer
|
|
1012
1099
|
// touch it.
|
|
1013
1100
|
}
|
|
1101
|
+
if (
|
|
1102
|
+
descriptor.storedSeqAtRead !== undefined &&
|
|
1103
|
+
lastStoredCaptureSeq(key) > descriptor.storedSeqAtRead
|
|
1104
|
+
) {
|
|
1105
|
+
publishCaptureDebugEvent(descriptor, { key, outcome: "skip-stored" });
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1014
1108
|
// Refused/failed within the window → skip the doomed re-render (one probe per
|
|
1015
1109
|
// key per window per isolate). Expired entries self-evict inside the check.
|
|
1016
1110
|
if (isCaptureBackedOff(key)) {
|
|
@@ -1035,16 +1129,23 @@ export function scheduleShellCapture(
|
|
|
1035
1129
|
const guardToken: CaptureGuardToken = { startedAt: Date.now() };
|
|
1036
1130
|
inFlightCaptures.set(key, guardToken);
|
|
1037
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();
|
|
1038
1136
|
try {
|
|
1039
|
-
|
|
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
|
|
1040
1141
|
? stripInternalParams(url)
|
|
1041
1142
|
: url;
|
|
1042
|
-
const
|
|
1043
|
-
? createNavigationCaptureRequest(request,
|
|
1143
|
+
const captureRequest = descriptor.navigationOnly
|
|
1144
|
+
? createNavigationCaptureRequest(request, captureUrl)
|
|
1044
1145
|
: request;
|
|
1045
1146
|
const resolvedSsrModule =
|
|
1046
1147
|
typeof ssrModule === "function"
|
|
1047
|
-
? await ssrModule(
|
|
1148
|
+
? await ssrModule(captureRequest, captureUrl)
|
|
1048
1149
|
: ssrModule;
|
|
1049
1150
|
if (
|
|
1050
1151
|
!resolvedSsrModule ||
|
|
@@ -1053,19 +1154,21 @@ export function scheduleShellCapture(
|
|
|
1053
1154
|
) {
|
|
1054
1155
|
return;
|
|
1055
1156
|
}
|
|
1056
|
-
// Hard-capped:
|
|
1057
|
-
//
|
|
1058
|
-
// wedged on a never-settling upstream await
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
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();
|
|
1063
1166
|
const outcome = await raceTaskHardCap(
|
|
1064
1167
|
runShellCapture(
|
|
1065
1168
|
ctx,
|
|
1066
|
-
|
|
1169
|
+
captureRequest,
|
|
1067
1170
|
env,
|
|
1068
|
-
|
|
1171
|
+
captureUrl,
|
|
1069
1172
|
reqCtx,
|
|
1070
1173
|
resolvedSsrModule,
|
|
1071
1174
|
descriptor,
|
|
@@ -1077,8 +1180,13 @@ export function scheduleShellCapture(
|
|
|
1077
1180
|
// any prior backoff; a `no-shell` (after the in-place retry) backs the key
|
|
1078
1181
|
// off so the next requests don't re-probe it. A `redirect` has no shell but
|
|
1079
1182
|
// is not a doomed render — leave the backoff untouched.
|
|
1080
|
-
if (outcome === "stored")
|
|
1081
|
-
|
|
1183
|
+
if (outcome === "stored") {
|
|
1184
|
+
clearCaptureBackoff(key);
|
|
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);
|
|
1189
|
+
} else if (outcome === "no-shell") {
|
|
1082
1190
|
markCaptureBackoff(key);
|
|
1083
1191
|
publishCaptureDebugEvent(descriptor, {
|
|
1084
1192
|
key,
|
|
@@ -1191,6 +1299,81 @@ export function scheduleShellCapture(
|
|
|
1191
1299
|
runBackground(reqCtx, serializedTask);
|
|
1192
1300
|
}
|
|
1193
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* The ttl/swr a shell is stored with: the route's ppr policy, capped by the
|
|
1304
|
+
* route cache() records the capture replayed or wrote
|
|
1305
|
+
* (RequestContext._routeRecordWindow). A HIT replays the capture's handler
|
|
1306
|
+
* output, while a client navigation reads the route's cache() tier first, so
|
|
1307
|
+
* a shell that outlived its record would show a document request other
|
|
1308
|
+
* handler output than a navigation of the same URL. Freshness and total
|
|
1309
|
+
* lifetime are capped separately, like the record's own SWR: the shell is
|
|
1310
|
+
* fresh while the record is (at most `ttl`), and servable while the record
|
|
1311
|
+
* is (at most `ttl + swr`), so a record already inside its swr window yields
|
|
1312
|
+
* a shell that is stale from the start and serves while it recaptures.
|
|
1313
|
+
*
|
|
1314
|
+
* Whole seconds, rounded up (the SegmentCacheStore.putShell contract), so the
|
|
1315
|
+
* shell may outlast the record by under a second. Null when the record's
|
|
1316
|
+
* total lifetime has run out: the shell would be dead on arrival.
|
|
1317
|
+
*/
|
|
1318
|
+
export function capShellWindow(
|
|
1319
|
+
ttl: number,
|
|
1320
|
+
swr: number,
|
|
1321
|
+
record: Pick<RouteRecordWindow, "freshUntil" | "staleUntil">,
|
|
1322
|
+
now: number,
|
|
1323
|
+
): { ttl: number; swr: number } | null {
|
|
1324
|
+
const total = Math.min(ttl + swr, (record.staleUntil - now) / 1000);
|
|
1325
|
+
if (!(total > 0)) return null;
|
|
1326
|
+
// At most `total`: freshUntil <= staleUntil and swr >= 0.
|
|
1327
|
+
const fresh = Math.max(0, Math.min(ttl, (record.freshUntil - now) / 1000));
|
|
1328
|
+
const wholeTtl = Math.ceil(fresh);
|
|
1329
|
+
return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Warn once per route (dev and production) that no shell was stored because
|
|
1334
|
+
* the route cache() entry ran out before the capture could store one
|
|
1335
|
+
* (capShellWindow returned null) where a retry cannot help: the capture
|
|
1336
|
+
* wrote the entry itself (`written`), or the in-place retry's entry ran out
|
|
1337
|
+
* too, or no budget was left to retry. The key backs off like a refused
|
|
1338
|
+
* capture.
|
|
1339
|
+
*/
|
|
1340
|
+
function warnRecordRanOutOnce(
|
|
1341
|
+
route: string,
|
|
1342
|
+
record: RouteRecordWindow,
|
|
1343
|
+
detail: string,
|
|
1344
|
+
): void {
|
|
1345
|
+
warnOnce(
|
|
1346
|
+
"record-ran-out",
|
|
1347
|
+
route,
|
|
1348
|
+
() =>
|
|
1349
|
+
`[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
|
|
1350
|
+
`${record.swr}) ran out before the shell capture could store a shell ` +
|
|
1351
|
+
`(${detail}), so no shell is stored and the route renders without ` +
|
|
1352
|
+
"one. A shell never outlives the route cache() entry it replays: give " +
|
|
1353
|
+
"that cache() a ttl or swr longer than the capture takes, queue wait " +
|
|
1354
|
+
"included.",
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/**
|
|
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.
|
|
1361
|
+
*/
|
|
1362
|
+
function expiredTerminal(
|
|
1363
|
+
attempt: CaptureAttemptStats,
|
|
1364
|
+
reason: string,
|
|
1365
|
+
): "no-shell" {
|
|
1366
|
+
const expired = attempt.expired;
|
|
1367
|
+
if (expired) {
|
|
1368
|
+
warnRecordRanOutOnce(
|
|
1369
|
+
expired.route,
|
|
1370
|
+
expired.record,
|
|
1371
|
+
`${reason}; the last attempt took ${expired.captureMs} ms`,
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
return "no-shell";
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1194
1377
|
/**
|
|
1195
1378
|
* The outcome of one capture attempt.
|
|
1196
1379
|
* - `stored`: a usable shell was captured (and a putShell was attempted; a store
|
|
@@ -1198,13 +1381,30 @@ export function scheduleShellCapture(
|
|
|
1198
1381
|
* the capture itself worked).
|
|
1199
1382
|
* - `redirect`: the matched route redirects, so there is no shell to capture.
|
|
1200
1383
|
* - `no-shell`: captureShellHTML returned null because the prelude was unusable
|
|
1201
|
-
* or its private capture abort landed before the shell completed
|
|
1202
|
-
*
|
|
1384
|
+
* or its private capture abort landed before the shell completed, or the
|
|
1385
|
+
* capture ran out of ppr.captureTimeout. Retried once in place, when no
|
|
1386
|
+
* deadline ran out (CaptureAttemptStats.noShellCause).
|
|
1387
|
+
* - `expired`: the route cache() record the capture read (not wrote) ran out
|
|
1388
|
+
* before the store (capShellWindow null): it was near its end (its age,
|
|
1389
|
+
* queue wait). Retried once in place, where a fresh match reads or renders
|
|
1390
|
+
* a newer record; an `expired` retry, or no hard-cap budget left for the
|
|
1391
|
+
* retry, is terminal `no-shell` (backoff, warned once per route).
|
|
1392
|
+
*/
|
|
1393
|
+
type CaptureAttemptOutcome =
|
|
1394
|
+
| "stored"
|
|
1395
|
+
| "redirect"
|
|
1396
|
+
| "no-shell"
|
|
1397
|
+
| "refused"
|
|
1398
|
+
| "expired";
|
|
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.
|
|
1203
1403
|
*/
|
|
1204
|
-
type
|
|
1404
|
+
type CaptureTaskOutcome = "stored" | "redirect" | "no-shell";
|
|
1205
1405
|
|
|
1206
1406
|
function createNavigationCaptureRequest(request: Request, url: URL): Request {
|
|
1207
|
-
const headers = new Headers(request
|
|
1407
|
+
const headers = new Headers(requestHeaders(request));
|
|
1208
1408
|
headers.set("accept", "text/html");
|
|
1209
1409
|
for (const name of [
|
|
1210
1410
|
"rsc-action",
|
|
@@ -1220,11 +1420,12 @@ function createNavigationCaptureRequest(request: Request, url: URL): Request {
|
|
|
1220
1420
|
}
|
|
1221
1421
|
|
|
1222
1422
|
/**
|
|
1223
|
-
*
|
|
1224
|
-
* attemptCapture, the rest in captureAndStoreShell)
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
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.
|
|
1228
1429
|
*/
|
|
1229
1430
|
type CaptureAttemptStats = Pick<
|
|
1230
1431
|
ShellCaptureDebugEvent,
|
|
@@ -1235,8 +1436,24 @@ type CaptureAttemptStats = Pick<
|
|
|
1235
1436
|
| "snapshotSkipped"
|
|
1236
1437
|
| "untaggedBake"
|
|
1237
1438
|
| "storeWrite"
|
|
1238
|
-
| "
|
|
1239
|
-
|
|
1439
|
+
| "recordSettleMs"
|
|
1440
|
+
| "entryBytes"
|
|
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
|
+
};
|
|
1240
1457
|
|
|
1241
1458
|
/**
|
|
1242
1459
|
* Run the shell capture with a single in-place retry, then store the result.
|
|
@@ -1249,12 +1466,15 @@ type CaptureAttemptStats = Pick<
|
|
|
1249
1466
|
* when we froze the shell; the attempt itself warmed the module graph, so a second
|
|
1250
1467
|
* attempt a short beat later usually completes the shell in the SAME background
|
|
1251
1468
|
* task. That kills the old multi-request warmup where the caller had to re-issue
|
|
1252
|
-
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1469
|
+
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
|
|
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.
|
|
1255
1473
|
*
|
|
1256
|
-
* `
|
|
1257
|
-
*
|
|
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.
|
|
1258
1478
|
*/
|
|
1259
1479
|
async function runShellCapture(
|
|
1260
1480
|
ctx: HandlerContext<any>,
|
|
@@ -1265,14 +1485,8 @@ async function runShellCapture(
|
|
|
1265
1485
|
ssrModule: SSRModule,
|
|
1266
1486
|
descriptor: ShellCaptureDescriptor,
|
|
1267
1487
|
retryDelayMs: number = SHELL_CAPTURE_RETRY_DELAY_MS,
|
|
1268
|
-
): Promise<
|
|
1269
|
-
const
|
|
1270
|
-
const captureRequest = descriptor.navigationOnly
|
|
1271
|
-
? createNavigationCaptureRequest(request, captureUrl)
|
|
1272
|
-
: request;
|
|
1273
|
-
const log = descriptor.debug
|
|
1274
|
-
? (message: string) => console.log(message)
|
|
1275
|
-
: () => {};
|
|
1488
|
+
): Promise<CaptureTaskOutcome> {
|
|
1489
|
+
const key = descriptor.key;
|
|
1276
1490
|
|
|
1277
1491
|
// One attempt + its structured debug event: the stats object rides through
|
|
1278
1492
|
// attemptCapture/captureAndStoreShell collecting the observability fields
|
|
@@ -1281,49 +1495,82 @@ async function runShellCapture(
|
|
|
1281
1495
|
// event — scheduleShellCapture's catch publishes the terminal `error` event.
|
|
1282
1496
|
const timedAttempt = async (
|
|
1283
1497
|
attempt: number,
|
|
1284
|
-
): Promise<
|
|
1498
|
+
): Promise<{
|
|
1499
|
+
outcome: CaptureAttemptOutcome;
|
|
1500
|
+
stats: CaptureAttemptStats;
|
|
1501
|
+
}> => {
|
|
1285
1502
|
const stats: CaptureAttemptStats = {};
|
|
1286
1503
|
const start = performance.now();
|
|
1287
1504
|
const outcome = await attemptCapture(
|
|
1288
1505
|
ctx,
|
|
1289
|
-
|
|
1506
|
+
request,
|
|
1290
1507
|
env,
|
|
1291
|
-
|
|
1508
|
+
url,
|
|
1292
1509
|
reqCtx,
|
|
1293
1510
|
ssrModule,
|
|
1294
1511
|
descriptor,
|
|
1295
1512
|
stats,
|
|
1296
1513
|
);
|
|
1514
|
+
const { noShellCause, pendingStacks, expired, ...eventFields } = stats;
|
|
1297
1515
|
publishCaptureDebugEvent(descriptor, {
|
|
1298
|
-
key
|
|
1516
|
+
key,
|
|
1299
1517
|
outcome,
|
|
1300
1518
|
attempt,
|
|
1301
1519
|
attemptMs: Math.round(performance.now() - start),
|
|
1302
|
-
...
|
|
1520
|
+
...eventFields,
|
|
1303
1521
|
});
|
|
1304
|
-
return outcome;
|
|
1522
|
+
return { outcome, stats };
|
|
1305
1523
|
};
|
|
1306
1524
|
|
|
1525
|
+
const taskStartedAt = performance.now();
|
|
1307
1526
|
const first = await timedAttempt(1);
|
|
1308
1527
|
// "refused" is deterministic (identity guard / rejected bake-lane loader —
|
|
1309
1528
|
// its own warning already fired): no retry, and the caller backs the key off
|
|
1310
1529
|
// exactly like a structural no-shell.
|
|
1311
|
-
if (first === "refused") return "no-shell";
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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") {
|
|
1535
|
+
// The record attempt 1 read ran out mid-capture: a fresh match reads or
|
|
1536
|
+
// renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
1537
|
+
// so a retry that could not finish inside it (barrier + captureTimeout)
|
|
1538
|
+
// is not started.
|
|
1539
|
+
const retryBudgetMs =
|
|
1540
|
+
SHELL_CAPTURE_TASK_HARD_CAP_MS - (performance.now() - taskStartedAt);
|
|
1541
|
+
const retryNeedsMs =
|
|
1542
|
+
SHELL_CAPTURE_WRITE_BARRIER_MS +
|
|
1543
|
+
(descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1544
|
+
if (retryBudgetMs < retryNeedsMs) {
|
|
1545
|
+
return expiredTerminal(first.stats, "no time was left to retry");
|
|
1546
|
+
}
|
|
1547
|
+
} else {
|
|
1548
|
+
// Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
|
|
1549
|
+
// handlers may still be running (a match is not cancellable), and a retry
|
|
1550
|
+
// would start the same work beside them. A cold-module abort, the retry's
|
|
1551
|
+
// reason, ends well inside the deadline.
|
|
1552
|
+
if (first.stats.noShellCause !== undefined) {
|
|
1553
|
+
warnNullCaptureOnce(key, false, first.stats);
|
|
1554
|
+
return "no-shell";
|
|
1555
|
+
}
|
|
1556
|
+
// Attempt 1 produced no usable shell. Retry ONCE in place — the first
|
|
1557
|
+
// attempt warmed the dev transform graph / cold worker, so attempt 2
|
|
1558
|
+
// typically completes the shell without another HTTP request.
|
|
1559
|
+
await delay(retryDelayMs);
|
|
1560
|
+
}
|
|
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).
|
|
1324
1565
|
const second = await timedAttempt(2);
|
|
1325
|
-
if (second === "
|
|
1326
|
-
|
|
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;
|
|
1327
1574
|
|
|
1328
1575
|
// Both attempts came back with no usable shell. Cold-start would have healed by
|
|
1329
1576
|
// now, so the eternal-MISS structural shape (a boundary-less live-loader read;
|
|
@@ -1331,23 +1578,177 @@ async function runShellCapture(
|
|
|
1331
1578
|
// warn once per key. Ordering matters: because the retry
|
|
1332
1579
|
// absorbs cold-start, cold-start routes almost never reach this warning. The
|
|
1333
1580
|
// caller (scheduleShellCapture) reads this `no-shell` return to back the key off.
|
|
1334
|
-
|
|
1335
|
-
`[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
|
|
1336
|
-
);
|
|
1337
|
-
warnNullCaptureOnce(descriptor.key);
|
|
1581
|
+
warnNullCaptureOnce(key, true, second.stats);
|
|
1338
1582
|
return "no-shell";
|
|
1339
1583
|
}
|
|
1340
1584
|
|
|
1341
|
-
/**
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1585
|
+
/**
|
|
1586
|
+
* What the record-first step produced ({@link settleCaptureRecord}):
|
|
1587
|
+
* - `record`: the doc record settled; `match` carries its fragments in place
|
|
1588
|
+
* of the handler layer's elements, so the capture's Flight payload is the
|
|
1589
|
+
* bytes every HIT replays.
|
|
1590
|
+
* - `prerender`: the prerender store supplied the handler layer (a
|
|
1591
|
+
* Prerender route); its HIT tail replays the same build-time segments, so
|
|
1592
|
+
* the capture renders the match as is.
|
|
1593
|
+
* - `timeout`: handler output did not settle within the capture budget.
|
|
1594
|
+
* - `refused`: a deterministic refusal (a capture guard tripped, the route
|
|
1595
|
+
* opted out, or the handler output failed); the warning already fired.
|
|
1596
|
+
*/
|
|
1597
|
+
export type CaptureRecordOutcome =
|
|
1598
|
+
| { kind: "record" | "prerender"; match: MatchResult }
|
|
1599
|
+
| { kind: "timeout"; reason: string }
|
|
1600
|
+
| { kind: "refused" };
|
|
1601
|
+
|
|
1602
|
+
/**
|
|
1603
|
+
* Record-first capture: the doc record is both the settle signal and the
|
|
1604
|
+
* parity source.
|
|
1605
|
+
*
|
|
1606
|
+
* Writing the record Flight-serializes every non-loader segment
|
|
1607
|
+
* (serializeSegments), which runs each handler's async server components and
|
|
1608
|
+
* waits for every promise the handler output carries — the capture's "is the
|
|
1609
|
+
* handler layer done" signal. Live-lane loader data is not in the record
|
|
1610
|
+
* (loader segments are excluded and their masked promises never settle), so
|
|
1611
|
+
* the wait never depends on a hole.
|
|
1612
|
+
*
|
|
1613
|
+
* The capture then renders its OWN Flight payload from the record's
|
|
1614
|
+
* fragments (fragmentSegments, the function a HIT tail uses) instead of from
|
|
1615
|
+
* the elements the match produced. Server components therefore render once
|
|
1616
|
+
* per capture, inside the record's encode: before this, the capture's Flight
|
|
1617
|
+
* render and the record's encode each ran them, so an uncached async
|
|
1618
|
+
* component showed one value in the prelude and another on every HIT.
|
|
1619
|
+
*
|
|
1620
|
+
* Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
|
|
1621
|
+
* top-level handle promises, the handler pushes' nested promises, and the
|
|
1622
|
+
* bake-lane loader containers settle first (the record encodes the handles
|
|
1623
|
+
* a HIT restores, and its handle encode has its own 5s timeout that must
|
|
1624
|
+
* never be the one that fires); then the capture's onResponse callbacks fire
|
|
1625
|
+
* with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
|
|
1626
|
+
* scope's own write), and the deferred writes settle.
|
|
1627
|
+
*/
|
|
1628
|
+
export async function settleCaptureRecord(
|
|
1629
|
+
match: MatchResult,
|
|
1630
|
+
derivation: CaptureContextDerivation,
|
|
1631
|
+
capture: Pick<ShellCaptureDescriptor, "key">,
|
|
1632
|
+
deadline: number,
|
|
1633
|
+
): Promise<CaptureRecordOutcome> {
|
|
1634
|
+
const { derivedCtx, freshHandleStore } = derivation;
|
|
1635
|
+
const recording = getRecordingStore(derivedCtx._cacheStore);
|
|
1636
|
+
|
|
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.
|
|
1642
|
+
freshHandleStore.seal();
|
|
1643
|
+
const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
|
|
1644
|
+
const settled = await raceDeadline(
|
|
1645
|
+
Promise.all([
|
|
1646
|
+
freshHandleStore.getData().then(resolveDeferredHandleValues),
|
|
1647
|
+
derivation.handlerPushesSettled(),
|
|
1648
|
+
loaderRecords && loaderRecords.size > 0
|
|
1649
|
+
? Promise.allSettled([...loaderRecords.values()])
|
|
1650
|
+
: undefined,
|
|
1651
|
+
]).catch(() => undefined),
|
|
1652
|
+
deadline,
|
|
1653
|
+
);
|
|
1654
|
+
// Past the deadline the record cannot settle in time: stop before its
|
|
1655
|
+
// write starts (no encode for a capture that already failed).
|
|
1656
|
+
if (!settled.done) {
|
|
1657
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx))
|
|
1658
|
+
return { kind: "refused" };
|
|
1659
|
+
return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
|
|
1663
|
+
if (callbacks.length > 0) {
|
|
1664
|
+
const synthetic = new Response(null, { status: 200 });
|
|
1665
|
+
for (const cb of callbacks) {
|
|
1666
|
+
try {
|
|
1667
|
+
cb(synthetic);
|
|
1668
|
+
} catch {
|
|
1669
|
+
// A capture-time cache write that throws is degradation, not failure.
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
const drained = recording
|
|
1674
|
+
? await recording.settleWrites(Math.max(0, deadline - Date.now()))
|
|
1675
|
+
: true;
|
|
1676
|
+
|
|
1677
|
+
// Capture guards first: a tripped guard usually also fails the record,
|
|
1678
|
+
// and its own message names the cause.
|
|
1679
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
|
|
1680
|
+
|
|
1681
|
+
const docKey = derivedCtx._shellImplicitCache?.docKey;
|
|
1682
|
+
const record =
|
|
1683
|
+
recording && docKey !== undefined ? recording.getRecord(docKey) : undefined;
|
|
1684
|
+
if (
|
|
1685
|
+
!record ||
|
|
1686
|
+
!Array.isArray(record.segments) ||
|
|
1687
|
+
record.segments.length === 0
|
|
1688
|
+
) {
|
|
1689
|
+
// Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
|
|
1690
|
+
if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
|
|
1691
|
+
return { kind: "prerender", match };
|
|
1692
|
+
}
|
|
1693
|
+
if (!drained) {
|
|
1694
|
+
return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
|
|
1695
|
+
}
|
|
1696
|
+
warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
|
|
1697
|
+
return { kind: "refused" };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// A handle encode that timed out stores "" (handle-snapshot.ts
|
|
1701
|
+
// encodeHandleValue). With every handler push settled above, that only
|
|
1702
|
+
// happens for a promise the walk does not reach (inside a Map, Set, or class
|
|
1703
|
+
// instance), which never settles in time on a retry either.
|
|
1704
|
+
if (!record.handles && recordSegmentsHaveHandles(record, freshHandleStore)) {
|
|
1705
|
+
warnCaptureRefusedOnce(
|
|
1706
|
+
capture.key,
|
|
1707
|
+
"a handle value the handler pushed did not finish encoding into the doc " +
|
|
1708
|
+
"record. A promise inside a Map, Set, or class instance is not awaited " +
|
|
1709
|
+
"before the encode; push plain objects and arrays.",
|
|
1710
|
+
);
|
|
1711
|
+
return { kind: "refused" };
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const { fragmentSegments } = await import("../cache/segment-codec.js");
|
|
1715
|
+
const fragments = new Map(
|
|
1716
|
+
(await fragmentSegments(record.segments)).map((s) => [s.id, s]),
|
|
1717
|
+
);
|
|
1718
|
+
return {
|
|
1719
|
+
kind: "record",
|
|
1720
|
+
match: {
|
|
1721
|
+
...match,
|
|
1722
|
+
segments: match.segments.map((segment) => {
|
|
1723
|
+
const fragment =
|
|
1724
|
+
segment.type === "loader" ? undefined : fragments.get(segment.id);
|
|
1725
|
+
return fragment
|
|
1726
|
+
? {
|
|
1727
|
+
...segment,
|
|
1728
|
+
component: fragment.component,
|
|
1729
|
+
layout: fragment.layout,
|
|
1730
|
+
loading: fragment.loading,
|
|
1731
|
+
}
|
|
1732
|
+
: segment;
|
|
1733
|
+
}),
|
|
1734
|
+
},
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/** Whether the handle store holds handler pushes for the record's segments. */
|
|
1739
|
+
function recordSegmentsHaveHandles(
|
|
1740
|
+
record: CachedEntryData,
|
|
1741
|
+
handleStore: HandleStore,
|
|
1742
|
+
): boolean {
|
|
1743
|
+
for (const segment of record.segments) {
|
|
1744
|
+
const id = segment?.metadata?.id;
|
|
1745
|
+
if (id === undefined) continue;
|
|
1746
|
+
const data = handleStore.getDataForSegment(id, true);
|
|
1747
|
+
for (const name in data) {
|
|
1748
|
+
if (data[name]!.length > 0) return true;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
return false;
|
|
1351
1752
|
}
|
|
1352
1753
|
|
|
1353
1754
|
/**
|
|
@@ -1365,8 +1766,8 @@ function handlerLayerIsLive(
|
|
|
1365
1766
|
* - _requestTags: a fresh Set. The capture collects its OWN shell tags here —
|
|
1366
1767
|
* non-loader tags only, since loaders are masked — which is exactly the tag
|
|
1367
1768
|
* set a shell entry should be invalidatable by (loader tags belong to holes).
|
|
1368
|
-
* - _transitionWhen: a fresh [] so the capture's transition gating is its own.
|
|
1369
1769
|
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
1770
|
+
* - _dynamic / dynamic(): an own latch the capture reads.
|
|
1370
1771
|
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
1371
1772
|
* (already-finalized) metrics.
|
|
1372
1773
|
* - _renderBarrier family: an own barrier wired to the fresh handle store
|
|
@@ -1379,6 +1780,8 @@ function handlerLayerIsLive(
|
|
|
1379
1780
|
* capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
|
|
1380
1781
|
* triggering request, and the derived context inherits its post-middleware state
|
|
1381
1782
|
* (guarding is serve-time; the shell is never served without the full chain).
|
|
1783
|
+
* After the match, {@link settleCaptureRecord} writes the doc record and the
|
|
1784
|
+
* capture renders from it.
|
|
1382
1785
|
*
|
|
1383
1786
|
* A FRESH context (and match/render) per attempt is what makes the retry sound:
|
|
1384
1787
|
* the second attempt is a clean capture, not a resumption of the first.
|
|
@@ -1406,23 +1809,68 @@ async function attemptCapture(
|
|
|
1406
1809
|
await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
|
|
1407
1810
|
stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
|
|
1408
1811
|
|
|
1409
|
-
const
|
|
1812
|
+
const derivation = deriveShellCaptureContext(
|
|
1410
1813
|
reqCtx,
|
|
1411
1814
|
descriptor,
|
|
1412
1815
|
descriptor.navigationOnly ? { request, url } : undefined,
|
|
1413
1816
|
);
|
|
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.
|
|
1414
1820
|
const captureStartedAt = Date.now();
|
|
1415
1821
|
|
|
1416
1822
|
return runWithRequestContext(derivedCtx, async () => {
|
|
1417
|
-
|
|
1823
|
+
// One deadline for the whole capture (see
|
|
1824
|
+
// PartialPrerenderProps.captureTimeout): the match (handlers, and the
|
|
1825
|
+
// loaders they await), then the record, then the prerender with what is
|
|
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.
|
|
1828
|
+
const deadline =
|
|
1829
|
+
Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1830
|
+
const matched = await raceDeadline(
|
|
1831
|
+
ctx.router.match(request, { env }),
|
|
1832
|
+
deadline,
|
|
1833
|
+
);
|
|
1834
|
+
if (!matched.done) {
|
|
1835
|
+
stats.noShellCause =
|
|
1836
|
+
"the handlers (or a loader a handler awaits) did not return within " +
|
|
1837
|
+
"ppr.captureTimeout";
|
|
1838
|
+
return "no-shell";
|
|
1839
|
+
}
|
|
1840
|
+
const match = matched.value;
|
|
1418
1841
|
// A route that redirects has no shell to capture — bail (no store write, no
|
|
1419
1842
|
// retry: a redirect is deterministic).
|
|
1420
1843
|
if (match.redirect) return "redirect";
|
|
1421
1844
|
|
|
1422
1845
|
setRequestContextParams(match.params, match.routeName);
|
|
1423
1846
|
|
|
1424
|
-
const
|
|
1847
|
+
const settleStart = performance.now();
|
|
1848
|
+
const settled = await settleCaptureRecord(
|
|
1425
1849
|
match,
|
|
1850
|
+
derivation,
|
|
1851
|
+
descriptor,
|
|
1852
|
+
deadline,
|
|
1853
|
+
);
|
|
1854
|
+
stats.recordSettleMs = Math.round(performance.now() - settleStart);
|
|
1855
|
+
if (
|
|
1856
|
+
process.env.NODE_ENV !== "production" &&
|
|
1857
|
+
settled.kind === "record" &&
|
|
1858
|
+
stats.recordSettleMs >= SHELL_CAPTURE_BAKE_WARN_MS
|
|
1859
|
+
) {
|
|
1860
|
+
warnBakeCostOnce(
|
|
1861
|
+
descriptor.key,
|
|
1862
|
+
"the handler output (promises it passes or pushes, async server components)",
|
|
1863
|
+
stats.recordSettleMs,
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
if (settled.kind === "refused") return "refused";
|
|
1867
|
+
if (settled.kind === "timeout") {
|
|
1868
|
+
stats.noShellCause = settled.reason;
|
|
1869
|
+
return "no-shell";
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
const payload = buildFullPayload(
|
|
1873
|
+
settled.match,
|
|
1426
1874
|
ctx,
|
|
1427
1875
|
url,
|
|
1428
1876
|
derivedCtx,
|
|
@@ -1446,15 +1894,23 @@ async function attemptCapture(
|
|
|
1446
1894
|
// content (and tags propagated by async cache()/"use cache" reads) lands after
|
|
1447
1895
|
// this synchronous construction point, so snapshotting here dropped it — the
|
|
1448
1896
|
// shell-tag snapshot must sit behind the quiesce gate (issue #676).
|
|
1449
|
-
|
|
1897
|
+
const outcome = await captureAndStoreShell(
|
|
1450
1898
|
ssrModule,
|
|
1451
1899
|
flightStage.stream,
|
|
1452
|
-
freshHandleStore,
|
|
1453
1900
|
derivedCtx,
|
|
1454
|
-
|
|
1455
|
-
|
|
1901
|
+
{
|
|
1902
|
+
...descriptor,
|
|
1903
|
+
captureTimeout: Math.max(1, deadline - Date.now()),
|
|
1904
|
+
},
|
|
1456
1905
|
captureStartedAt,
|
|
1906
|
+
stats,
|
|
1457
1907
|
);
|
|
1908
|
+
if (outcome === "no-shell" && Date.now() >= deadline) {
|
|
1909
|
+
stats.noShellCause =
|
|
1910
|
+
"the shell render did not finish within what ppr.captureTimeout left " +
|
|
1911
|
+
`after the handler output settled (${stats.recordSettleMs}ms)`;
|
|
1912
|
+
}
|
|
1913
|
+
return outcome;
|
|
1458
1914
|
});
|
|
1459
1915
|
}
|
|
1460
1916
|
|
|
@@ -1463,13 +1919,20 @@ async function attemptCapture(
|
|
|
1463
1919
|
* shared by BOTH shell producers: the runtime background capture
|
|
1464
1920
|
* (attemptCapture, producer A) and the build-time prerender shell capture
|
|
1465
1921
|
* (prerender/build-shell-capture.ts, producer B — issue #699). One
|
|
1466
|
-
* implementation so the capture semantics — the
|
|
1467
|
-
*
|
|
1468
|
-
* scope — cannot drift between producers.
|
|
1922
|
+
* implementation so the capture semantics — the handle push funnel (handler
|
|
1923
|
+
* pushes settled, loader pushes masked), the capture guards, snapshot
|
|
1924
|
+
* recording, the implicit doc-cache scope — cannot drift between producers.
|
|
1469
1925
|
*/
|
|
1470
1926
|
export interface CaptureContextDerivation {
|
|
1471
1927
|
derivedCtx: RequestContext;
|
|
1472
1928
|
freshHandleStore: HandleStore;
|
|
1929
|
+
/**
|
|
1930
|
+
* Resolves once every handler handle push has settled at any depth of
|
|
1931
|
+
* plain objects and arrays (top-level promise, then the promises nested
|
|
1932
|
+
* in what it resolved to). The record-first step awaits it before the doc
|
|
1933
|
+
* record encodes its handles, so that encode never races a pending value.
|
|
1934
|
+
*/
|
|
1935
|
+
handlerPushesSettled: () => Promise<void>;
|
|
1473
1936
|
}
|
|
1474
1937
|
|
|
1475
1938
|
/**
|
|
@@ -1487,52 +1950,75 @@ export function deriveShellCaptureContext(
|
|
|
1487
1950
|
): CaptureContextDerivation {
|
|
1488
1951
|
const freshHandleStore = createHandleStore();
|
|
1489
1952
|
freshHandleStore.onError = reqCtx._handleStore.onError;
|
|
1490
|
-
//
|
|
1491
|
-
// (
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
//
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
//
|
|
1499
|
-
//
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
1504
|
-
//
|
|
1505
|
-
//
|
|
1506
|
-
//
|
|
1507
|
-
// (
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
// (
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1953
|
+
// The capture handle store's push is the single funnel every push wrapper
|
|
1954
|
+
// (setupLoaderAccess, createUseFunction, prerender) goes through; the store
|
|
1955
|
+
// exists only for this capture attempt, so the foreground store is
|
|
1956
|
+
// untouched. Two lanes:
|
|
1957
|
+
// - HANDLER pushes (outside a DSL loader scope: handler bodies, handler-
|
|
1958
|
+
// invoked ctx.use(loader) bodies, defers) are handler output, baked like
|
|
1959
|
+
// everything else the handler layer produces. They pass through
|
|
1960
|
+
// unmasked; settleHandlerPush tracks each one so the record-first step
|
|
1961
|
+
// (settleCaptureRecord) can wait for its promises, top-level and nested,
|
|
1962
|
+
// before the doc record encodes the handles a HIT replays. A route
|
|
1963
|
+
// cache() record's restore (handle-snapshot.ts restoreHandles) lands
|
|
1964
|
+
// here too: a value it restores through pushReplayed keeps that loader
|
|
1965
|
+
// as owner, so a HIT restores it as that loader's (pushRestored).
|
|
1966
|
+
// - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
|
|
1967
|
+
// loaders they await via ctx.use execute at capture (live loaders are
|
|
1968
|
+
// masked), their pushes are in the captured HTML (<head> title/meta,
|
|
1969
|
+
// useHandle echoes), and a HIT does not run a promise-free bake-lane
|
|
1970
|
+
// loader (loader-cache.ts), so the stored record carries them: each
|
|
1971
|
+
// settled, thenable-free push from a loader body keeps that loader as
|
|
1972
|
+
// `owner` (HandleStore.push), the record stores it
|
|
1973
|
+
// (CachedEntryData.handleOwners), and the HIT restores it through
|
|
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,
|
|
1982
|
+
// one with masked nested promises, or one outside a loader body (a
|
|
1983
|
+
// loader-cache replay names its loader: pushReplayed below) stays
|
|
1984
|
+
// tagged (HandleStore.push loaderPush) so the
|
|
1985
|
+
// handle encode cannot stall on a never-resolving mask; only the
|
|
1986
|
+
// captureHandles cache-write call site excludes tagged pushes, and the
|
|
1987
|
+
// loader records then carry `runs: 1` so a HIT runs those bodies for
|
|
1988
|
+
// them.
|
|
1989
|
+
const handlerPushSettles: Promise<void>[] = [];
|
|
1990
|
+
// A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
|
|
1991
|
+
// route cache() record's owned values, restoreHandles) re-pushes a loader's
|
|
1992
|
+
// recorded values through pushReplayed, which calls push() outside any
|
|
1993
|
+
// loader body: name that loader for the funnel so the record keeps them
|
|
1994
|
+
// under it too.
|
|
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
|
+
};
|
|
2014
|
+
const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
|
|
2015
|
+
freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
|
|
2016
|
+
replayOwner = loaderId;
|
|
2017
|
+
try {
|
|
2018
|
+
rawPushReplayed(handleName, segmentId, value, loaderId);
|
|
2019
|
+
} finally {
|
|
2020
|
+
replayOwner = undefined;
|
|
2021
|
+
}
|
|
1536
2022
|
};
|
|
1537
2023
|
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
1538
2024
|
freshHandleStore.push = (
|
|
@@ -1540,49 +2026,63 @@ export function deriveShellCaptureContext(
|
|
|
1540
2026
|
segmentId: string,
|
|
1541
2027
|
value: unknown,
|
|
1542
2028
|
) => {
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
2029
|
+
if (!isInsideLoaderScope()) {
|
|
2030
|
+
handlerPushSettles.push(settleNestedThenables(value));
|
|
2031
|
+
rawCapturePush(handleName, segmentId, value, false, replayOwner);
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
1546
2034
|
let maskedNestedThenable = false;
|
|
1547
|
-
const
|
|
2035
|
+
const mask = (v: unknown): unknown => {
|
|
1548
2036
|
const report: MaskReport = { thenable: false };
|
|
1549
2037
|
const masked = maskNestedContainerThenables(v, undefined, report);
|
|
1550
|
-
if (report.thenable)
|
|
1551
|
-
maskedNestedThenable = true;
|
|
1552
|
-
if (!pushedInLoaderScope) handleLiveness.holes = true;
|
|
1553
|
-
}
|
|
2038
|
+
if (report.thenable) maskedNestedThenable = true;
|
|
1554
2039
|
return masked;
|
|
1555
2040
|
};
|
|
1556
2041
|
let masked: unknown;
|
|
1557
|
-
let loaderPush =
|
|
2042
|
+
let loaderPush = true;
|
|
1558
2043
|
let owner: string | undefined;
|
|
1559
2044
|
if (isThenable(value)) {
|
|
1560
|
-
if (!pushedInLoaderScope) {
|
|
1561
|
-
handleLiveness.pendingPushes++;
|
|
1562
|
-
const settle = () => handleLiveness.pendingPushes--;
|
|
1563
|
-
value.then(settle, settle);
|
|
1564
|
-
}
|
|
1565
2045
|
// Deferred (thenable) loader pushes always keep the tag — the
|
|
1566
|
-
//
|
|
1567
|
-
masked = value.then(
|
|
2046
|
+
// carve-out below is for settled values only.
|
|
2047
|
+
masked = value.then(mask);
|
|
1568
2048
|
} else {
|
|
1569
|
-
masked =
|
|
1570
|
-
if (
|
|
1571
|
-
//
|
|
1572
|
-
// funnel comment above):
|
|
1573
|
-
//
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
2049
|
+
masked = mask(value);
|
|
2050
|
+
if (!maskedNestedThenable) {
|
|
2051
|
+
// A settled, thenable-free push from a loader body is shell
|
|
2052
|
+
// material (see the funnel comment above): only bake-lane loaders
|
|
2053
|
+
// and the loaders they await run at capture, and a HIT does not run
|
|
2054
|
+
// a promise-free bake-lane loader (loader-cache.ts), so the record
|
|
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;
|
|
2065
|
+
loaderPush = bodyLoaderId === undefined;
|
|
1578
2066
|
if (!loaderPush) owner = bodyLoaderId;
|
|
1579
2067
|
}
|
|
1580
2068
|
}
|
|
2069
|
+
// A push the record cannot keep reaches a HIT only from a live run of
|
|
2070
|
+
// its loader, so the loader records ask for one (runs: 1).
|
|
2071
|
+
if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
|
|
1581
2072
|
rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
|
|
1582
2073
|
};
|
|
1583
2074
|
|
|
2075
|
+
// The capture reuses the record keys its request resolved (CacheScope
|
|
2076
|
+
// resolveKeyFrom), read through the prototype: the map must exist on the
|
|
2077
|
+
// request before the derivation, or the capture would start its own and
|
|
2078
|
+
// run key() under the capture guard.
|
|
2079
|
+
reqCtx._resolvedCacheKeys ??= new Map();
|
|
1584
2080
|
const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
|
|
1585
2081
|
_handleStore: freshHandleStore,
|
|
2082
|
+
// The capture notes the route records it reads or writes itself; the
|
|
2083
|
+
// foreground's (a partial request's `partial:` record, say) are not the
|
|
2084
|
+
// ones this shell is captured from.
|
|
2085
|
+
_routeRecordWindow: undefined,
|
|
1586
2086
|
});
|
|
1587
2087
|
if (identity) {
|
|
1588
2088
|
derivedCtx.request = identity.request;
|
|
@@ -1609,11 +2109,20 @@ export function deriveShellCaptureContext(
|
|
|
1609
2109
|
// the whole capture snapshot in memory. Capture registrations die with this
|
|
1610
2110
|
// context; module-singleton stores stay registered by normal renders.
|
|
1611
2111
|
derivedCtx._explicitTaggedStores = new Set();
|
|
1612
|
-
derivedCtx._transitionWhen = [];
|
|
1613
2112
|
// Own list: the capture's render errors refuse the capture only, and the
|
|
1614
2113
|
// foreground's never reach it.
|
|
1615
2114
|
derivedCtx._renderErrors = [];
|
|
1616
2115
|
derivedCtx._shellCaptureRun = true;
|
|
2116
|
+
// Own dynamic() latch. The inherited method writes the FOREGROUND context
|
|
2117
|
+
// (its closure), which already served, and nothing read it for a runtime
|
|
2118
|
+
// capture: handler code that opts out after an await (a handler promise the
|
|
2119
|
+
// capture now waits for) must refuse THIS capture instead
|
|
2120
|
+
// (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
|
|
2121
|
+
// header latch is left alone: a capture's response is never sent.
|
|
2122
|
+
derivedCtx._dynamic = false;
|
|
2123
|
+
derivedCtx.dynamic = (): void => {
|
|
2124
|
+
derivedCtx._dynamic = true;
|
|
2125
|
+
};
|
|
1617
2126
|
// Own-property reset: an inherited flag would make serializeSegments store
|
|
1618
2127
|
// a double-encoded fragment. No current arming site reaches a capture
|
|
1619
2128
|
// render (kept as defense-in-depth for a future one).
|
|
@@ -1624,32 +2133,32 @@ export function deriveShellCaptureContext(
|
|
|
1624
2133
|
// foreground rango.request span ended (orphan spans in the trace).
|
|
1625
2134
|
// _tracing is otherwise inherited through Object.create(reqCtx).
|
|
1626
2135
|
derivedCtx._tracing = undefined;
|
|
1627
|
-
// Bake-lane loader containers
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
// 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.
|
|
1631
2141
|
derivedCtx._shellCaptureLoaderRecords = new Map();
|
|
1632
|
-
// Own onResponse list so the capture's match-middleware callbacks (the
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
//
|
|
1636
|
-
// without
|
|
1637
|
-
// 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.
|
|
1638
2147
|
derivedCtx._onResponseCallbacks = [];
|
|
1639
2148
|
|
|
1640
|
-
// Capture data snapshot:
|
|
1641
|
-
// wrapper on the DERIVED context's store (own property, so the
|
|
1642
|
-
// reqCtx._cacheStore is untouched — the snapshot is per-capture).
|
|
1643
|
-
//
|
|
1644
|
-
//
|
|
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.
|
|
1645
2155
|
//
|
|
1646
|
-
// Cache writes are deferred (waitUntil):
|
|
1647
|
-
//
|
|
1648
|
-
//
|
|
1649
|
-
//
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
derivedCtx._shellCaptureHandleLiveness = handleLiveness;
|
|
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.
|
|
1653
2162
|
if (reqCtx._cacheStore) {
|
|
1654
2163
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
1655
2164
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -1658,12 +2167,13 @@ export function deriveShellCaptureContext(
|
|
|
1658
2167
|
recordingStore.trackWrite(p);
|
|
1659
2168
|
reqCtx.waitUntil(() => p);
|
|
1660
2169
|
};
|
|
1661
|
-
//
|
|
2170
|
+
// The doc record (capture side): the implicit doc-cache scope makes the
|
|
1662
2171
|
// capture's match write ALL matched non-loader segments as one doc-keyed
|
|
1663
2172
|
// segment record — into the snapshot only (SnapshotOnlySegmentStore), so
|
|
1664
2173
|
// the record dies with the shell entry and the next capture's lookup
|
|
1665
|
-
// still misses (handlers re-run on recapture).
|
|
1666
|
-
// cache scope
|
|
2174
|
+
// still misses (handlers re-run on recapture). A route deriving its own
|
|
2175
|
+
// cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
|
|
2176
|
+
// writes the same record for it.
|
|
1667
2177
|
derivedCtx._shellImplicitCache = {
|
|
1668
2178
|
ttl: descriptor.ttl,
|
|
1669
2179
|
swr: descriptor.swr,
|
|
@@ -1672,17 +2182,26 @@ export function deriveShellCaptureContext(
|
|
|
1672
2182
|
};
|
|
1673
2183
|
}
|
|
1674
2184
|
|
|
1675
|
-
return {
|
|
2185
|
+
return {
|
|
2186
|
+
derivedCtx,
|
|
2187
|
+
freshHandleStore,
|
|
2188
|
+
handlerPushesSettled: () =>
|
|
2189
|
+
Promise.all(handlerPushSettles).then(() => undefined),
|
|
2190
|
+
};
|
|
1676
2191
|
}
|
|
1677
2192
|
|
|
1678
2193
|
/**
|
|
1679
|
-
*
|
|
1680
|
-
* captureShellHTML, and store the result.
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
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).
|
|
1686
2205
|
*
|
|
1687
2206
|
* A `no-shell` result from captureShellHTML is the only retryable outcome. Every
|
|
1688
2207
|
* captureShellHTML error propagates to reportCacheError and is NOT retried. The
|
|
@@ -1692,117 +2211,45 @@ export function deriveShellCaptureContext(
|
|
|
1692
2211
|
async function captureAndStoreShell(
|
|
1693
2212
|
ssrModule: SSRModule,
|
|
1694
2213
|
rscStream: ReadableStream<Uint8Array>,
|
|
1695
|
-
handleStore: HandleStore,
|
|
1696
2214
|
reqCtx: RequestContext<any>,
|
|
1697
2215
|
capture: ShellCaptureDescriptor,
|
|
1698
|
-
|
|
1699
|
-
|
|
2216
|
+
captureStartedAt: number,
|
|
2217
|
+
stats: CaptureAttemptStats = {},
|
|
1700
2218
|
): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
|
|
1701
2219
|
const captureShellHTML = ssrModule.captureShellHTML!;
|
|
1702
2220
|
|
|
1703
|
-
|
|
1704
|
-
// -> handleStore.stream()) converges and completes even though masked loaders
|
|
1705
|
-
// never resolve. handleStore.settled gates ONLY on tracked HANDLER promises
|
|
1706
|
-
// (handleStore.track, via trackHandler) — NOT on deferred handle VALUES pushed
|
|
1707
|
-
// through ctx.use(Handle).defer(), which are plain pushed promises. So seal()
|
|
1708
|
-
// does not reject or hang on outstanding defers: settled resolves once the
|
|
1709
|
-
// handlers settle, and each deferred slot resolves on its own createDeferred
|
|
1710
|
-
// timeout (defer.ts, default 10s) or when its resolver fires. A defer whose
|
|
1711
|
-
// resolver depends on a masked loader can never fire, so it stays pending until
|
|
1712
|
-
// that 10s timeout — longer than maxWaitMs (5s). At the abort the handles
|
|
1713
|
-
// generator has not yielded, SsrRoot suspends at the root (consumeAsyncGenerator
|
|
1714
|
-
// sits above every boundary), the prelude comes back trivial, and
|
|
1715
|
-
// captureShellHTML's sanity gate returns null: the designed fail-safe no-op, not
|
|
1716
|
-
// an error. This mirrors the matchForPrerender (router/prerender-match.ts)
|
|
1717
|
-
// seal+settled regime, which also excludes loaders. See
|
|
1718
|
-
// docs/design/ppr-shell-resume.md ("Loaders and handles").
|
|
1719
|
-
handleStore.seal();
|
|
1720
|
-
|
|
1721
|
-
// Handles contract, shell half ("nesting = liveness"): TOP-LEVEL pushed handle
|
|
1722
|
-
// promises are BAKED into the shell — resolvedHandleStream awaits them before
|
|
1723
|
-
// the payload's handles row emits. A pushed promise with real latency would lose
|
|
1724
|
-
// the byte-quiet race (the pending handles row emits no bytes, the gate freezes,
|
|
1725
|
-
// the row is dropped, SsrRoot suspends at the root), so the gate is HELD open
|
|
1726
|
-
// until the same await completes: handlesBaked mirrors resolvedHandleStream's
|
|
1727
|
-
// resolution (getData waits the tracked-handler barrier; resolveDeferredHandleValues
|
|
1728
|
-
// awaits the top-level thenables). NESTED promises inside pushed containers are
|
|
1729
|
-
// shallow-skipped by isThenable and never hold the gate — they stay holes.
|
|
1730
|
-
// Bounded by maxWaitMs like every quiesce input (a defer hanging on a masked
|
|
1731
|
-
// loader still ends in the sanity-gate refusal).
|
|
1732
|
-
const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
|
|
1733
|
-
// Bake-lane loader containers hold the gate the same way (loader-container-
|
|
1734
|
-
// bake): a boundary-less container with real latency (a 100ms layout loader)
|
|
1735
|
-
// would otherwise lose the 2-hop byte-quiet race — the pending loaderData row
|
|
1736
|
-
// emits no bytes, the gate freezes, and the awaiting tree pins above <body>.
|
|
1737
|
-
// The records map is fully populated before this point (loader promises are
|
|
1738
|
-
// created during the capture's match()), so the hold covers every bake-lane
|
|
1739
|
-
// container. allSettled: a REJECTED container releases the hold (the drain
|
|
1740
|
-
// below refuses the capture); nested promises INSIDE a container never hold
|
|
1741
|
-
// the gate — they stay holes. Bounded by maxWaitMs like every quiesce input.
|
|
1742
|
-
const loaderRecordsForHold = reqCtx._shellCaptureLoaderRecords;
|
|
1743
|
-
const holdUntil =
|
|
1744
|
-
loaderRecordsForHold && loaderRecordsForHold.size > 0
|
|
1745
|
-
? Promise.allSettled([handlesBaked, ...loaderRecordsForHold.values()])
|
|
1746
|
-
: handlesBaked;
|
|
1747
|
-
// Bake-cost attribution: record how long each bake source held the gate,
|
|
1748
|
-
// measured from capture start. Side-channel observers only — holdUntil and
|
|
1749
|
-
// its consumers are untouched, and a rejected source is recorded the same
|
|
1750
|
-
// (allSettled swallows it; the drain below owns the refusal). Folded into
|
|
1751
|
-
// stats.bakeWaitMs (+ a once-per-key dev warning) after a successful
|
|
1752
|
-
// capture; on a refusal the other warnings own the story.
|
|
1753
|
-
const bakeStart = performance.now();
|
|
1754
|
-
const bakeWaits: { source: string; ms: number }[] = [];
|
|
1755
|
-
const observeBake = (source: string, promise: Promise<unknown>): void => {
|
|
1756
|
-
const record = (): void => {
|
|
1757
|
-
bakeWaits.push({
|
|
1758
|
-
source,
|
|
1759
|
-
ms: Math.round(performance.now() - bakeStart),
|
|
1760
|
-
});
|
|
1761
|
-
};
|
|
1762
|
-
promise.then(record, record);
|
|
1763
|
-
};
|
|
1764
|
-
observeBake("top-level pushed handle promises", handlesBaked);
|
|
1765
|
-
if (loaderRecordsForHold) {
|
|
1766
|
-
for (const [loaderId, promise] of loaderRecordsForHold) {
|
|
1767
|
-
observeBake(`bake-lane segment loader "${loaderId}"`, promise);
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
|
-
const gate = gateFlightForCapture(rscStream, undefined, holdUntil);
|
|
2221
|
+
const gate = gateFlightForCapture(rscStream);
|
|
1771
2222
|
// Quiesce = handles settled AND the Flight shell rows went task-quiet. Either
|
|
1772
2223
|
// half stalling is bounded by captureShellHTML's maxWaitMs.
|
|
1773
|
-
const quiesce = Promise.all([
|
|
2224
|
+
const quiesce = Promise.all([reqCtx._handleStore.settled, gate.quiesce]).then(
|
|
1774
2225
|
() => {},
|
|
1775
2226
|
);
|
|
1776
2227
|
|
|
1777
|
-
// Deterministic
|
|
1778
|
-
// guard error either rejects the prerender itself
|
|
1779
|
-
// lands in the catch) or is swallowed into
|
|
1780
|
-
// completes — caught after the try).
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
const
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
PPR_LANE_HINT,
|
|
1803
|
-
);
|
|
1804
|
-
return "refused";
|
|
1805
|
-
};
|
|
2228
|
+
// Deterministic capture-guard refusal (refuseOnCaptureGuard), checked at
|
|
2229
|
+
// BOTH exits below: the guard error either rejects the prerender itself
|
|
2230
|
+
// (boundary-less segment — lands in the catch) or is swallowed into
|
|
2231
|
+
// per-loader error UI (the render completes — caught after the try).
|
|
2232
|
+
const refuseOnGuardTrip = (): "refused" | undefined =>
|
|
2233
|
+
refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
|
|
2234
|
+
|
|
2235
|
+
// Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
|
|
2236
|
+
// the tasks React reports as still pending at the capture's abort. React's
|
|
2237
|
+
// componentStack is computed on read, so it is read only while there is room.
|
|
2238
|
+
let pendingStacks: string[] | undefined;
|
|
2239
|
+
let onAbortedTask:
|
|
2240
|
+
| ((errorInfo: { componentStack?: string } | undefined) => void)
|
|
2241
|
+
| undefined;
|
|
2242
|
+
if (process.env.NODE_ENV !== "production") {
|
|
2243
|
+
const stacks: string[] = [];
|
|
2244
|
+
pendingStacks = stacks;
|
|
2245
|
+
onAbortedTask = (errorInfo) => {
|
|
2246
|
+
if (stacks.length >= MAX_PENDING_STACKS) return;
|
|
2247
|
+
const componentStack = errorInfo?.componentStack;
|
|
2248
|
+
if (componentStack && !stacks.includes(componentStack)) {
|
|
2249
|
+
stacks.push(componentStack);
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
1806
2253
|
|
|
1807
2254
|
try {
|
|
1808
2255
|
// captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
|
|
@@ -1821,6 +2268,7 @@ async function captureAndStoreShell(
|
|
|
1821
2268
|
onError: (error) => {
|
|
1822
2269
|
reqCtx._renderErrors?.push(error);
|
|
1823
2270
|
},
|
|
2271
|
+
onAbortedTask,
|
|
1824
2272
|
}),
|
|
1825
2273
|
);
|
|
1826
2274
|
} catch (error) {
|
|
@@ -1848,71 +2296,24 @@ async function captureAndStoreShell(
|
|
|
1848
2296
|
if (refused) return refused;
|
|
1849
2297
|
|
|
1850
2298
|
if (result === null) {
|
|
2299
|
+
stats.pendingStacks = pendingStacks;
|
|
1851
2300
|
return "no-shell";
|
|
1852
2301
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
//
|
|
1856
|
-
//
|
|
1857
|
-
//
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
}
|
|
1862
|
-
if (slowestBake && slowestBake.ms > 0) {
|
|
1863
|
-
if (stats) stats.bakeWaitMs = slowestBake.ms;
|
|
1864
|
-
if (
|
|
1865
|
-
process.env.NODE_ENV !== "production" &&
|
|
1866
|
-
slowestBake.ms >= SHELL_CAPTURE_BAKE_WARN_MS
|
|
1867
|
-
) {
|
|
1868
|
-
warnBakeCostOnce(capture.key, slowestBake.source, slowestBake.ms);
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
// Store per the flag's key/ttl/swr/tags, into the flag's store: the middleware
|
|
1873
|
-
// threads the SAME store it resolved for its getShell read (options.store ??
|
|
1874
|
-
// _cacheStore), so a store-attached middleware writes captures where it reads
|
|
1875
|
-
// them. The _cacheStore fallback covers a flag armed without a store (tests).
|
|
1876
|
-
// reactVersion is read from the same React.version import the middleware
|
|
1877
|
-
// validates reads against, so capture and serve always agree.
|
|
1878
|
-
// Fire the capture's isolated onResponse callbacks with a synthetic 200 so
|
|
1879
|
-
// the ring-3 segment cache write (cacheScope.cacheRoute, registered via
|
|
1880
|
-
// onResponse by the cache-store match-middleware and gated on a 200) runs
|
|
1881
|
-
// DURING capture, routed through the recording store. The foreground path
|
|
1882
|
-
// never fires for the capture — it builds no Response — so without this a
|
|
1883
|
-
// cache() SEGMENT that MISSED at capture would be rendered fresh into the
|
|
1884
|
-
// prelude yet never written, hence never recorded, and would drift on a HIT
|
|
1885
|
-
// (an item-family "use cache" write already runs inline during the render, so
|
|
1886
|
-
// it needs none of this; only segment writes are onResponse-gated). The
|
|
1887
|
-
// derived context's own _onResponseCallbacks holds only capture match-
|
|
1888
|
-
// middleware callbacks (HTTP middleware never runs for a capture), so firing
|
|
1889
|
-
// them is safe. Best-effort: a throwing callback must not fail the capture.
|
|
1890
|
-
const responseCallbacks = reqCtx._onResponseCallbacks;
|
|
1891
|
-
if (responseCallbacks && responseCallbacks.length > 0) {
|
|
1892
|
-
const synthetic = new Response(null, { status: 200 });
|
|
1893
|
-
for (const cb of responseCallbacks) {
|
|
1894
|
-
try {
|
|
1895
|
-
cb(synthetic);
|
|
1896
|
-
} catch {
|
|
1897
|
-
// A capture-time cache write that throws is degradation, not failure.
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// Drain the capture data snapshot from the recording store on the derived
|
|
1903
|
-
// context. Await the deferred cache writes first so a MISS-at-capture value
|
|
1904
|
-
// (setItem/set scheduled under waitUntil, including the segment write just
|
|
1905
|
-
// fired) is pinned, not just read-hits. When no recording store is installed
|
|
1906
|
-
// (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.
|
|
1907
2310
|
const recording = getRecordingStore(reqCtx._cacheStore);
|
|
1908
|
-
let snapshot: ShellSnapshotRecord[]
|
|
2311
|
+
let snapshot: ShellSnapshotRecord[] = [];
|
|
1909
2312
|
if (recording) {
|
|
1910
2313
|
const settleStart = performance.now();
|
|
1911
2314
|
await recording.settleWrites(SHELL_SNAPSHOT_WRITE_SETTLE_MS);
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
}
|
|
1915
|
-
snapshot = recording.drainSnapshot();
|
|
2315
|
+
stats.writeSettleMs = Math.round(performance.now() - settleStart);
|
|
2316
|
+
snapshot = recording.drainSnapshot() ?? [];
|
|
1916
2317
|
}
|
|
1917
2318
|
|
|
1918
2319
|
// Pin the bake-lane loader containers (loader family). Settled containers
|
|
@@ -1934,6 +2335,8 @@ async function captureAndStoreShell(
|
|
|
1934
2335
|
let serializeContainer:
|
|
1935
2336
|
| typeof import("../cache/segment-codec.js").serializeResult
|
|
1936
2337
|
| undefined;
|
|
2338
|
+
// Capture-wide, read once: every record of this snapshot agrees.
|
|
2339
|
+
const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
|
|
1937
2340
|
for (const [segmentKey, containerPromise] of loaderRecords) {
|
|
1938
2341
|
const elided = await elideLoaderContainer(containerPromise);
|
|
1939
2342
|
if (elided.state === "rejected") {
|
|
@@ -1979,14 +2382,18 @@ async function captureAndStoreShell(
|
|
|
1979
2382
|
reqCtx._renderErrors?.push(error);
|
|
1980
2383
|
});
|
|
1981
2384
|
if (serialized !== null) {
|
|
1982
|
-
|
|
2385
|
+
snapshot.push({
|
|
1983
2386
|
family: "loader",
|
|
1984
2387
|
key: segmentKey,
|
|
1985
2388
|
// The hole bit rides with the record so the HIT overlay knows
|
|
1986
2389
|
// without rescanning whether the pin can resolve immediately
|
|
1987
2390
|
// (holes: 0) or must wait for the fresh run's live promises
|
|
1988
2391
|
// (holes: 1). See ShellSnapshotLoaderValue.
|
|
1989
|
-
value: {
|
|
2392
|
+
value: {
|
|
2393
|
+
value: serialized,
|
|
2394
|
+
holes: elided.hasHole ? 1 : 0,
|
|
2395
|
+
runs,
|
|
2396
|
+
},
|
|
1990
2397
|
});
|
|
1991
2398
|
}
|
|
1992
2399
|
} catch {
|
|
@@ -2006,50 +2413,114 @@ async function captureAndStoreShell(
|
|
|
2006
2413
|
const renderErrors = reqCtx._renderErrors;
|
|
2007
2414
|
if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
|
|
2008
2415
|
|
|
2009
|
-
//
|
|
2010
|
-
//
|
|
2011
|
-
//
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
if (
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
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.
|
|
2419
|
+
const docKey = reqCtx._shellImplicitCache?.docKey;
|
|
2420
|
+
let prunedRecords: string | undefined;
|
|
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;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
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
|
|
2435
|
+
// exempt: without it a HIT could not serve at all. The whole entry is
|
|
2436
|
+
// bounded separately below, by the store's value limit.
|
|
2437
|
+
if (snapshot.length > 0) {
|
|
2438
|
+
const docRecord =
|
|
2439
|
+
docKey !== undefined
|
|
2440
|
+
? snapshot.find((r) => r.family === "segment" && r.key === docKey)
|
|
2441
|
+
: undefined;
|
|
2442
|
+
const pins = docRecord
|
|
2443
|
+
? snapshot.filter((r) => r !== docRecord)
|
|
2444
|
+
: snapshot;
|
|
2445
|
+
const pinBytes =
|
|
2446
|
+
pins.length > 0
|
|
2447
|
+
? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
|
|
2448
|
+
: 0;
|
|
2449
|
+
stats.snapshotBytes = pinBytes;
|
|
2024
2450
|
const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
|
|
2025
|
-
if (
|
|
2026
|
-
warnSnapshotOverCapOnce(capture.key,
|
|
2027
|
-
snapshot =
|
|
2028
|
-
|
|
2451
|
+
if (pinBytes > cap) {
|
|
2452
|
+
warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
|
|
2453
|
+
snapshot = docRecord ? [docRecord] : [];
|
|
2454
|
+
stats.snapshotSkipped = true;
|
|
2029
2455
|
}
|
|
2030
2456
|
}
|
|
2031
2457
|
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
//
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
2041
|
-
|
|
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.
|
|
2462
|
+
const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
|
|
2463
|
+
|
|
2464
|
+
// Shell tags: what the shell renders from, and nothing else. The handler
|
|
2465
|
+
// layer is the doc record, whose tags are what its content recorded (the
|
|
2466
|
+
// handlers, the server components its serialization rendered, the loaders
|
|
2467
|
+
// they consumed, the handle values; cache-scope.ts collectRecordTags), or
|
|
2468
|
+
// what a replayed route cache() record carried (recordSegmentTags). So a
|
|
2469
|
+
// capture that rendered fresh and one that replayed the route's record
|
|
2470
|
+
// store the same tags. Bake-lane loaders add theirs (SHELL_BAKE_TAG_OWNER):
|
|
2471
|
+
// their data is in the shell, and no handler reads it. A live-lane loader
|
|
2472
|
+
// never runs at capture, and one whose cache() config tags are recorded
|
|
2473
|
+
// anyway is a hole's, so request-level tags are not the source. Read at
|
|
2474
|
+
// the write barrier: the record settled and the deferred writes were
|
|
2475
|
+
// awaited, so a tag recorded after an await (#676) is in. A capture
|
|
2476
|
+
// without a doc record (the prerender store served its match) keeps the
|
|
2477
|
+
// request-level set. Union with the route's static ppr.tags.
|
|
2478
|
+
const docRecordValue = storedDocKey
|
|
2479
|
+
? (snapshot.find((r) => r.family === "segment" && r.key === storedDocKey)
|
|
2480
|
+
?.value as CachedEntryData | undefined)
|
|
2481
|
+
: undefined;
|
|
2482
|
+
const collected = docRecordValue
|
|
2483
|
+
? [
|
|
2484
|
+
...(docRecordValue.tags ?? []),
|
|
2485
|
+
...getSegmentTags(reqCtx, SHELL_BAKE_TAG_OWNER),
|
|
2486
|
+
]
|
|
2487
|
+
: [...reqCtx._requestTags];
|
|
2042
2488
|
const union = new Set<string>([...(capture.tags ?? []), ...collected]);
|
|
2043
2489
|
const shellTags = union.size > 0 ? [...union] : undefined;
|
|
2044
2490
|
|
|
2491
|
+
// Whole-entry guard: the prelude, postponed state, snapshot, and tags
|
|
2492
|
+
// travel in one stored value. Over the store's limit (Cloudflare KV:
|
|
2493
|
+
// 25 MiB) the write would fail inside waitUntil and every MISS would
|
|
2494
|
+
// recapture, so refuse here (the key backs off) instead. Measured as the
|
|
2495
|
+
// stores write it (estimateShellEntryBytes): the postponed state rides a
|
|
2496
|
+
// JSON head as an escaped string.
|
|
2497
|
+
const entryBytes = estimateShellEntryBytes({
|
|
2498
|
+
// A navigation-only entry stores no document half.
|
|
2499
|
+
preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
|
|
2500
|
+
postponed: capture.navigationOnly ? undefined : result.postponed,
|
|
2501
|
+
snapshot,
|
|
2502
|
+
tags: shellTags,
|
|
2503
|
+
docKey: storedDocKey,
|
|
2504
|
+
prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
|
|
2505
|
+
});
|
|
2506
|
+
stats.entryBytes = entryBytes;
|
|
2507
|
+
const entryLimit =
|
|
2508
|
+
(capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
|
|
2509
|
+
DEFAULT_SHELL_ENTRY_MAX_BYTES;
|
|
2510
|
+
if (entryBytes > entryLimit) {
|
|
2511
|
+
warnCaptureRefusedOnce(
|
|
2512
|
+
capture.key,
|
|
2513
|
+
`the shell entry is ${entryBytes} bytes (prelude, postponed state, ` +
|
|
2514
|
+
`snapshot, and head), over the store's ${entryLimit}-byte value limit. ` +
|
|
2515
|
+
"Shrink the page the shell bakes, or move large regions under a live " +
|
|
2516
|
+
"loader's boundary.",
|
|
2517
|
+
);
|
|
2518
|
+
return "refused";
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2045
2521
|
// Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
|
|
2046
2522
|
// that choice only to operators who enabled structured capture diagnostics.
|
|
2047
|
-
if (
|
|
2048
|
-
capture.debugSink &&
|
|
2049
|
-
stats &&
|
|
2050
|
-
bakedLoaderMaterial &&
|
|
2051
|
-
shellTags === undefined
|
|
2052
|
-
) {
|
|
2523
|
+
if (capture.debugSink && bakedLoaderMaterial && shellTags === undefined) {
|
|
2053
2524
|
stats.untaggedBake = true;
|
|
2054
2525
|
}
|
|
2055
2526
|
|
|
@@ -2065,48 +2536,76 @@ async function captureAndStoreShell(
|
|
|
2065
2536
|
...(capture.navigationOnly
|
|
2066
2537
|
? {}
|
|
2067
2538
|
: {
|
|
2068
|
-
//
|
|
2069
|
-
//
|
|
2070
|
-
|
|
2071
|
-
// reads the whole ArrayBuffer it is handed.
|
|
2072
|
-
prelude: bufferToBase64(
|
|
2073
|
-
result.prelude.slice().buffer as ArrayBuffer,
|
|
2074
|
-
),
|
|
2539
|
+
// bytesToBase64 encodes the view's own region, so a prelude
|
|
2540
|
+
// that is a subarray of a larger buffer encodes correctly.
|
|
2541
|
+
prelude: bytesToBase64(result.prelude),
|
|
2075
2542
|
postponed: result.postponed,
|
|
2076
2543
|
}),
|
|
2077
2544
|
reactVersion: React.version,
|
|
2078
2545
|
buildVersion: capture.buildVersion,
|
|
2079
2546
|
// The theme this capture's payload was built with (buildFullPayload
|
|
2080
|
-
// reads
|
|
2081
|
-
//
|
|
2082
|
-
// ShellCacheEntry.initialTheme.
|
|
2083
|
-
initialTheme: reqCtx
|
|
2547
|
+
// reads payloadInitialTheme off the derived context: the no-cookie
|
|
2548
|
+
// default, #971). The serve tail replays it so the resume tree
|
|
2549
|
+
// matches the frozen prelude — see ShellCacheEntry.initialTheme.
|
|
2550
|
+
initialTheme: payloadInitialTheme(reqCtx),
|
|
2084
2551
|
snapshot,
|
|
2552
|
+
prunedRecords: snapshot.length > 0 ? prunedRecords : undefined,
|
|
2085
2553
|
// The canonical doc segment record's key, published by the doc
|
|
2086
|
-
// scope's cacheRoute during this capture's match
|
|
2087
|
-
//
|
|
2088
|
-
//
|
|
2089
|
-
docKey:
|
|
2554
|
+
// scope's cacheRoute during this capture's match. Every HIT tail
|
|
2555
|
+
// looks the record up by this key (serveShellHit fixedDocKey);
|
|
2556
|
+
// undefined only for a prerender-served entry.
|
|
2557
|
+
docKey: storedDocKey,
|
|
2090
2558
|
navigationOnly: capture.navigationOnly,
|
|
2091
|
-
// Handler-layer liveness folded at the barrier: nested thenables in
|
|
2092
|
-
// handler-scoped pushes, handler pushes still pending (liveness
|
|
2093
|
-
// unknowable), or a handler-invoked loader execution — any of them
|
|
2094
|
-
// refuses the FAST PATH, not the capture. See
|
|
2095
|
-
// _shellCaptureHandleLiveness.
|
|
2096
|
-
handlerLiveHoles: handlerLayerIsLive(
|
|
2097
|
-
reqCtx._shellCaptureHandleLiveness,
|
|
2098
|
-
),
|
|
2099
|
-
transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
|
|
2100
2559
|
createdAt: captureStartedAt,
|
|
2101
2560
|
};
|
|
2561
|
+
// Capped to the route record the capture used: the policy the
|
|
2562
|
+
// store would apply (resolveTtl/resolveSwrWindow, as putShell does),
|
|
2563
|
+
// made explicit.
|
|
2564
|
+
const record = reqCtx._routeRecordWindow;
|
|
2565
|
+
let window: { ttl?: number; swr?: number } = {
|
|
2566
|
+
ttl: capture.ttl,
|
|
2567
|
+
swr: capture.swr,
|
|
2568
|
+
};
|
|
2569
|
+
if (record) {
|
|
2570
|
+
const capped = capShellWindow(
|
|
2571
|
+
resolveTtl(capture.ttl, store.defaults, DEFAULT_FUNCTION_TTL),
|
|
2572
|
+
resolveSwrWindow(capture.swr, store.defaults),
|
|
2573
|
+
record,
|
|
2574
|
+
Date.now(),
|
|
2575
|
+
);
|
|
2576
|
+
if (!capped) {
|
|
2577
|
+
// Dead on arrival, not stored. A record this attempt wrote ran
|
|
2578
|
+
// out between its write and the store, which a retry repeats:
|
|
2579
|
+
// warn, refuse, back off. A record it read was near its end (its
|
|
2580
|
+
// age, queue wait): `expired` retries on a fresh match.
|
|
2581
|
+
const route = reqCtx._routeName ?? capture.key;
|
|
2582
|
+
if (record.written) {
|
|
2583
|
+
const sinceWriteMs =
|
|
2584
|
+
Date.now() - (record.freshUntil - record.ttl * 1000);
|
|
2585
|
+
warnRecordRanOutOnce(
|
|
2586
|
+
route,
|
|
2587
|
+
record,
|
|
2588
|
+
`${sinceWriteMs} ms after this capture wrote it`,
|
|
2589
|
+
);
|
|
2590
|
+
return "refused";
|
|
2591
|
+
}
|
|
2592
|
+
stats.expired = {
|
|
2593
|
+
route,
|
|
2594
|
+
record,
|
|
2595
|
+
captureMs: Date.now() - captureStartedAt,
|
|
2596
|
+
};
|
|
2597
|
+
return "expired";
|
|
2598
|
+
}
|
|
2599
|
+
window = capped;
|
|
2600
|
+
}
|
|
2102
2601
|
const storeWrite = await store.putShell(
|
|
2103
2602
|
capture.key,
|
|
2104
2603
|
entry,
|
|
2105
|
-
|
|
2106
|
-
|
|
2604
|
+
window.ttl,
|
|
2605
|
+
window.swr,
|
|
2107
2606
|
shellTags,
|
|
2108
2607
|
);
|
|
2109
|
-
if (
|
|
2608
|
+
if (storeWrite) stats.storeWrite = storeWrite;
|
|
2110
2609
|
if (storeWrite === "invalidated") {
|
|
2111
2610
|
warnCaptureRefusedOnce(
|
|
2112
2611
|
capture.key,
|
|
@@ -2176,3 +2675,18 @@ export {
|
|
|
2176
2675
|
REFUSED_CAPTURE_MAX_MS,
|
|
2177
2676
|
REFUSED_CAPTURE_DEV_MAX_MS,
|
|
2178
2677
|
};
|
|
2678
|
+
|
|
2679
|
+
/**
|
|
2680
|
+
* @internal Reset this module's per-isolate state: the capture stampede
|
|
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.
|
|
2685
|
+
*/
|
|
2686
|
+
export function resetShellCaptureStateForTests(): void {
|
|
2687
|
+
inFlightCaptures.clear();
|
|
2688
|
+
refusedCaptures.clear();
|
|
2689
|
+
resetShellWarningsForTests();
|
|
2690
|
+
storedCaptureSeqs.clear();
|
|
2691
|
+
lastCaptureEventsForTiming.clear();
|
|
2692
|
+
}
|