@rangojs/router 0.17.0 → 0.18.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/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- 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 +185 -44
- 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 +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- 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 +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -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 +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- 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 +990 -348
- 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 +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import React from "react";
|
|
20
|
-
import { isLoaderDataResult } from "../types.js";
|
|
21
|
-
import {
|
|
20
|
+
import { isLoaderDataResult, type MatchResult } from "../types.js";
|
|
21
|
+
import { bytesToBase64 } from "../cache/cf/cf-base64.js";
|
|
22
|
+
import { SHELL_BAKE_TAG_OWNER, getSegmentTags } from "../cache/cache-tag.js";
|
|
22
23
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
23
24
|
import { runBackground } from "../cache/background-task.js";
|
|
24
25
|
import {
|
|
@@ -41,10 +42,17 @@ import {
|
|
|
41
42
|
wireRenderBarrier,
|
|
42
43
|
UNTRACKED_BACKGROUND_TASK,
|
|
43
44
|
type RequestContext,
|
|
45
|
+
type RouteRecordWindow,
|
|
44
46
|
} from "../server/request-context.js";
|
|
47
|
+
import {
|
|
48
|
+
DEFAULT_FUNCTION_TTL,
|
|
49
|
+
resolveSwrWindow,
|
|
50
|
+
resolveTtl,
|
|
51
|
+
} from "../cache/cache-policy.js";
|
|
45
52
|
import { createHandleStore, type HandleStore } from "../server/handle-store.js";
|
|
46
53
|
import {
|
|
47
54
|
maskNestedContainerThenables,
|
|
55
|
+
settleNestedThenables,
|
|
48
56
|
type MaskReport,
|
|
49
57
|
} from "../router/segment-resolution/mask-nested.js";
|
|
50
58
|
import {
|
|
@@ -53,6 +61,7 @@ import {
|
|
|
53
61
|
} from "../server/context.js";
|
|
54
62
|
import { isThenable } from "../handles/is-thenable.js";
|
|
55
63
|
import type {
|
|
64
|
+
CachedEntryData,
|
|
56
65
|
ShellCacheEntry,
|
|
57
66
|
SegmentCacheStore,
|
|
58
67
|
ShellSnapshotRecord,
|
|
@@ -64,11 +73,16 @@ import {
|
|
|
64
73
|
import {
|
|
65
74
|
RecordingShellStore,
|
|
66
75
|
SnapshotOnlySegmentStore,
|
|
76
|
+
countSnapshotFamilies,
|
|
77
|
+
estimateShellEntryBytes,
|
|
67
78
|
getRecordingStore,
|
|
79
|
+
hasDocRecord,
|
|
80
|
+
pruneShellSnapshot,
|
|
81
|
+
type ShellSnapshotReaders,
|
|
68
82
|
} from "../cache/shell-snapshot.js";
|
|
69
83
|
import type { HandlerContext } from "./handler-context.js";
|
|
70
84
|
import type { SSRModule } from "./types.js";
|
|
71
|
-
import { buildFullPayload } from "./full-payload.js";
|
|
85
|
+
import { buildFullPayload, payloadInitialTheme } from "./full-payload.js";
|
|
72
86
|
import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
|
|
73
87
|
import { renderRscFlightStage } from "./render-pipeline.js";
|
|
74
88
|
import { stripInternalParams } from "../router/handler-context.js";
|
|
@@ -359,8 +373,9 @@ function clearCaptureBackoff(key: string): void {
|
|
|
359
373
|
const warnedNullCaptures = new Set<string>();
|
|
360
374
|
|
|
361
375
|
/**
|
|
362
|
-
* Warn once per key that a capture produced no usable shell
|
|
363
|
-
* in-place retry (runShellCapture attempt 2)
|
|
376
|
+
* Warn once per key that a capture produced no usable shell: after the
|
|
377
|
+
* in-place retry (runShellCapture attempt 2), or after one attempt that ran
|
|
378
|
+
* out of its deadline (no retry: noShellCauses). Naming both causes with the
|
|
364
379
|
* distinguishing signal — does the route ever flip to HIT — is the whole point:
|
|
365
380
|
* the pre-retry version blamed "a loader route without loading()" unconditionally
|
|
366
381
|
* and misled users whose route DID have loading() and was merely cold. Because the
|
|
@@ -371,25 +386,71 @@ const warnedNullCaptures = new Set<string>();
|
|
|
371
386
|
* The pointer is the shared PPR_LANE_HINT: the /ppr skill ships in the npm
|
|
372
387
|
* tarball, so the path resolves for consumers (a05c8251 convention).
|
|
373
388
|
*/
|
|
374
|
-
function warnNullCaptureOnce(key: string): void {
|
|
389
|
+
function warnNullCaptureOnce(key: string, retried: boolean): void {
|
|
390
|
+
const pendingStacks = noShellPendingStacks.get(key);
|
|
391
|
+
noShellPendingStacks.delete(key);
|
|
392
|
+
const cause = noShellCauses.get(key);
|
|
393
|
+
noShellCauses.delete(key);
|
|
375
394
|
if (warnedNullCaptures.has(key)) return;
|
|
376
395
|
warnedNullCaptures.add(key);
|
|
377
396
|
console.warn(
|
|
378
|
-
`[rango] Shell capture for "${key}" produced no usable shell
|
|
379
|
-
|
|
380
|
-
"
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
397
|
+
`[rango] Shell capture for "${key}" produced no usable shell` +
|
|
398
|
+
(retried ? " after an in-place retry" : "") +
|
|
399
|
+
"; nothing was stored, so this request stays on MISS. " +
|
|
400
|
+
(cause
|
|
401
|
+
? `Cause: ${cause}. Every capture waits for the handler layer to finish and ` +
|
|
402
|
+
'bakes it; make the slow part cheaper (cache() or "use cache"), raise ' +
|
|
403
|
+
"ppr.captureTimeout, or move it into a live loader (no ssr: false) read under " +
|
|
404
|
+
"loading() or an inline <Suspense>.\n"
|
|
405
|
+
: "Causes, told apart by whether the route ever flips to HIT:\n" +
|
|
406
|
+
" 1. Cold-start warmup (dev module transform, or a cold worker): the capture raced " +
|
|
407
|
+
"an unfinished shell render. This SELF-HEALS — the route flips to HIT once a later " +
|
|
408
|
+
"request warms the modules.\n" +
|
|
409
|
+
" 2. Something suspends above <body> with no Suspense boundary and never settles " +
|
|
410
|
+
"within the capture window: a live loader (no ssr: false) read without loading() or " +
|
|
411
|
+
"an inline <Suspense>, or a loader(Def, { ssr: false }) or top-level push (e.g. Meta) " +
|
|
412
|
+
"slower than ppr.captureTimeout. The boundary belongs to the entry or component " +
|
|
413
|
+
"that owns the data.\n") +
|
|
414
|
+
(pendingStacks && pendingStacks.length > 0
|
|
415
|
+
? "Components still pending when the capture froze the shell (one of them " +
|
|
416
|
+
"suspended above <body>):\n" +
|
|
417
|
+
pendingStacks.map(formatPendingStack).join("\n") +
|
|
418
|
+
"\n"
|
|
419
|
+
: "") +
|
|
389
420
|
PPR_LANE_HINT,
|
|
390
421
|
);
|
|
391
422
|
}
|
|
392
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Why the latest no-shell attempt per key ran out of ppr.captureTimeout (the
|
|
426
|
+
* match, the handler output, or the prerender after them). Set only for a
|
|
427
|
+
* deadline outcome, so runShellCapture also reads it to skip the in-place
|
|
428
|
+
* retry. Printed (and cleared) by warnNullCaptureOnce.
|
|
429
|
+
*/
|
|
430
|
+
const noShellCauses = new Map<string, string>();
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Dev only: component stacks of the tasks still pending at the latest
|
|
434
|
+
* no-shell attempt's abort, per key, printed (and cleared) by
|
|
435
|
+
* warnNullCaptureOnce. React reports them through prerender's onError
|
|
436
|
+
* (captureShellHTML's onAbortedTask).
|
|
437
|
+
*/
|
|
438
|
+
const noShellPendingStacks = new Map<string, string[]>();
|
|
439
|
+
|
|
440
|
+
/** Stacks kept per attempt: the root pin is almost always among the first. */
|
|
441
|
+
const MAX_PENDING_STACKS = 3;
|
|
442
|
+
|
|
443
|
+
/** The first lines of one component stack, indented under the warning. */
|
|
444
|
+
function formatPendingStack(stack: string): string {
|
|
445
|
+
return stack
|
|
446
|
+
.split("\n")
|
|
447
|
+
.map((line) => line.trim())
|
|
448
|
+
.filter(Boolean)
|
|
449
|
+
.slice(0, 8)
|
|
450
|
+
.map((line) => ` ${line}`)
|
|
451
|
+
.join("\n");
|
|
452
|
+
}
|
|
453
|
+
|
|
393
454
|
/** Keys already warned about a deterministic capture refusal (once per key). */
|
|
394
455
|
const warnedRefusedCaptures = new Set<string>();
|
|
395
456
|
|
|
@@ -415,6 +476,71 @@ function warnCaptureRefusedOnce(
|
|
|
415
476
|
);
|
|
416
477
|
}
|
|
417
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Refuse the capture when a capture guard tripped: a request-scoped read
|
|
481
|
+
* (cookies(), headers(), a { cache: false } variable) or ctx.dynamic() made
|
|
482
|
+
* anywhere the capture waited for — a handler, a promise it passes or pushes,
|
|
483
|
+
* an async server component, a bake-lane loader. The read throws where it
|
|
484
|
+
* happens, but the code can catch that throw, so the flag the guard sets on
|
|
485
|
+
* the capture context is what decides (server/context.ts
|
|
486
|
+
* assertNotInsideShellCapture). Returns true when it refused (and warned).
|
|
487
|
+
*/
|
|
488
|
+
function refuseOnCaptureGuard(
|
|
489
|
+
key: string,
|
|
490
|
+
derivedCtx: RequestContext<any>,
|
|
491
|
+
): boolean {
|
|
492
|
+
if (derivedCtx._dynamic) {
|
|
493
|
+
// A build-time capture reports dynamic() as its own outcome
|
|
494
|
+
// (build-shell-capture.ts): the URL keeps runtime capture.
|
|
495
|
+
if (derivedCtx.build) return true;
|
|
496
|
+
warnCaptureRefusedOnce(
|
|
497
|
+
key,
|
|
498
|
+
"ctx.dynamic() was called while capturing the shared shell (in a handler, or " +
|
|
499
|
+
"in a promise or async component the capture waited for). A dynamic render " +
|
|
500
|
+
"has no shell; the route keeps serving axis 1 for requests that opt out.",
|
|
501
|
+
);
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
const trip = derivedCtx._shellCaptureGuardTripped;
|
|
505
|
+
if (!trip) return false;
|
|
506
|
+
// Name the recorded source instead of hardcoding a lane: a trip can come
|
|
507
|
+
// from a bake-lane SEGMENT loader or from handler/render code, including a
|
|
508
|
+
// loader a handler awaits (issue #672). The guard recorded the read and its
|
|
509
|
+
// fix (server/context.ts tripShellCaptureGuard): cookies()/headers(), a
|
|
510
|
+
// { cache: false } variable, and the theme reads (#971).
|
|
511
|
+
const loaderId = derivedCtx._shellCaptureGuardTrippedLoaderId;
|
|
512
|
+
const origin = loaderId
|
|
513
|
+
? `the loader "${loaderId}"`
|
|
514
|
+
: "handler/render code (no loader body was executing)";
|
|
515
|
+
warnCaptureRefusedOnce(
|
|
516
|
+
key,
|
|
517
|
+
`${origin} read ${trip.surface} during capture; request-scoped data must not ` +
|
|
518
|
+
`bake into the shared shell. ${trip.fix}`,
|
|
519
|
+
PPR_LANE_HINT,
|
|
520
|
+
);
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The no-shell cause for handler output that missed the capture deadline. */
|
|
525
|
+
const HANDLER_OUTPUT_TIMEOUT_REASON: string =
|
|
526
|
+
"the handler output (a promise it passes or pushes, or an async server " +
|
|
527
|
+
"component) did not settle within ppr.captureTimeout";
|
|
528
|
+
|
|
529
|
+
/** The refusal for a capture that stored no doc record (a HIT needs it). */
|
|
530
|
+
const NO_DOC_RECORD_REASON: string =
|
|
531
|
+
"the capture produced no doc segment record, so a HIT could not replay the " +
|
|
532
|
+
"handler layer. Either the route's cache() refused the write (cache(false), or " +
|
|
533
|
+
"a condition() that returned false for the capture), or rendering the handler " +
|
|
534
|
+
"output failed (see the cache-write error reported for this key).";
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Default limit (bytes) on a whole stored shell entry — prelude, postponed
|
|
538
|
+
* state, and snapshot — when the store declares none
|
|
539
|
+
* (SegmentCacheStore.maxShellEntryBytes). Cloudflare KV's 25 MiB value limit:
|
|
540
|
+
* the tightest common store tier a shell entry lands in.
|
|
541
|
+
*/
|
|
542
|
+
export const DEFAULT_SHELL_ENTRY_MAX_BYTES: number = 25 * 1024 * 1024;
|
|
543
|
+
|
|
418
544
|
/**
|
|
419
545
|
* Default cap (serialized UTF-8 bytes) on the capture data snapshot riding
|
|
420
546
|
* inside a shell entry, when the route's `ppr` option does not set
|
|
@@ -424,8 +550,9 @@ function warnCaptureRefusedOnce(
|
|
|
424
550
|
* while still fitting any sane pinned-ring payload. Applied ONLY in
|
|
425
551
|
* captureAndStoreShell (the single defaulting site — resolvePprConfig passes
|
|
426
552
|
* the option through undefaulted), so every producer and direct caller gets
|
|
427
|
-
* the same policy.
|
|
428
|
-
*
|
|
553
|
+
* the same policy. The doc record is exempt; over the cap the other records
|
|
554
|
+
* are dropped (shell still stored and replayed; the pins' loaders read the
|
|
555
|
+
* live store — see PartialPrerenderProps.maxSnapshotBytes).
|
|
429
556
|
*/
|
|
430
557
|
export const DEFAULT_PPR_MAX_SNAPSHOT_BYTES: number = 8 * 1024 * 1024;
|
|
431
558
|
|
|
@@ -436,12 +563,13 @@ const SNAPSHOT_BYTE_ENCODER = new TextEncoder();
|
|
|
436
563
|
const warnedOverCapSnapshots = new Set<string>();
|
|
437
564
|
|
|
438
565
|
/**
|
|
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
|
|
566
|
+
* Warn once per key that the capture's loader pins exceeded the route's
|
|
567
|
+
* `maxSnapshotBytes` cap and were dropped. The shell entry is still stored
|
|
568
|
+
* with its doc record and every HIT replays the handler layer; only the
|
|
569
|
+
* pinned loaders lose their captured values, so their cached data can drift
|
|
570
|
+
* from the frozen prelude and hydration repairs it client-side. Once per
|
|
571
|
+
* key: the same page recaptures on every TTL roll and would otherwise
|
|
572
|
+
* re-warn forever.
|
|
445
573
|
*/
|
|
446
574
|
function warnSnapshotOverCapOnce(
|
|
447
575
|
key: string,
|
|
@@ -451,14 +579,15 @@ function warnSnapshotOverCapOnce(
|
|
|
451
579
|
if (warnedOverCapSnapshots.has(key)) return;
|
|
452
580
|
warnedOverCapSnapshots.add(key);
|
|
453
581
|
console.warn(
|
|
454
|
-
`[rango] Shell capture for "${key}" recorded
|
|
455
|
-
`
|
|
456
|
-
"the shell was stored
|
|
457
|
-
"
|
|
458
|
-
"the shell's TTL, hydration repairs the
|
|
459
|
-
"cap via the route's ppr option
|
|
460
|
-
"still fits your store's value limit
|
|
461
|
-
"or shrink the cache()'d data the
|
|
582
|
+
`[rango] Shell capture for "${key}" recorded ${snapshotBytes} bytes of ` +
|
|
583
|
+
`loader pins, over the ${capBytes}-byte cap — the pins were dropped and ` +
|
|
584
|
+
"the shell was stored with its doc record only. HITs still replay the " +
|
|
585
|
+
"handler layer, but ssr: false loaders now read the live store: if " +
|
|
586
|
+
"their data drifts before the shell's TTL, hydration repairs the " +
|
|
587
|
+
"mismatch client-side. Raise the cap via the route's ppr option " +
|
|
588
|
+
"({ maxSnapshotBytes }) if the entry still fits your store's value limit " +
|
|
589
|
+
"(Cloudflare KV: 25 MiB per value), or shrink the cache()'d data the " +
|
|
590
|
+
"loaders read.",
|
|
462
591
|
);
|
|
463
592
|
}
|
|
464
593
|
|
|
@@ -479,8 +608,8 @@ const warnedBakeCosts = new Set<string>();
|
|
|
479
608
|
|
|
480
609
|
/**
|
|
481
610
|
* Dev-only, once per key: name the slowest bake source and the three remedies
|
|
482
|
-
* in preference order.
|
|
483
|
-
*
|
|
611
|
+
* in preference order. Handler output and bake-lane values are shell material
|
|
612
|
+
* by design, so this is a cost report with an exit, not a deprecation.
|
|
484
613
|
*/
|
|
485
614
|
function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
486
615
|
if (warnedBakeCosts.has(key)) return;
|
|
@@ -489,10 +618,11 @@ function warnBakeCostOnce(key: string, source: string, ms: number): void {
|
|
|
489
618
|
`[rango] Shell capture for "${key}" waited ${ms}ms for ${source} to ` +
|
|
490
619
|
"settle before the shell could freeze. This cost recurs on every capture " +
|
|
491
620
|
"of the route and occupies the per-isolate capture queue; the served " +
|
|
492
|
-
"response never shows it. To
|
|
493
|
-
|
|
494
|
-
"
|
|
495
|
-
"
|
|
621
|
+
"response never shows it. To keep it baked but cheap, wrap the work in " +
|
|
622
|
+
'cache() or "use cache"; to make it per-request, load it in a live ' +
|
|
623
|
+
"loader (no ssr: false) read under loading() or an inline <Suspense>; for " +
|
|
624
|
+
"a loader(Def, { ssr: false }), return the slow part as a nested promise " +
|
|
625
|
+
"or drop ssr: false. " +
|
|
496
626
|
PPR_LANE_HINT,
|
|
497
627
|
);
|
|
498
628
|
}
|
|
@@ -509,10 +639,13 @@ export interface ShellCaptureDebugEvent {
|
|
|
509
639
|
key: string;
|
|
510
640
|
/**
|
|
511
641
|
* What happened:
|
|
512
|
-
* - stored / redirect / no-shell / refused: one capture
|
|
513
|
-
* (see CaptureAttemptOutcome for the semantics of each)
|
|
642
|
+
* - stored / redirect / no-shell / refused / expired: one capture
|
|
643
|
+
* ATTEMPT's outcome (see CaptureAttemptOutcome for the semantics of each)
|
|
514
644
|
* - error: the capture task failed with a genuine error (also routed through
|
|
515
645
|
* reportCacheError; the key is backed off)
|
|
646
|
+
* - skip-stored: a capture for the key stored after the scheduling request
|
|
647
|
+
* read the store and missed (ShellCaptureDescriptor.storedSeqAtRead), so
|
|
648
|
+
* the shell that request missed exists now
|
|
516
649
|
* - skip-in-flight: scheduleShellCapture found a capture already running for
|
|
517
650
|
* the key (stampede guard) and scheduled nothing
|
|
518
651
|
* - skip-backoff: the key is inside its refused-capture backoff window and
|
|
@@ -535,8 +668,10 @@ export interface ShellCaptureDebugEvent {
|
|
|
535
668
|
| "redirect"
|
|
536
669
|
| "no-shell"
|
|
537
670
|
| "refused"
|
|
671
|
+
| "expired"
|
|
538
672
|
| "error"
|
|
539
673
|
| "skip-in-flight"
|
|
674
|
+
| "skip-stored"
|
|
540
675
|
| "skip-backoff"
|
|
541
676
|
| "skip-capacity"
|
|
542
677
|
| "skip-inert-store"
|
|
@@ -588,6 +723,14 @@ export interface ShellCaptureDebugEvent {
|
|
|
588
723
|
* it attributable. Absent when nothing held the gate.
|
|
589
724
|
*/
|
|
590
725
|
bakeWaitMs?: number;
|
|
726
|
+
/**
|
|
727
|
+
* How long the capture waited for its doc record: the handler layer's
|
|
728
|
+
* promises, async server components and handle pushes settling, then the
|
|
729
|
+
* record's encode (settleCaptureRecord). Part of the one capture deadline.
|
|
730
|
+
*/
|
|
731
|
+
recordSettleMs?: number;
|
|
732
|
+
/** Bytes of the stored entry: prelude, postponed state, and snapshot. */
|
|
733
|
+
entryBytes?: number;
|
|
591
734
|
}
|
|
592
735
|
|
|
593
736
|
/**
|
|
@@ -646,6 +789,12 @@ export function describeShellCaptureEvent(
|
|
|
646
789
|
if (event.bakeWaitMs !== undefined) {
|
|
647
790
|
parts.push(`bake=${event.bakeWaitMs}ms`);
|
|
648
791
|
}
|
|
792
|
+
if (event.recordSettleMs !== undefined) {
|
|
793
|
+
parts.push(`record=${event.recordSettleMs}ms`);
|
|
794
|
+
}
|
|
795
|
+
if (event.entryBytes !== undefined) {
|
|
796
|
+
parts.push(`entry=${event.entryBytes}b`);
|
|
797
|
+
}
|
|
649
798
|
return parts.join(" ");
|
|
650
799
|
}
|
|
651
800
|
|
|
@@ -681,6 +830,7 @@ const TIMING_RECORDED_OUTCOMES = new Set<ShellCaptureDebugEvent["outcome"]>([
|
|
|
681
830
|
"redirect",
|
|
682
831
|
"no-shell",
|
|
683
832
|
"refused",
|
|
833
|
+
"expired",
|
|
684
834
|
"error",
|
|
685
835
|
]);
|
|
686
836
|
|
|
@@ -974,6 +1124,33 @@ export interface ShellCaptureDescriptor {
|
|
|
974
1124
|
debugSink?: (event: ShellCaptureDebugEvent) => void;
|
|
975
1125
|
/** Store the snapshot for navigation replay, never the captured HTML prelude. */
|
|
976
1126
|
navigationOnly?: true;
|
|
1127
|
+
/**
|
|
1128
|
+
* lastStoredCaptureSeq(key) when a document request read the store and
|
|
1129
|
+
* missed. The request schedules its capture only after rendering the MISS,
|
|
1130
|
+
* so a capture another request started can store in between; a later
|
|
1131
|
+
* sequence number then skips the redundant one (`skip-stored`), which would
|
|
1132
|
+
* re-render the page and drop the isolate's memo of the shell just stored.
|
|
1133
|
+
*/
|
|
1134
|
+
storedSeqAtRead?: number;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Per key, the sequence number of its latest stored capture (bounded). */
|
|
1138
|
+
const storedCaptureSeqs = new Map<string, number>();
|
|
1139
|
+
const STORED_CAPTURE_SEQS_MAX = 1_000;
|
|
1140
|
+
let storedCaptureSeq = 0;
|
|
1141
|
+
|
|
1142
|
+
/** The latest stored capture's sequence number for `key` in this isolate, or 0. */
|
|
1143
|
+
export function lastStoredCaptureSeq(key: string): number {
|
|
1144
|
+
return storedCaptureSeqs.get(key) ?? 0;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function noteCaptureStored(key: string): void {
|
|
1148
|
+
storedCaptureSeqs.delete(key);
|
|
1149
|
+
if (storedCaptureSeqs.size >= STORED_CAPTURE_SEQS_MAX) {
|
|
1150
|
+
const oldest = storedCaptureSeqs.keys().next().value;
|
|
1151
|
+
if (oldest !== undefined) storedCaptureSeqs.delete(oldest);
|
|
1152
|
+
}
|
|
1153
|
+
storedCaptureSeqs.set(key, ++storedCaptureSeq);
|
|
977
1154
|
}
|
|
978
1155
|
|
|
979
1156
|
/**
|
|
@@ -1011,6 +1188,13 @@ export function scheduleShellCapture(
|
|
|
1011
1188
|
// new token, and the stale task's token-guarded release can no longer
|
|
1012
1189
|
// touch it.
|
|
1013
1190
|
}
|
|
1191
|
+
if (
|
|
1192
|
+
descriptor.storedSeqAtRead !== undefined &&
|
|
1193
|
+
lastStoredCaptureSeq(key) > descriptor.storedSeqAtRead
|
|
1194
|
+
) {
|
|
1195
|
+
publishCaptureDebugEvent(descriptor, { key, outcome: "skip-stored" });
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1014
1198
|
// Refused/failed within the window → skip the doomed re-render (one probe per
|
|
1015
1199
|
// key per window per isolate). Expired entries self-evict inside the check.
|
|
1016
1200
|
if (isCaptureBackedOff(key)) {
|
|
@@ -1076,9 +1260,12 @@ export function scheduleShellCapture(
|
|
|
1076
1260
|
// Update the negative cache off the terminal outcome. A stored shell clears
|
|
1077
1261
|
// any prior backoff; a `no-shell` (after the in-place retry) backs the key
|
|
1078
1262
|
// off so the next requests don't re-probe it. A `redirect` has no shell but
|
|
1079
|
-
// is not a doomed render
|
|
1080
|
-
|
|
1081
|
-
|
|
1263
|
+
// is not a doomed render, and an `expired` retry lost a second record to
|
|
1264
|
+
// the same race — leave the backoff untouched.
|
|
1265
|
+
if (outcome === "stored") {
|
|
1266
|
+
clearCaptureBackoff(key);
|
|
1267
|
+
noteCaptureStored(key);
|
|
1268
|
+
} else if (outcome === "no-shell") {
|
|
1082
1269
|
markCaptureBackoff(key);
|
|
1083
1270
|
publishCaptureDebugEvent(descriptor, {
|
|
1084
1271
|
key,
|
|
@@ -1191,6 +1378,91 @@ export function scheduleShellCapture(
|
|
|
1191
1378
|
runBackground(reqCtx, serializedTask);
|
|
1192
1379
|
}
|
|
1193
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* The ttl/swr a shell is stored with: the route's ppr policy, capped by the
|
|
1383
|
+
* route cache() records the capture replayed or wrote
|
|
1384
|
+
* (RequestContext._routeRecordWindow). A HIT replays the capture's handler
|
|
1385
|
+
* output, while a client navigation reads the route's cache() tier first, so
|
|
1386
|
+
* a shell that outlived its record would show a document request other
|
|
1387
|
+
* handler output than a navigation of the same URL. Freshness and total
|
|
1388
|
+
* lifetime are capped separately, like the record's own SWR: the shell is
|
|
1389
|
+
* fresh while the record is (at most `ttl`), and servable while the record
|
|
1390
|
+
* is (at most `ttl + swr`), so a record already inside its swr window yields
|
|
1391
|
+
* a shell that is stale from the start and serves while it recaptures.
|
|
1392
|
+
*
|
|
1393
|
+
* Whole seconds, rounded up (the SegmentCacheStore.putShell contract), so the
|
|
1394
|
+
* shell may outlast the record by under a second. Null when the record's
|
|
1395
|
+
* total lifetime has run out: the shell would be dead on arrival.
|
|
1396
|
+
*/
|
|
1397
|
+
export function capShellWindow(
|
|
1398
|
+
ttl: number,
|
|
1399
|
+
swr: number,
|
|
1400
|
+
record: Pick<RouteRecordWindow, "freshUntil" | "staleUntil">,
|
|
1401
|
+
now: number,
|
|
1402
|
+
): { ttl: number; swr: number } | null {
|
|
1403
|
+
const total = Math.min(ttl + swr, (record.staleUntil - now) / 1000);
|
|
1404
|
+
if (!(total > 0)) return null;
|
|
1405
|
+
// At most `total`: freshUntil <= staleUntil and swr >= 0.
|
|
1406
|
+
const fresh = Math.max(0, Math.min(ttl, (record.freshUntil - now) / 1000));
|
|
1407
|
+
const wholeTtl = Math.ceil(fresh);
|
|
1408
|
+
return { ttl: wholeTtl, swr: Math.ceil(total) - wholeTtl };
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Routes already warned that their cache() entry expires before a capture. */
|
|
1412
|
+
const warnedExpiredRecordRoutes = new Set<string>();
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Warn once per route (dev and production) that no shell was stored because
|
|
1416
|
+
* the route cache() entry ran out before the capture could store one
|
|
1417
|
+
* (capShellWindow returned null) where a retry cannot help: the capture
|
|
1418
|
+
* wrote the entry itself (`written`), or the in-place retry's entry ran out
|
|
1419
|
+
* too, or no budget was left to retry. The key backs off like a refused
|
|
1420
|
+
* capture.
|
|
1421
|
+
*/
|
|
1422
|
+
function warnRecordRanOutOnce(
|
|
1423
|
+
route: string,
|
|
1424
|
+
record: RouteRecordWindow,
|
|
1425
|
+
detail: string,
|
|
1426
|
+
): void {
|
|
1427
|
+
if (warnedExpiredRecordRoutes.has(route)) return;
|
|
1428
|
+
warnedExpiredRecordRoutes.add(route);
|
|
1429
|
+
console.warn(
|
|
1430
|
+
`[rango] Route "${route}": its cache() entry (ttl ${record.ttl}, swr ` +
|
|
1431
|
+
`${record.swr}) ran out before the shell capture could store a shell ` +
|
|
1432
|
+
`(${detail}), so no shell is stored and the route renders without ` +
|
|
1433
|
+
"one. A shell never outlives the route cache() entry it replays: give " +
|
|
1434
|
+
"that cache() a ttl or swr longer than the capture takes, queue wait " +
|
|
1435
|
+
"included.",
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* The record an `expired` attempt ran out on, per key, for the warning
|
|
1441
|
+
* runShellCapture prints when that outcome is terminal. Set and consumed
|
|
1442
|
+
* within one capture task.
|
|
1443
|
+
*/
|
|
1444
|
+
const expiredRecords = new Map<
|
|
1445
|
+
string,
|
|
1446
|
+
{ route: string; record: RouteRecordWindow; captureMs: number }
|
|
1447
|
+
>();
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* A terminal `expired`: warn once per route with the record it ran out on,
|
|
1451
|
+
* and return `no-shell` so the caller backs the key off.
|
|
1452
|
+
*/
|
|
1453
|
+
function expiredTerminal(key: string, reason: string): "no-shell" {
|
|
1454
|
+
const expired = expiredRecords.get(key);
|
|
1455
|
+
expiredRecords.delete(key);
|
|
1456
|
+
if (expired) {
|
|
1457
|
+
warnRecordRanOutOnce(
|
|
1458
|
+
expired.route,
|
|
1459
|
+
expired.record,
|
|
1460
|
+
`${reason}; the last attempt took ${expired.captureMs} ms`,
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
return "no-shell";
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1194
1466
|
/**
|
|
1195
1467
|
* The outcome of one capture attempt.
|
|
1196
1468
|
* - `stored`: a usable shell was captured (and a putShell was attempted; a store
|
|
@@ -1198,10 +1470,21 @@ export function scheduleShellCapture(
|
|
|
1198
1470
|
* the capture itself worked).
|
|
1199
1471
|
* - `redirect`: the matched route redirects, so there is no shell to capture.
|
|
1200
1472
|
* - `no-shell`: captureShellHTML returned null because the prelude was unusable
|
|
1201
|
-
* or its private capture abort landed before the shell completed
|
|
1202
|
-
*
|
|
1473
|
+
* or its private capture abort landed before the shell completed, or the
|
|
1474
|
+
* capture ran out of ppr.captureTimeout. Retried once in place, when no
|
|
1475
|
+
* deadline ran out (noShellCauses).
|
|
1476
|
+
* - `expired`: the route cache() record the capture read (not wrote) ran out
|
|
1477
|
+
* before the store (capShellWindow null): it was near its end (its age,
|
|
1478
|
+
* queue wait). Retried once in place, where a fresh match reads or renders
|
|
1479
|
+
* a newer record; a second `expired`, or no hard-cap budget left for the
|
|
1480
|
+
* retry, is terminal `no-shell` (backoff, warned once per route).
|
|
1203
1481
|
*/
|
|
1204
|
-
type CaptureAttemptOutcome =
|
|
1482
|
+
type CaptureAttemptOutcome =
|
|
1483
|
+
| "stored"
|
|
1484
|
+
| "redirect"
|
|
1485
|
+
| "no-shell"
|
|
1486
|
+
| "refused"
|
|
1487
|
+
| "expired";
|
|
1205
1488
|
|
|
1206
1489
|
function createNavigationCaptureRequest(request: Request, url: URL): Request {
|
|
1207
1490
|
const headers = new Headers(request.headers);
|
|
@@ -1236,6 +1519,8 @@ type CaptureAttemptStats = Pick<
|
|
|
1236
1519
|
| "untaggedBake"
|
|
1237
1520
|
| "storeWrite"
|
|
1238
1521
|
| "bakeWaitMs"
|
|
1522
|
+
| "recordSettleMs"
|
|
1523
|
+
| "entryBytes"
|
|
1239
1524
|
>;
|
|
1240
1525
|
|
|
1241
1526
|
/**
|
|
@@ -1249,8 +1534,9 @@ type CaptureAttemptStats = Pick<
|
|
|
1249
1534
|
* when we froze the shell; the attempt itself warmed the module graph, so a second
|
|
1250
1535
|
* attempt a short beat later usually completes the shell in the SAME background
|
|
1251
1536
|
* 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
|
-
*
|
|
1537
|
+
* several HTTP requests before a capture stuck. We retry ONLY on `no-shell`
|
|
1538
|
+
* that did not run out of ppr.captureTimeout (noShellCauses); a genuine render
|
|
1539
|
+
* error is NOT retried — it propagates to scheduleShellCapture's
|
|
1254
1540
|
* reportCacheError. See docs/design/ppr-shell-resume.md.
|
|
1255
1541
|
*
|
|
1256
1542
|
* `retryDelayMs` is a parameter (defaulting to the module const) so unit tests can
|
|
@@ -1304,24 +1590,58 @@ async function runShellCapture(
|
|
|
1304
1590
|
return outcome;
|
|
1305
1591
|
};
|
|
1306
1592
|
|
|
1593
|
+
const taskStartedAt = performance.now();
|
|
1307
1594
|
const first = await timedAttempt(1);
|
|
1308
1595
|
// "refused" is deterministic (identity guard / rejected bake-lane loader —
|
|
1309
1596
|
// its own warning already fired): no retry, and the caller backs the key off
|
|
1310
1597
|
// exactly like a structural no-shell.
|
|
1311
1598
|
if (first === "refused") return "no-shell";
|
|
1312
|
-
|
|
1313
|
-
if (first
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1599
|
+
let second: CaptureAttemptOutcome;
|
|
1600
|
+
if (first === "expired") {
|
|
1601
|
+
// The record attempt 1 read ran out mid-capture: a fresh match reads or
|
|
1602
|
+
// renders a newer one. The whole task races SHELL_CAPTURE_TASK_HARD_CAP_MS,
|
|
1603
|
+
// so a retry that could not finish inside it (barrier + captureTimeout)
|
|
1604
|
+
// is not started.
|
|
1605
|
+
const retryBudgetMs =
|
|
1606
|
+
SHELL_CAPTURE_TASK_HARD_CAP_MS - (performance.now() - taskStartedAt);
|
|
1607
|
+
const retryNeedsMs =
|
|
1608
|
+
SHELL_CAPTURE_WRITE_BARRIER_MS +
|
|
1609
|
+
(descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1610
|
+
if (retryBudgetMs < retryNeedsMs) {
|
|
1611
|
+
return expiredTerminal(descriptor.key, "no time was left to retry");
|
|
1612
|
+
}
|
|
1613
|
+
log(
|
|
1614
|
+
`[ShellCache] capture attempt 1/2 for ${descriptor.key} outlived the route cache() record it read — retrying`,
|
|
1615
|
+
);
|
|
1616
|
+
second = await timedAttempt(2);
|
|
1617
|
+
if (second === "expired") {
|
|
1618
|
+
return expiredTerminal(descriptor.key, "twice in a row");
|
|
1619
|
+
}
|
|
1620
|
+
expiredRecords.delete(descriptor.key);
|
|
1621
|
+
} else {
|
|
1622
|
+
// "stored" (success) or "redirect" (no shell exists): nothing to retry.
|
|
1623
|
+
if (first !== "no-shell") return first;
|
|
1624
|
+
// Attempt 1 ran out of ppr.captureTimeout (a cause is recorded): its
|
|
1625
|
+
// handlers may still be running (a match is not cancellable), and a retry
|
|
1626
|
+
// would start the same work beside them. A cold-module abort, the retry's
|
|
1627
|
+
// reason, ends well inside the deadline.
|
|
1628
|
+
if (noShellCauses.has(descriptor.key)) {
|
|
1629
|
+
warnNullCaptureOnce(descriptor.key, false);
|
|
1630
|
+
return "no-shell";
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Attempt 1 produced no usable shell. Retry ONCE in place — the first
|
|
1634
|
+
// attempt warmed the dev transform graph / cold worker, so attempt 2
|
|
1635
|
+
// typically completes the shell without another HTTP request. The concise
|
|
1636
|
+
// line is gated on the middleware's debug flag (threaded via the
|
|
1637
|
+
// descriptor) so it replaces the old full DOMException dump with one
|
|
1638
|
+
// readable breadcrumb.
|
|
1639
|
+
log(
|
|
1640
|
+
`[ShellCache] capture attempt 1/2 for ${descriptor.key} aborted before shell completed (cold modules?) — retrying`,
|
|
1641
|
+
);
|
|
1642
|
+
await delay(retryDelayMs);
|
|
1643
|
+
second = await timedAttempt(2);
|
|
1644
|
+
}
|
|
1325
1645
|
if (second === "refused") return "no-shell";
|
|
1326
1646
|
if (second !== "no-shell") return second;
|
|
1327
1647
|
|
|
@@ -1334,20 +1654,193 @@ async function runShellCapture(
|
|
|
1334
1654
|
log(
|
|
1335
1655
|
`[ShellCache] capture attempt 2/2 for ${descriptor.key} aborted — giving up until next request`,
|
|
1336
1656
|
);
|
|
1337
|
-
warnNullCaptureOnce(descriptor.key);
|
|
1657
|
+
warnNullCaptureOnce(descriptor.key, true);
|
|
1338
1658
|
return "no-shell";
|
|
1339
1659
|
}
|
|
1340
1660
|
|
|
1341
|
-
/**
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
)
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1661
|
+
/**
|
|
1662
|
+
* Who reads the snapshot this capture stores besides its doc record
|
|
1663
|
+
* (docs/design/shell-entry-layout.md §2): partial replay alone for a
|
|
1664
|
+
* navigation-only entry ("segments"), the loaders a document HIT re-runs
|
|
1665
|
+
* otherwise ("loaders"). Undefined when the capture recorded no doc record,
|
|
1666
|
+
* which only a prerender-served capture stores (its HIT tail takes the handler
|
|
1667
|
+
* layer from the prerender store and reads nothing else from the snapshot
|
|
1668
|
+
* beyond the loader records, so nothing is pruned).
|
|
1669
|
+
*/
|
|
1670
|
+
function snapshotReaders(
|
|
1671
|
+
capture: ShellCaptureDescriptor,
|
|
1672
|
+
snapshot: readonly ShellSnapshotRecord[],
|
|
1673
|
+
docKey: string | undefined,
|
|
1674
|
+
): ShellSnapshotReaders | undefined {
|
|
1675
|
+
if (!hasDocRecord(snapshot, docKey)) return undefined;
|
|
1676
|
+
return capture.navigationOnly ? "segments" : "loaders";
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* What the record-first step produced ({@link settleCaptureRecord}):
|
|
1681
|
+
* - `record`: the doc record settled; `match` carries its fragments in place
|
|
1682
|
+
* of the handler layer's elements, so the capture's Flight payload is the
|
|
1683
|
+
* bytes every HIT replays.
|
|
1684
|
+
* - `prerender`: the prerender store supplied the handler layer (a
|
|
1685
|
+
* Prerender route); its HIT tail replays the same build-time segments, so
|
|
1686
|
+
* the capture renders the match as is.
|
|
1687
|
+
* - `timeout`: handler output did not settle within the capture budget.
|
|
1688
|
+
* - `refused`: a deterministic refusal (a capture guard tripped, the route
|
|
1689
|
+
* opted out, or the handler output failed); the warning already fired.
|
|
1690
|
+
*/
|
|
1691
|
+
export type CaptureRecordOutcome =
|
|
1692
|
+
| { kind: "record" | "prerender"; match: MatchResult }
|
|
1693
|
+
| { kind: "timeout"; reason: string }
|
|
1694
|
+
| { kind: "refused" };
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Record-first capture: the doc record is both the settle signal and the
|
|
1698
|
+
* parity source.
|
|
1699
|
+
*
|
|
1700
|
+
* Writing the record Flight-serializes every non-loader segment
|
|
1701
|
+
* (serializeSegments), which runs each handler's async server components and
|
|
1702
|
+
* waits for every promise the handler output carries — the capture's "is the
|
|
1703
|
+
* handler layer done" signal. Live-lane loader data is not in the record
|
|
1704
|
+
* (loader segments are excluded and their masked promises never settle), so
|
|
1705
|
+
* the wait never depends on a hole.
|
|
1706
|
+
*
|
|
1707
|
+
* The capture then renders its OWN Flight payload from the record's
|
|
1708
|
+
* fragments (fragmentSegments, the function a HIT tail uses) instead of from
|
|
1709
|
+
* the elements the match produced. Server components therefore render once
|
|
1710
|
+
* per capture, inside the record's encode: before this, the capture's Flight
|
|
1711
|
+
* render and the record's encode each ran them, so an uncached async
|
|
1712
|
+
* component showed one value in the prelude and another on every HIT.
|
|
1713
|
+
*
|
|
1714
|
+
* Order, all bounded by the one capture deadline (`ppr.captureTimeout`):
|
|
1715
|
+
* top-level handle promises, the handler pushes' nested promises, and the
|
|
1716
|
+
* bake-lane loader containers settle first (the record encodes the handles
|
|
1717
|
+
* a HIT restores, and its handle encode has its own 5s timeout that must
|
|
1718
|
+
* never be the one that fires); then the capture's onResponse callbacks fire
|
|
1719
|
+
* with a synthetic 200 (the implicit doc scope's cacheRoute, an explicit
|
|
1720
|
+
* scope's own write), and the deferred writes settle.
|
|
1721
|
+
*/
|
|
1722
|
+
export async function settleCaptureRecord(
|
|
1723
|
+
match: MatchResult,
|
|
1724
|
+
derivation: CaptureContextDerivation,
|
|
1725
|
+
capture: Pick<ShellCaptureDescriptor, "key">,
|
|
1726
|
+
deadline: number,
|
|
1727
|
+
): Promise<CaptureRecordOutcome> {
|
|
1728
|
+
const { derivedCtx, freshHandleStore } = derivation;
|
|
1729
|
+
const recording = getRecordingStore(derivedCtx._cacheStore);
|
|
1730
|
+
|
|
1731
|
+
// cacheRoute waits for handleStore.settled, which needs the seal.
|
|
1732
|
+
freshHandleStore.seal();
|
|
1733
|
+
const loaderRecords = derivedCtx._shellCaptureLoaderRecords;
|
|
1734
|
+
const settled = await withinDeadline(
|
|
1735
|
+
Promise.all([
|
|
1736
|
+
freshHandleStore.getData().then(resolveDeferredHandleValues),
|
|
1737
|
+
derivation.handlerPushesSettled(),
|
|
1738
|
+
loaderRecords && loaderRecords.size > 0
|
|
1739
|
+
? Promise.allSettled([...loaderRecords.values()])
|
|
1740
|
+
: undefined,
|
|
1741
|
+
]).catch(() => undefined),
|
|
1742
|
+
deadline,
|
|
1743
|
+
);
|
|
1744
|
+
// Past the deadline the record cannot settle in time: stop before its
|
|
1745
|
+
// write starts (no encode for a capture that already failed).
|
|
1746
|
+
if (!settled.done) {
|
|
1747
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx))
|
|
1748
|
+
return { kind: "refused" };
|
|
1749
|
+
return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
const callbacks = derivedCtx._onResponseCallbacks?.splice(0) ?? [];
|
|
1753
|
+
if (callbacks.length > 0) {
|
|
1754
|
+
const synthetic = new Response(null, { status: 200 });
|
|
1755
|
+
for (const cb of callbacks) {
|
|
1756
|
+
try {
|
|
1757
|
+
cb(synthetic);
|
|
1758
|
+
} catch {
|
|
1759
|
+
// A capture-time cache write that throws is degradation, not failure.
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const drained = recording
|
|
1764
|
+
? await recording.settleWrites(Math.max(0, deadline - Date.now()))
|
|
1765
|
+
: true;
|
|
1766
|
+
|
|
1767
|
+
// Capture guards first: a tripped guard usually also fails the record,
|
|
1768
|
+
// and its own message names the cause.
|
|
1769
|
+
if (refuseOnCaptureGuard(capture.key, derivedCtx)) return { kind: "refused" };
|
|
1770
|
+
|
|
1771
|
+
const docKey = derivedCtx._shellImplicitCache?.docKey;
|
|
1772
|
+
const record =
|
|
1773
|
+
recording && docKey !== undefined
|
|
1774
|
+
? (recording.getRecord("segment", docKey) as CachedEntryData | undefined)
|
|
1775
|
+
: undefined;
|
|
1776
|
+
if (
|
|
1777
|
+
!record ||
|
|
1778
|
+
!Array.isArray(record.segments) ||
|
|
1779
|
+
record.segments.length === 0
|
|
1780
|
+
) {
|
|
1781
|
+
// Post-match serve-source truth (cache-lookup.ts tryPrerenderLookup).
|
|
1782
|
+
if (derivedCtx._pprReplayPostMatchReason === "prerender-store") {
|
|
1783
|
+
return { kind: "prerender", match };
|
|
1784
|
+
}
|
|
1785
|
+
if (!drained) {
|
|
1786
|
+
return { kind: "timeout", reason: HANDLER_OUTPUT_TIMEOUT_REASON };
|
|
1787
|
+
}
|
|
1788
|
+
warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
|
|
1789
|
+
return { kind: "refused" };
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// A handle encode that timed out stores "" (handle-snapshot.ts
|
|
1793
|
+
// encodeHandleValue). With every handler push settled above, that only
|
|
1794
|
+
// happens for a promise the walk does not reach (inside a Map, Set, or class
|
|
1795
|
+
// instance), which never settles in time on a retry either.
|
|
1796
|
+
if (!record.handles && recordSegmentsHaveHandles(record, freshHandleStore)) {
|
|
1797
|
+
warnCaptureRefusedOnce(
|
|
1798
|
+
capture.key,
|
|
1799
|
+
"a handle value the handler pushed did not finish encoding into the doc " +
|
|
1800
|
+
"record. A promise inside a Map, Set, or class instance is not awaited " +
|
|
1801
|
+
"before the encode; push plain objects and arrays.",
|
|
1802
|
+
);
|
|
1803
|
+
return { kind: "refused" };
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const { fragmentSegments } = await import("../cache/segment-codec.js");
|
|
1807
|
+
const fragments = new Map(
|
|
1808
|
+
(await fragmentSegments(record.segments)).map((s) => [s.id, s]),
|
|
1809
|
+
);
|
|
1810
|
+
return {
|
|
1811
|
+
kind: "record",
|
|
1812
|
+
match: {
|
|
1813
|
+
...match,
|
|
1814
|
+
segments: match.segments.map((segment) => {
|
|
1815
|
+
const fragment =
|
|
1816
|
+
segment.type === "loader" ? undefined : fragments.get(segment.id);
|
|
1817
|
+
return fragment
|
|
1818
|
+
? {
|
|
1819
|
+
...segment,
|
|
1820
|
+
component: fragment.component,
|
|
1821
|
+
layout: fragment.layout,
|
|
1822
|
+
loading: fragment.loading,
|
|
1823
|
+
}
|
|
1824
|
+
: segment;
|
|
1825
|
+
}),
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/** Whether the handle store holds handler pushes for the record's segments. */
|
|
1831
|
+
function recordSegmentsHaveHandles(
|
|
1832
|
+
record: CachedEntryData,
|
|
1833
|
+
handleStore: HandleStore,
|
|
1834
|
+
): boolean {
|
|
1835
|
+
for (const segment of record.segments) {
|
|
1836
|
+
const id = segment?.metadata?.id;
|
|
1837
|
+
if (id === undefined) continue;
|
|
1838
|
+
const data = handleStore.getDataForSegment(id, true);
|
|
1839
|
+
for (const name in data) {
|
|
1840
|
+
if (data[name]!.length > 0) return true;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return false;
|
|
1351
1844
|
}
|
|
1352
1845
|
|
|
1353
1846
|
/**
|
|
@@ -1367,6 +1860,7 @@ function handlerLayerIsLive(
|
|
|
1367
1860
|
* set a shell entry should be invalidatable by (loader tags belong to holes).
|
|
1368
1861
|
* - _transitionWhen: a fresh [] so the capture's transition gating is its own.
|
|
1369
1862
|
* - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
|
|
1863
|
+
* - _dynamic / dynamic(): an own latch the capture reads.
|
|
1370
1864
|
* - _metricsStore: undefined so the capture never appends to the foreground's
|
|
1371
1865
|
* (already-finalized) metrics.
|
|
1372
1866
|
* - _renderBarrier family: an own barrier wired to the fresh handle store
|
|
@@ -1379,6 +1873,8 @@ function handlerLayerIsLive(
|
|
|
1379
1873
|
* capture guard is load-bearing). Middleware is NOT re-run: it already ran for the
|
|
1380
1874
|
* triggering request, and the derived context inherits its post-middleware state
|
|
1381
1875
|
* (guarding is serve-time; the shell is never served without the full chain).
|
|
1876
|
+
* After the match, {@link settleCaptureRecord} writes the doc record and the
|
|
1877
|
+
* capture renders from it.
|
|
1382
1878
|
*
|
|
1383
1879
|
* A FRESH context (and match/render) per attempt is what makes the retry sound:
|
|
1384
1880
|
* the second attempt is a clean capture, not a resumption of the first.
|
|
@@ -1406,23 +1902,67 @@ async function attemptCapture(
|
|
|
1406
1902
|
await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
|
|
1407
1903
|
stats.barrierWaitMs = Math.round(performance.now() - barrierStart);
|
|
1408
1904
|
|
|
1409
|
-
const
|
|
1905
|
+
const derivation = deriveShellCaptureContext(
|
|
1410
1906
|
reqCtx,
|
|
1411
1907
|
descriptor,
|
|
1412
1908
|
descriptor.navigationOnly ? { request, url } : undefined,
|
|
1413
1909
|
);
|
|
1910
|
+
const { derivedCtx, freshHandleStore } = derivation;
|
|
1414
1911
|
const captureStartedAt = Date.now();
|
|
1415
1912
|
|
|
1416
1913
|
return runWithRequestContext(derivedCtx, async () => {
|
|
1417
|
-
|
|
1914
|
+
// One deadline for the whole capture (see
|
|
1915
|
+
// PartialPrerenderProps.captureTimeout): the match (handlers, and the
|
|
1916
|
+
// loaders they await), then the record, then the prerender with what is
|
|
1917
|
+
// left.
|
|
1918
|
+
const deadline =
|
|
1919
|
+
Date.now() + (descriptor.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
|
|
1920
|
+
const matched = await withinDeadline(
|
|
1921
|
+
ctx.router.match(request, { env }),
|
|
1922
|
+
deadline,
|
|
1923
|
+
);
|
|
1924
|
+
if (!matched.done) {
|
|
1925
|
+
noShellCauses.set(
|
|
1926
|
+
descriptor.key,
|
|
1927
|
+
"the handlers (or a loader a handler awaits) did not return within " +
|
|
1928
|
+
"ppr.captureTimeout",
|
|
1929
|
+
);
|
|
1930
|
+
return "no-shell";
|
|
1931
|
+
}
|
|
1932
|
+
const match = matched.value;
|
|
1418
1933
|
// A route that redirects has no shell to capture — bail (no store write, no
|
|
1419
1934
|
// retry: a redirect is deterministic).
|
|
1420
1935
|
if (match.redirect) return "redirect";
|
|
1421
1936
|
|
|
1422
1937
|
setRequestContextParams(match.params, match.routeName);
|
|
1423
1938
|
|
|
1424
|
-
const
|
|
1939
|
+
const settleStart = performance.now();
|
|
1940
|
+
const settled = await settleCaptureRecord(
|
|
1425
1941
|
match,
|
|
1942
|
+
derivation,
|
|
1943
|
+
descriptor,
|
|
1944
|
+
deadline,
|
|
1945
|
+
);
|
|
1946
|
+
stats.recordSettleMs = Math.round(performance.now() - settleStart);
|
|
1947
|
+
if (
|
|
1948
|
+
process.env.NODE_ENV !== "production" &&
|
|
1949
|
+
settled.kind === "record" &&
|
|
1950
|
+
stats.recordSettleMs >= SHELL_CAPTURE_BAKE_WARN_MS
|
|
1951
|
+
) {
|
|
1952
|
+
warnBakeCostOnce(
|
|
1953
|
+
descriptor.key,
|
|
1954
|
+
"the handler output (promises it passes or pushes, async server components)",
|
|
1955
|
+
stats.recordSettleMs,
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
if (settled.kind === "refused") return "refused";
|
|
1959
|
+
if (settled.kind === "timeout") {
|
|
1960
|
+
noShellCauses.set(descriptor.key, settled.reason);
|
|
1961
|
+
return "no-shell";
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const payload = buildFullPayload(
|
|
1965
|
+
settled.match,
|
|
1426
1966
|
ctx,
|
|
1427
1967
|
url,
|
|
1428
1968
|
derivedCtx,
|
|
@@ -1446,30 +1986,77 @@ async function attemptCapture(
|
|
|
1446
1986
|
// content (and tags propagated by async cache()/"use cache" reads) lands after
|
|
1447
1987
|
// this synchronous construction point, so snapshotting here dropped it — the
|
|
1448
1988
|
// shell-tag snapshot must sit behind the quiesce gate (issue #676).
|
|
1449
|
-
|
|
1989
|
+
const outcome = await captureAndStoreShell(
|
|
1450
1990
|
ssrModule,
|
|
1451
1991
|
flightStage.stream,
|
|
1452
1992
|
freshHandleStore,
|
|
1453
1993
|
derivedCtx,
|
|
1454
|
-
|
|
1994
|
+
{
|
|
1995
|
+
...descriptor,
|
|
1996
|
+
captureTimeout: Math.max(1, deadline - Date.now()),
|
|
1997
|
+
},
|
|
1455
1998
|
stats,
|
|
1456
1999
|
captureStartedAt,
|
|
1457
2000
|
);
|
|
2001
|
+
if (outcome === "no-shell" && Date.now() >= deadline) {
|
|
2002
|
+
noShellCauses.set(
|
|
2003
|
+
descriptor.key,
|
|
2004
|
+
"the shell render did not finish within what ppr.captureTimeout left " +
|
|
2005
|
+
`after the handler output settled (${stats.recordSettleMs}ms)`,
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
return outcome;
|
|
1458
2009
|
});
|
|
1459
2010
|
}
|
|
1460
2011
|
|
|
2012
|
+
/**
|
|
2013
|
+
* Race `work` against an absolute deadline, keeping its value. The losing
|
|
2014
|
+
* work is not cancelled (a router match has no abort signal); callers that
|
|
2015
|
+
* lose do not start it again (runShellCapture skips its in-place retry).
|
|
2016
|
+
*/
|
|
2017
|
+
export async function withinDeadline<T>(
|
|
2018
|
+
work: Promise<T>,
|
|
2019
|
+
deadline: number,
|
|
2020
|
+
): Promise<{ done: true; value: T } | { done: false }> {
|
|
2021
|
+
// The losing branch keeps running; a late rejection must not surface.
|
|
2022
|
+
work.catch(() => {});
|
|
2023
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
2024
|
+
const expired = new Promise<{ done: false }>((resolve) => {
|
|
2025
|
+
timer = setTimeout(
|
|
2026
|
+
() => resolve({ done: false }),
|
|
2027
|
+
Math.max(0, deadline - Date.now()),
|
|
2028
|
+
);
|
|
2029
|
+
(timer as { unref?: () => void }).unref?.();
|
|
2030
|
+
});
|
|
2031
|
+
try {
|
|
2032
|
+
return await Promise.race([
|
|
2033
|
+
work.then((value) => ({ done: true as const, value })),
|
|
2034
|
+
expired,
|
|
2035
|
+
]);
|
|
2036
|
+
} finally {
|
|
2037
|
+
if (timer) clearTimeout(timer);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1461
2041
|
/**
|
|
1462
2042
|
* The derived capture context and its fresh (mask-funneled) handle store,
|
|
1463
2043
|
* shared by BOTH shell producers: the runtime background capture
|
|
1464
2044
|
* (attemptCapture, producer A) and the build-time prerender shell capture
|
|
1465
2045
|
* (prerender/build-shell-capture.ts, producer B — issue #699). One
|
|
1466
|
-
* implementation so the capture semantics — the
|
|
1467
|
-
*
|
|
1468
|
-
* scope — cannot drift between producers.
|
|
2046
|
+
* implementation so the capture semantics — the handle push funnel (handler
|
|
2047
|
+
* pushes settled, loader pushes masked), the capture guards, snapshot
|
|
2048
|
+
* recording, the implicit doc-cache scope — cannot drift between producers.
|
|
1469
2049
|
*/
|
|
1470
2050
|
export interface CaptureContextDerivation {
|
|
1471
2051
|
derivedCtx: RequestContext;
|
|
1472
2052
|
freshHandleStore: HandleStore;
|
|
2053
|
+
/**
|
|
2054
|
+
* Resolves once every handler handle push has settled at any depth of
|
|
2055
|
+
* plain objects and arrays (top-level promise, then the promises nested
|
|
2056
|
+
* in what it resolved to). The record-first step awaits it before the doc
|
|
2057
|
+
* record encodes its handles, so that encode never races a pending value.
|
|
2058
|
+
*/
|
|
2059
|
+
handlerPushesSettled: () => Promise<void>;
|
|
1473
2060
|
}
|
|
1474
2061
|
|
|
1475
2062
|
/**
|
|
@@ -1487,52 +2074,53 @@ export function deriveShellCaptureContext(
|
|
|
1487
2074
|
): CaptureContextDerivation {
|
|
1488
2075
|
const freshHandleStore = createHandleStore();
|
|
1489
2076
|
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
|
-
|
|
2077
|
+
// The capture handle store's push is the single funnel every push wrapper
|
|
2078
|
+
// (setupLoaderAccess, createUseFunction, prerender) goes through; the store
|
|
2079
|
+
// exists only for this capture attempt, so the foreground store is
|
|
2080
|
+
// untouched. Two lanes:
|
|
2081
|
+
// - HANDLER pushes (outside a DSL loader scope: handler bodies, handler-
|
|
2082
|
+
// invoked ctx.use(loader) bodies, defers) are handler output, baked like
|
|
2083
|
+
// everything else the handler layer produces. They pass through
|
|
2084
|
+
// unmasked; settleHandlerPush tracks each one so the record-first step
|
|
2085
|
+
// (settleCaptureRecord) can wait for its promises, top-level and nested,
|
|
2086
|
+
// before the doc record encodes the handles a HIT replays. A route
|
|
2087
|
+
// cache() record's restore (handle-snapshot.ts restoreHandles) lands
|
|
2088
|
+
// here too: a value it restores through pushReplayed keeps that loader
|
|
2089
|
+
// as owner, so a HIT that runs the loader replaces it, not appends.
|
|
2090
|
+
// - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
|
|
2091
|
+
// loaders they await via ctx.use execute at capture (live loaders are
|
|
2092
|
+
// masked), their pushes are in the captured HTML (<head> title/meta,
|
|
2093
|
+
// useHandle echoes), and a HIT does not run a promise-free bake-lane
|
|
2094
|
+
// loader (loader-cache.ts), so the stored record carries them: each
|
|
2095
|
+
// settled, thenable-free push from a loader body keeps that loader as
|
|
2096
|
+
// `owner` (HandleStore.push), the record stores it
|
|
2097
|
+
// (CachedEntryData.handleOwners), and the HIT restores it through
|
|
2098
|
+
// pushReplayed; a loader that does run on the HIT (a promise-carrying
|
|
2099
|
+
// bake-lane loader, a live loader awaiting it) replaces its restored
|
|
2100
|
+
// copies in place. Nested thenables stay masked (the promise shape is
|
|
2101
|
+
// the liveness declaration, mask-nested.ts). A deferred (thenable) push,
|
|
2102
|
+
// one with masked nested promises, or one outside a loader body (a
|
|
2103
|
+
// loader-cache replay names its loader: pushReplayed below) stays
|
|
2104
|
+
// tagged (HandleStore.push loaderPush) so the
|
|
2105
|
+
// handle encode cannot stall on a never-resolving mask; only the
|
|
2106
|
+
// captureHandles cache-write call site excludes tagged pushes, and the
|
|
2107
|
+
// loader records then carry `runs: 1` so a HIT runs those bodies for
|
|
2108
|
+
// them.
|
|
2109
|
+
const handlerPushSettles: Promise<void>[] = [];
|
|
2110
|
+
// A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
|
|
2111
|
+
// route cache() record's owned values, restoreHandles) re-pushes a loader's
|
|
2112
|
+
// recorded values through pushReplayed, which calls push() outside any
|
|
2113
|
+
// loader body: name that loader for the funnel so the record keeps them
|
|
2114
|
+
// under it too.
|
|
2115
|
+
let replayOwner: string | undefined;
|
|
2116
|
+
const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
|
|
2117
|
+
freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
|
|
2118
|
+
replayOwner = loaderId;
|
|
2119
|
+
try {
|
|
2120
|
+
rawPushReplayed(handleName, segmentId, value, loaderId);
|
|
2121
|
+
} finally {
|
|
2122
|
+
replayOwner = undefined;
|
|
2123
|
+
}
|
|
1536
2124
|
};
|
|
1537
2125
|
const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
|
|
1538
2126
|
freshHandleStore.push = (
|
|
@@ -1540,49 +2128,55 @@ export function deriveShellCaptureContext(
|
|
|
1540
2128
|
segmentId: string,
|
|
1541
2129
|
value: unknown,
|
|
1542
2130
|
) => {
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
2131
|
+
if (!isInsideLoaderScope()) {
|
|
2132
|
+
handlerPushSettles.push(settleNestedThenables(value));
|
|
2133
|
+
rawCapturePush(handleName, segmentId, value, false, replayOwner);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
1546
2136
|
let maskedNestedThenable = false;
|
|
1547
|
-
const
|
|
2137
|
+
const mask = (v: unknown): unknown => {
|
|
1548
2138
|
const report: MaskReport = { thenable: false };
|
|
1549
2139
|
const masked = maskNestedContainerThenables(v, undefined, report);
|
|
1550
|
-
if (report.thenable)
|
|
1551
|
-
maskedNestedThenable = true;
|
|
1552
|
-
if (!pushedInLoaderScope) handleLiveness.holes = true;
|
|
1553
|
-
}
|
|
2140
|
+
if (report.thenable) maskedNestedThenable = true;
|
|
1554
2141
|
return masked;
|
|
1555
2142
|
};
|
|
1556
2143
|
let masked: unknown;
|
|
1557
|
-
let loaderPush =
|
|
2144
|
+
let loaderPush = true;
|
|
1558
2145
|
let owner: string | undefined;
|
|
1559
2146
|
if (isThenable(value)) {
|
|
1560
|
-
if (!pushedInLoaderScope) {
|
|
1561
|
-
handleLiveness.pendingPushes++;
|
|
1562
|
-
const settle = () => handleLiveness.pendingPushes--;
|
|
1563
|
-
value.then(settle, settle);
|
|
1564
|
-
}
|
|
1565
2147
|
// Deferred (thenable) loader pushes always keep the tag — the
|
|
1566
|
-
//
|
|
1567
|
-
masked = value.then(
|
|
2148
|
+
// carve-out below is for settled values only.
|
|
2149
|
+
masked = value.then(mask);
|
|
1568
2150
|
} else {
|
|
1569
|
-
masked =
|
|
1570
|
-
if (
|
|
1571
|
-
//
|
|
1572
|
-
// funnel comment above):
|
|
1573
|
-
//
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
2151
|
+
masked = mask(value);
|
|
2152
|
+
if (!maskedNestedThenable) {
|
|
2153
|
+
// A settled, thenable-free push from a loader body is shell
|
|
2154
|
+
// material (see the funnel comment above): only bake-lane loaders
|
|
2155
|
+
// and the loaders they await run at capture, and a HIT does not run
|
|
2156
|
+
// a promise-free bake-lane loader (loader-cache.ts), so the record
|
|
2157
|
+
// keeps the push under the loader that made it.
|
|
2158
|
+
const bodyLoaderId = getCurrentLoaderBodyId() ?? replayOwner;
|
|
2159
|
+
loaderPush = bodyLoaderId === undefined;
|
|
1578
2160
|
if (!loaderPush) owner = bodyLoaderId;
|
|
1579
2161
|
}
|
|
1580
2162
|
}
|
|
2163
|
+
// A push the record cannot keep reaches a HIT only from a live run of
|
|
2164
|
+
// its loader, so the loader records ask for one (runs: 1).
|
|
2165
|
+
if (loaderPush) derivedCtx._shellCaptureUnrecordedLoaderPush = true;
|
|
1581
2166
|
rawCapturePush(handleName, segmentId, masked, loaderPush, owner);
|
|
1582
2167
|
};
|
|
1583
2168
|
|
|
2169
|
+
// The capture reuses the record keys its request resolved (CacheScope
|
|
2170
|
+
// resolveKeyFrom), read through the prototype: the map must exist on the
|
|
2171
|
+
// request before the derivation, or the capture would start its own and
|
|
2172
|
+
// run key() under the capture guard.
|
|
2173
|
+
reqCtx._resolvedCacheKeys ??= new Map();
|
|
1584
2174
|
const derivedCtx: RequestContext = Object.assign(Object.create(reqCtx), {
|
|
1585
2175
|
_handleStore: freshHandleStore,
|
|
2176
|
+
// The capture notes the route records it reads or writes itself; the
|
|
2177
|
+
// foreground's (a partial request's `partial:` record, say) are not the
|
|
2178
|
+
// ones this shell is captured from.
|
|
2179
|
+
_routeRecordWindow: undefined,
|
|
1586
2180
|
});
|
|
1587
2181
|
if (identity) {
|
|
1588
2182
|
derivedCtx.request = identity.request;
|
|
@@ -1614,6 +2208,16 @@ export function deriveShellCaptureContext(
|
|
|
1614
2208
|
// foreground's never reach it.
|
|
1615
2209
|
derivedCtx._renderErrors = [];
|
|
1616
2210
|
derivedCtx._shellCaptureRun = true;
|
|
2211
|
+
// Own dynamic() latch. The inherited method writes the FOREGROUND context
|
|
2212
|
+
// (its closure), which already served, and nothing read it for a runtime
|
|
2213
|
+
// capture: handler code that opts out after an await (a handler promise the
|
|
2214
|
+
// capture now waits for) must refuse THIS capture instead
|
|
2215
|
+
// (settleCaptureRecord and captureAndStoreShell read _dynamic). The ppr
|
|
2216
|
+
// header latch is left alone: a capture's response is never sent.
|
|
2217
|
+
derivedCtx._dynamic = false;
|
|
2218
|
+
derivedCtx.dynamic = (): void => {
|
|
2219
|
+
derivedCtx._dynamic = true;
|
|
2220
|
+
};
|
|
1617
2221
|
// Own-property reset: an inherited flag would make serializeSegments store
|
|
1618
2222
|
// a double-encoded fragment. No current arming site reaches a capture
|
|
1619
2223
|
// render (kept as defense-in-depth for a future one).
|
|
@@ -1649,7 +2253,6 @@ export function deriveShellCaptureContext(
|
|
|
1649
2253
|
// forwarding to the parent so the write persists and the worker stays alive),
|
|
1650
2254
|
// then captureAndStoreShell awaits them before draining. Reads that HIT are
|
|
1651
2255
|
// recorded synchronously during the render and need none of this.
|
|
1652
|
-
derivedCtx._shellCaptureHandleLiveness = handleLiveness;
|
|
1653
2256
|
if (reqCtx._cacheStore) {
|
|
1654
2257
|
const recordingStore = new RecordingShellStore(reqCtx._cacheStore);
|
|
1655
2258
|
derivedCtx._cacheStore = recordingStore;
|
|
@@ -1658,12 +2261,13 @@ export function deriveShellCaptureContext(
|
|
|
1658
2261
|
recordingStore.trackWrite(p);
|
|
1659
2262
|
reqCtx.waitUntil(() => p);
|
|
1660
2263
|
};
|
|
1661
|
-
//
|
|
2264
|
+
// The doc record (capture side): the implicit doc-cache scope makes the
|
|
1662
2265
|
// capture's match write ALL matched non-loader segments as one doc-keyed
|
|
1663
2266
|
// segment record — into the snapshot only (SnapshotOnlySegmentStore), so
|
|
1664
2267
|
// the record dies with the shell entry and the next capture's lookup
|
|
1665
|
-
// still misses (handlers re-run on recapture).
|
|
1666
|
-
// cache scope
|
|
2268
|
+
// still misses (handlers re-run on recapture). A route deriving its own
|
|
2269
|
+
// cache() scope keeps it, and recordShellCaptureDocRecord (cache-store.ts)
|
|
2270
|
+
// writes the same record for it.
|
|
1667
2271
|
derivedCtx._shellImplicitCache = {
|
|
1668
2272
|
ttl: descriptor.ttl,
|
|
1669
2273
|
swr: descriptor.swr,
|
|
@@ -1672,7 +2276,12 @@ export function deriveShellCaptureContext(
|
|
|
1672
2276
|
};
|
|
1673
2277
|
}
|
|
1674
2278
|
|
|
1675
|
-
return {
|
|
2279
|
+
return {
|
|
2280
|
+
derivedCtx,
|
|
2281
|
+
freshHandleStore,
|
|
2282
|
+
handlerPushesSettled: () =>
|
|
2283
|
+
Promise.all(handlerPushSettles).then(() => undefined),
|
|
2284
|
+
};
|
|
1676
2285
|
}
|
|
1677
2286
|
|
|
1678
2287
|
/**
|
|
@@ -1718,17 +2327,19 @@ async function captureAndStoreShell(
|
|
|
1718
2327
|
// docs/design/ppr-shell-resume.md ("Loaders and handles").
|
|
1719
2328
|
handleStore.seal();
|
|
1720
2329
|
|
|
1721
|
-
//
|
|
1722
|
-
//
|
|
1723
|
-
//
|
|
1724
|
-
//
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
// awaits the top-level thenables).
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
2330
|
+
// TOP-LEVEL pushed handle promises are BAKED into the shell —
|
|
2331
|
+
// resolvedHandleStream awaits them before the payload's handles row emits. A
|
|
2332
|
+
// pushed promise with real latency would lose the byte-quiet race (the
|
|
2333
|
+
// pending handles row emits no bytes, the gate freezes, the row is dropped,
|
|
2334
|
+
// SsrRoot suspends at the root), so the gate is HELD open until the same
|
|
2335
|
+
// await completes: handlesBaked mirrors resolvedHandleStream's resolution
|
|
2336
|
+
// (getData waits the tracked-handler barrier; resolveDeferredHandleValues
|
|
2337
|
+
// awaits the top-level thenables). On the runtime path settleCaptureRecord
|
|
2338
|
+
// already waited for these (and for handler pushes' nested promises), so the
|
|
2339
|
+
// hold is normally released at once. A DSL loader push's nested promises are
|
|
2340
|
+
// masked and never hold the gate — they stay holes. Bounded by maxWaitMs like
|
|
2341
|
+
// every quiesce input (a defer hanging on a masked loader still ends in the
|
|
2342
|
+
// sanity-gate refusal).
|
|
1732
2343
|
const handlesBaked = handleStore.getData().then(resolveDeferredHandleValues);
|
|
1733
2344
|
// Bake-lane loader containers hold the gate the same way (loader-container-
|
|
1734
2345
|
// bake): a boundary-less container with real latency (a 100ms layout loader)
|
|
@@ -1774,35 +2385,32 @@ async function captureAndStoreShell(
|
|
|
1774
2385
|
() => {},
|
|
1775
2386
|
);
|
|
1776
2387
|
|
|
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
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
);
|
|
1804
|
-
return "refused";
|
|
1805
|
-
};
|
|
2388
|
+
// Deterministic capture-guard refusal (refuseOnCaptureGuard), checked at
|
|
2389
|
+
// BOTH exits below: the guard error either rejects the prerender itself
|
|
2390
|
+
// (boundary-less segment — lands in the catch) or is swallowed into
|
|
2391
|
+
// per-loader error UI (the render completes — caught after the try).
|
|
2392
|
+
const refuseOnGuardTrip = (): "refused" | undefined =>
|
|
2393
|
+
refuseOnCaptureGuard(capture.key, reqCtx) ? "refused" : undefined;
|
|
2394
|
+
|
|
2395
|
+
// Dev diagnostics for a no-shell attempt (warnNullCaptureOnce): the stacks of
|
|
2396
|
+
// the tasks React reports as still pending at the capture's abort. React's
|
|
2397
|
+
// componentStack is computed on read, so it is read only while there is room.
|
|
2398
|
+
let pendingStacks: string[] | undefined;
|
|
2399
|
+
let onAbortedTask:
|
|
2400
|
+
| ((errorInfo: { componentStack?: string } | undefined) => void)
|
|
2401
|
+
| undefined;
|
|
2402
|
+
if (process.env.NODE_ENV !== "production") {
|
|
2403
|
+
noShellPendingStacks.delete(capture.key);
|
|
2404
|
+
const stacks: string[] = [];
|
|
2405
|
+
pendingStacks = stacks;
|
|
2406
|
+
onAbortedTask = (errorInfo) => {
|
|
2407
|
+
if (stacks.length >= MAX_PENDING_STACKS) return;
|
|
2408
|
+
const componentStack = errorInfo?.componentStack;
|
|
2409
|
+
if (componentStack && !stacks.includes(componentStack)) {
|
|
2410
|
+
stacks.push(componentStack);
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
1806
2414
|
|
|
1807
2415
|
try {
|
|
1808
2416
|
// captureShellHTML CONSUMES the (gated) stream — it is not also SSR'd.
|
|
@@ -1821,6 +2429,7 @@ async function captureAndStoreShell(
|
|
|
1821
2429
|
onError: (error) => {
|
|
1822
2430
|
reqCtx._renderErrors?.push(error);
|
|
1823
2431
|
},
|
|
2432
|
+
onAbortedTask,
|
|
1824
2433
|
}),
|
|
1825
2434
|
);
|
|
1826
2435
|
} catch (error) {
|
|
@@ -1848,6 +2457,7 @@ async function captureAndStoreShell(
|
|
|
1848
2457
|
if (refused) return refused;
|
|
1849
2458
|
|
|
1850
2459
|
if (result === null) {
|
|
2460
|
+
if (pendingStacks) noShellPendingStacks.set(capture.key, pendingStacks);
|
|
1851
2461
|
return "no-shell";
|
|
1852
2462
|
}
|
|
1853
2463
|
if (stats) stats.preludeBytes = result.prelude.length;
|
|
@@ -1934,6 +2544,8 @@ async function captureAndStoreShell(
|
|
|
1934
2544
|
let serializeContainer:
|
|
1935
2545
|
| typeof import("../cache/segment-codec.js").serializeResult
|
|
1936
2546
|
| undefined;
|
|
2547
|
+
// Capture-wide, read once: every record of this snapshot agrees.
|
|
2548
|
+
const runs: 0 | 1 = reqCtx._shellCaptureUnrecordedLoaderPush ? 1 : 0;
|
|
1937
2549
|
for (const [segmentKey, containerPromise] of loaderRecords) {
|
|
1938
2550
|
const elided = await elideLoaderContainer(containerPromise);
|
|
1939
2551
|
if (elided.state === "rejected") {
|
|
@@ -1986,7 +2598,11 @@ async function captureAndStoreShell(
|
|
|
1986
2598
|
// without rescanning whether the pin can resolve immediately
|
|
1987
2599
|
// (holes: 0) or must wait for the fresh run's live promises
|
|
1988
2600
|
// (holes: 1). See ShellSnapshotLoaderValue.
|
|
1989
|
-
value: {
|
|
2601
|
+
value: {
|
|
2602
|
+
value: serialized,
|
|
2603
|
+
holes: elided.hasHole ? 1 : 0,
|
|
2604
|
+
runs,
|
|
2605
|
+
},
|
|
1990
2606
|
});
|
|
1991
2607
|
}
|
|
1992
2608
|
} catch {
|
|
@@ -2006,42 +2622,127 @@ async function captureAndStoreShell(
|
|
|
2006
2622
|
const renderErrors = reqCtx._renderErrors;
|
|
2007
2623
|
if (renderErrors && renderErrors.length > 0) throw renderErrors[0];
|
|
2008
2624
|
|
|
2625
|
+
// Record only what a HIT reads (issue #941): the doc record, plus what
|
|
2626
|
+
// the bake-lane loaders a HIT runs read. Before the size guards, so they measure
|
|
2627
|
+
// what is stored.
|
|
2628
|
+
const docKey = reqCtx._shellImplicitCache?.docKey;
|
|
2629
|
+
let prunedRecords: string | undefined;
|
|
2630
|
+
if (recording && snapshot) {
|
|
2631
|
+
const readers = snapshotReaders(capture, snapshot, docKey);
|
|
2632
|
+
if (readers && docKey !== undefined) {
|
|
2633
|
+
const { kept, pruned } = pruneShellSnapshot(
|
|
2634
|
+
snapshot,
|
|
2635
|
+
readers,
|
|
2636
|
+
recording.loaderKeys,
|
|
2637
|
+
docKey,
|
|
2638
|
+
);
|
|
2639
|
+
if (pruned.length > 0) {
|
|
2640
|
+
prunedRecords = countSnapshotFamilies(pruned);
|
|
2641
|
+
snapshot = kept.length > 0 ? kept : undefined;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2009
2646
|
// Snapshot size guard (issue #651): the snapshot duplicates every pinned
|
|
2010
|
-
// cache value inside the shell entry
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2014
|
-
//
|
|
2015
|
-
//
|
|
2016
|
-
//
|
|
2017
|
-
// client-side, the pre-snapshot behavior), which beats losing the whole
|
|
2018
|
-
// entry to a store-side write rejection. Reported once per key.
|
|
2647
|
+
// cache value inside the shell entry. `maxSnapshotBytes` bounds the PINS
|
|
2648
|
+
// (loader-family records and the item/response records loaders read);
|
|
2649
|
+
// over it they are dropped and the entry keeps its doc record, so every
|
|
2650
|
+
// HIT still replays the handler layer (the pins' loaders then read the
|
|
2651
|
+
// live store: documented drift, repaired client-side). The doc record is
|
|
2652
|
+
// exempt: without it a HIT could not serve at all. The whole entry is
|
|
2653
|
+
// bounded separately below, by the store's value limit.
|
|
2019
2654
|
if (snapshot && snapshot.length > 0) {
|
|
2020
|
-
const
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2655
|
+
const docRecord =
|
|
2656
|
+
docKey !== undefined
|
|
2657
|
+
? snapshot.find((r) => r.family === "segment" && r.key === docKey)
|
|
2658
|
+
: undefined;
|
|
2659
|
+
const pins = docRecord
|
|
2660
|
+
? snapshot.filter((r) => r !== docRecord)
|
|
2661
|
+
: snapshot;
|
|
2662
|
+
const pinBytes =
|
|
2663
|
+
pins.length > 0
|
|
2664
|
+
? SNAPSHOT_BYTE_ENCODER.encode(JSON.stringify(pins)).length
|
|
2665
|
+
: 0;
|
|
2666
|
+
if (stats) stats.snapshotBytes = pinBytes;
|
|
2024
2667
|
const cap = capture.maxSnapshotBytes ?? DEFAULT_PPR_MAX_SNAPSHOT_BYTES;
|
|
2025
|
-
if (
|
|
2026
|
-
warnSnapshotOverCapOnce(capture.key,
|
|
2027
|
-
snapshot = undefined;
|
|
2668
|
+
if (pinBytes > cap) {
|
|
2669
|
+
warnSnapshotOverCapOnce(capture.key, pinBytes, cap);
|
|
2670
|
+
snapshot = docRecord ? [docRecord] : undefined;
|
|
2028
2671
|
if (stats) stats.snapshotSkipped = true;
|
|
2029
2672
|
}
|
|
2030
2673
|
}
|
|
2031
2674
|
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2035
|
-
//
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2675
|
+
// A capture that ran handlers must store its doc record: an entry without
|
|
2676
|
+
// it could only be served by running handlers after the commit. Only a
|
|
2677
|
+
// prerender-served capture (the prerender store supplies the handler
|
|
2678
|
+
// layer on every HIT) or a navigation-only entry may lack it.
|
|
2679
|
+
const storedDocKey = hasDocRecord(snapshot, docKey) ? docKey : undefined;
|
|
2680
|
+
if (
|
|
2681
|
+
storedDocKey === undefined &&
|
|
2682
|
+
!capture.navigationOnly &&
|
|
2683
|
+
reqCtx._pprReplayPostMatchReason !== "prerender-store"
|
|
2684
|
+
) {
|
|
2685
|
+
warnCaptureRefusedOnce(capture.key, NO_DOC_RECORD_REASON);
|
|
2686
|
+
return "refused";
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Shell tags: what the shell renders from, and nothing else. The handler
|
|
2690
|
+
// layer is the doc record, whose tags are what its content recorded (the
|
|
2691
|
+
// handlers, the server components its serialization rendered, the loaders
|
|
2692
|
+
// they consumed, the handle values; cache-scope.ts collectRecordTags), or
|
|
2693
|
+
// what a replayed route cache() record carried (recordSegmentTags). So a
|
|
2694
|
+
// capture that rendered fresh and one that replayed the route's record
|
|
2695
|
+
// store the same tags. Bake-lane loaders add theirs (SHELL_BAKE_TAG_OWNER):
|
|
2696
|
+
// their data is in the shell, and no handler reads it. A live-lane loader
|
|
2697
|
+
// never runs at capture, and one whose cache() config tags are recorded
|
|
2698
|
+
// anyway is a hole's, so request-level tags are not the source. Read at
|
|
2699
|
+
// the write barrier: the record settled and the deferred writes were
|
|
2700
|
+
// awaited, so a tag recorded after an await (#676) is in. A capture
|
|
2701
|
+
// without a doc record (the prerender store served its match) keeps the
|
|
2702
|
+
// request-level set. Union with the route's static ppr.tags.
|
|
2703
|
+
const docRecordValue = storedDocKey
|
|
2704
|
+
? (snapshot?.find((r) => r.family === "segment" && r.key === storedDocKey)
|
|
2705
|
+
?.value as CachedEntryData | undefined)
|
|
2706
|
+
: undefined;
|
|
2707
|
+
const collected = docRecordValue
|
|
2708
|
+
? [
|
|
2709
|
+
...(docRecordValue.tags ?? []),
|
|
2710
|
+
...getSegmentTags(reqCtx, SHELL_BAKE_TAG_OWNER),
|
|
2711
|
+
]
|
|
2712
|
+
: [...reqCtx._requestTags];
|
|
2042
2713
|
const union = new Set<string>([...(capture.tags ?? []), ...collected]);
|
|
2043
2714
|
const shellTags = union.size > 0 ? [...union] : undefined;
|
|
2044
2715
|
|
|
2716
|
+
// Whole-entry guard: the prelude, postponed state, snapshot, and tags
|
|
2717
|
+
// travel in one stored value. Over the store's limit (Cloudflare KV:
|
|
2718
|
+
// 25 MiB) the write would fail inside waitUntil and every MISS would
|
|
2719
|
+
// recapture, so refuse here (the key backs off) instead. Measured as the
|
|
2720
|
+
// stores write it (estimateShellEntryBytes): the postponed state rides a
|
|
2721
|
+
// JSON head as an escaped string.
|
|
2722
|
+
const entryBytes = estimateShellEntryBytes({
|
|
2723
|
+
// A navigation-only entry stores no document half.
|
|
2724
|
+
preludeBytes: capture.navigationOnly ? 0 : result.prelude.length,
|
|
2725
|
+
postponed: capture.navigationOnly ? undefined : result.postponed,
|
|
2726
|
+
snapshot,
|
|
2727
|
+
tags: shellTags,
|
|
2728
|
+
docKey: storedDocKey,
|
|
2729
|
+
prunedRecords: snapshot ? prunedRecords : undefined,
|
|
2730
|
+
});
|
|
2731
|
+
if (stats) stats.entryBytes = entryBytes;
|
|
2732
|
+
const entryLimit =
|
|
2733
|
+
(capture.store ?? reqCtx._cacheStore)?.maxShellEntryBytes ??
|
|
2734
|
+
DEFAULT_SHELL_ENTRY_MAX_BYTES;
|
|
2735
|
+
if (entryBytes > entryLimit) {
|
|
2736
|
+
warnCaptureRefusedOnce(
|
|
2737
|
+
capture.key,
|
|
2738
|
+
`the shell entry is ${entryBytes} bytes (prelude, postponed state, ` +
|
|
2739
|
+
`snapshot, and head), over the store's ${entryLimit}-byte value limit. ` +
|
|
2740
|
+
"Shrink the page the shell bakes, or move large regions under a live " +
|
|
2741
|
+
"loader's boundary.",
|
|
2742
|
+
);
|
|
2743
|
+
return "refused";
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2045
2746
|
// Missing tags are valid: the shell follows TTL/SWR-only invalidation. Expose
|
|
2046
2747
|
// that choice only to operators who enabled structured capture diagnostics.
|
|
2047
2748
|
if (
|
|
@@ -2065,45 +2766,73 @@ async function captureAndStoreShell(
|
|
|
2065
2766
|
...(capture.navigationOnly
|
|
2066
2767
|
? {}
|
|
2067
2768
|
: {
|
|
2068
|
-
//
|
|
2069
|
-
//
|
|
2070
|
-
|
|
2071
|
-
// reads the whole ArrayBuffer it is handed.
|
|
2072
|
-
prelude: bufferToBase64(
|
|
2073
|
-
result.prelude.slice().buffer as ArrayBuffer,
|
|
2074
|
-
),
|
|
2769
|
+
// bytesToBase64 encodes the view's own region, so a prelude
|
|
2770
|
+
// that is a subarray of a larger buffer encodes correctly.
|
|
2771
|
+
prelude: bytesToBase64(result.prelude),
|
|
2075
2772
|
postponed: result.postponed,
|
|
2076
2773
|
}),
|
|
2077
2774
|
reactVersion: React.version,
|
|
2078
2775
|
buildVersion: capture.buildVersion,
|
|
2079
2776
|
// The theme this capture's payload was built with (buildFullPayload
|
|
2080
|
-
// reads
|
|
2081
|
-
//
|
|
2082
|
-
// ShellCacheEntry.initialTheme.
|
|
2083
|
-
initialTheme: reqCtx
|
|
2777
|
+
// reads payloadInitialTheme off the derived context: the no-cookie
|
|
2778
|
+
// default, #971). The serve tail replays it so the resume tree
|
|
2779
|
+
// matches the frozen prelude — see ShellCacheEntry.initialTheme.
|
|
2780
|
+
initialTheme: payloadInitialTheme(reqCtx),
|
|
2084
2781
|
snapshot,
|
|
2782
|
+
prunedRecords: snapshot ? prunedRecords : undefined,
|
|
2085
2783
|
// The canonical doc segment record's key, published by the doc
|
|
2086
|
-
// scope's cacheRoute during this capture's match
|
|
2087
|
-
//
|
|
2088
|
-
//
|
|
2089
|
-
docKey:
|
|
2784
|
+
// scope's cacheRoute during this capture's match. Every HIT tail
|
|
2785
|
+
// looks the record up by this key (serveShellHit fixedDocKey);
|
|
2786
|
+
// undefined only for a prerender-served entry.
|
|
2787
|
+
docKey: storedDocKey,
|
|
2090
2788
|
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
2789
|
createdAt: captureStartedAt,
|
|
2101
2790
|
};
|
|
2791
|
+
// Capped to the route record the capture used: the policy the
|
|
2792
|
+
// store would apply (resolveTtl/resolveSwrWindow, as putShell does),
|
|
2793
|
+
// made explicit.
|
|
2794
|
+
const record = reqCtx._routeRecordWindow;
|
|
2795
|
+
let window: { ttl?: number; swr?: number } = {
|
|
2796
|
+
ttl: capture.ttl,
|
|
2797
|
+
swr: capture.swr,
|
|
2798
|
+
};
|
|
2799
|
+
if (record) {
|
|
2800
|
+
const capped = capShellWindow(
|
|
2801
|
+
resolveTtl(capture.ttl, store.defaults, DEFAULT_FUNCTION_TTL),
|
|
2802
|
+
resolveSwrWindow(capture.swr, store.defaults),
|
|
2803
|
+
record,
|
|
2804
|
+
Date.now(),
|
|
2805
|
+
);
|
|
2806
|
+
if (!capped) {
|
|
2807
|
+
// Dead on arrival, not stored. A record this attempt wrote ran
|
|
2808
|
+
// out between its write and the store, which a retry repeats:
|
|
2809
|
+
// warn, refuse, back off. A record it read was near its end (its
|
|
2810
|
+
// age, queue wait): `expired` retries on a fresh match.
|
|
2811
|
+
const route = reqCtx._routeName ?? capture.key;
|
|
2812
|
+
if (record.written) {
|
|
2813
|
+
const sinceWriteMs =
|
|
2814
|
+
Date.now() - (record.freshUntil - record.ttl * 1000);
|
|
2815
|
+
warnRecordRanOutOnce(
|
|
2816
|
+
route,
|
|
2817
|
+
record,
|
|
2818
|
+
`${sinceWriteMs} ms after this capture wrote it`,
|
|
2819
|
+
);
|
|
2820
|
+
return "refused";
|
|
2821
|
+
}
|
|
2822
|
+
expiredRecords.set(capture.key, {
|
|
2823
|
+
route,
|
|
2824
|
+
record,
|
|
2825
|
+
captureMs: Date.now() - captureStartedAt,
|
|
2826
|
+
});
|
|
2827
|
+
return "expired";
|
|
2828
|
+
}
|
|
2829
|
+
window = capped;
|
|
2830
|
+
}
|
|
2102
2831
|
const storeWrite = await store.putShell(
|
|
2103
2832
|
capture.key,
|
|
2104
2833
|
entry,
|
|
2105
|
-
|
|
2106
|
-
|
|
2834
|
+
window.ttl,
|
|
2835
|
+
window.swr,
|
|
2107
2836
|
shellTags,
|
|
2108
2837
|
);
|
|
2109
2838
|
if (stats && storeWrite) stats.storeWrite = storeWrite;
|
|
@@ -2176,3 +2905,25 @@ export {
|
|
|
2176
2905
|
REFUSED_CAPTURE_MAX_MS,
|
|
2177
2906
|
REFUSED_CAPTURE_DEV_MAX_MS,
|
|
2178
2907
|
};
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* @internal Reset this module's per-isolate state: the capture stampede
|
|
2911
|
+
* guard, the refused-capture backoff, the recorded no-shell causes (which
|
|
2912
|
+
* also skip a timed-out capture's in-place retry), the once-per-key warnings
|
|
2913
|
+
* and the buffered debug events. Tests only (testing/serve-shell-request.ts
|
|
2914
|
+
* resetShellTestState), between requests, never while a capture runs.
|
|
2915
|
+
*/
|
|
2916
|
+
export function resetShellCaptureStateForTests(): void {
|
|
2917
|
+
inFlightCaptures.clear();
|
|
2918
|
+
refusedCaptures.clear();
|
|
2919
|
+
warnedNullCaptures.clear();
|
|
2920
|
+
noShellCauses.clear();
|
|
2921
|
+
noShellPendingStacks.clear();
|
|
2922
|
+
warnedRefusedCaptures.clear();
|
|
2923
|
+
warnedOverCapSnapshots.clear();
|
|
2924
|
+
warnedBakeCosts.clear();
|
|
2925
|
+
warnedExpiredRecordRoutes.clear();
|
|
2926
|
+
expiredRecords.clear();
|
|
2927
|
+
storedCaptureSeqs.clear();
|
|
2928
|
+
lastCaptureEventsForTiming.clear();
|
|
2929
|
+
}
|