@rangojs/router 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +11 -1
- package/dist/types/browser/dev-discovery.d.ts +11 -0
- package/dist/types/browser/navigation-store.d.ts +3 -13
- package/dist/types/browser/prefetch/loader.d.ts +10 -0
- package/dist/types/browser/prefetch/runtime.d.ts +3 -0
- package/dist/types/browser/types.d.ts +4 -16
- package/dist/types/build/generate-manifest.d.ts +6 -0
- package/dist/types/cache/cache-scope.d.ts +7 -5
- package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
- package/dist/types/cache/memory-segment-store.d.ts +6 -6
- package/dist/types/cache/shell-snapshot.d.ts +21 -8
- package/dist/types/cache/types.d.ts +24 -7
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
- package/dist/types/dev-discovery-protocol.d.ts +5 -0
- package/dist/types/prerender/store.d.ts +1 -0
- package/dist/types/route-map-builder.d.ts +4 -29
- package/dist/types/router/prerender-match.d.ts +4 -1
- package/dist/types/router/router-interfaces.d.ts +3 -1
- package/dist/types/rsc/handler-context.d.ts +1 -0
- package/dist/types/rsc/render-pipeline.d.ts +3 -2
- package/dist/types/rsc/rsc-rendering.d.ts +5 -10
- package/dist/types/rsc/shell-capture.d.ts +4 -8
- package/dist/types/rsc/types.d.ts +2 -0
- package/dist/types/server/context.d.ts +16 -0
- package/dist/types/server/request-context.d.ts +18 -8
- package/dist/types/server.d.ts +1 -1
- package/dist/types/types/route-entry.d.ts +3 -0
- package/dist/types/vite/discovery/state.d.ts +3 -2
- package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
- package/dist/vite/index.js +194 -107
- package/package.json +7 -2
- package/skills/caching/SKILL.md +1 -1
- package/skills/ppr/SKILL.md +48 -4
- package/src/browser/dev-discovery.ts +66 -0
- package/src/browser/navigation-client.ts +6 -0
- package/src/browser/navigation-store.ts +4 -271
- package/src/browser/partial-update.ts +7 -14
- package/src/browser/prefetch/cache.ts +1 -1
- package/src/browser/prefetch/loader.ts +110 -0
- package/src/browser/prefetch/runtime.ts +7 -0
- package/src/browser/react/Link.tsx +7 -10
- package/src/browser/react/NavigationProvider.tsx +1 -1
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/rsc-router.tsx +4 -2
- package/src/browser/types.ts +4 -27
- package/src/build/generate-manifest.ts +11 -0
- package/src/cache/cache-scope.ts +17 -6
- package/src/cache/cf/cf-cache-store.ts +63 -39
- package/src/cache/memory-segment-store.ts +17 -6
- package/src/cache/shell-snapshot.ts +45 -15
- package/src/cache/types.ts +23 -6
- package/src/cache/vercel/vercel-cache-store.ts +52 -22
- package/src/dev-discovery-protocol.ts +11 -0
- package/src/prerender/build-shell-capture.ts +7 -1
- package/src/prerender/store.ts +5 -0
- package/src/route-definition/dsl-helpers.ts +7 -2
- package/src/route-map-builder.ts +56 -49
- package/src/router/handler-context.ts +13 -5
- package/src/router/lazy-includes.ts +1 -0
- package/src/router/match-api.ts +8 -4
- package/src/router/prerender-match.ts +19 -2
- package/src/router/router-interfaces.ts +4 -0
- package/src/router/segment-resolution/static-store.ts +36 -10
- package/src/router.ts +34 -3
- package/src/rsc/handler-context.ts +1 -0
- package/src/rsc/handler.ts +3 -1
- package/src/rsc/loader-fetch.ts +2 -2
- package/src/rsc/manifest-init.ts +4 -11
- package/src/rsc/render-pipeline.ts +10 -2
- package/src/rsc/rsc-rendering.ts +207 -146
- package/src/rsc/shell-capture.ts +34 -11
- package/src/rsc/types.ts +2 -0
- package/src/server/context.ts +28 -0
- package/src/server/request-context.ts +32 -10
- package/src/server.ts +0 -2
- package/src/testing/dispatch.ts +1 -0
- package/src/types/route-entry.ts +3 -0
- package/src/urls/include-helper.ts +1 -0
- package/src/urls/path-helper.ts +8 -4
- package/src/vite/discovery/bundle-postprocess.ts +10 -7
- package/src/vite/discovery/discover-routers.ts +7 -66
- package/src/vite/discovery/prerender-collection.ts +13 -2
- package/src/vite/discovery/state.ts +4 -4
- package/src/vite/discovery/virtual-module-codegen.ts +75 -42
- package/src/vite/plugins/version-injector.ts +0 -1
- package/src/vite/plugins/virtual-entries.ts +11 -1
- package/src/vite/router-discovery.ts +132 -22
- package/src/vite/utils/manifest-utils.ts +1 -4
- package/src/vite/utils/shared-utils.ts +7 -0
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getRequestContext,
|
|
11
11
|
setRequestContextParams,
|
|
12
12
|
runWithRequestContext,
|
|
13
|
+
wireRenderBarrier,
|
|
13
14
|
} from "../server/request-context.js";
|
|
14
15
|
import {
|
|
15
16
|
SeededShellStore,
|
|
@@ -51,9 +52,13 @@ import {
|
|
|
51
52
|
describeShellTailTiming,
|
|
52
53
|
publishShellTailTiming,
|
|
53
54
|
takeShellTailTimingForServerTiming,
|
|
55
|
+
type ResolvedPprConfig,
|
|
54
56
|
type ShellTailTiming,
|
|
55
57
|
} from "./shell-serve.js";
|
|
56
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
lookupBuildShell,
|
|
60
|
+
type DevShellLookup,
|
|
61
|
+
} from "./shell-build-manifest.js";
|
|
57
62
|
import { contextGet } from "../context-var.js";
|
|
58
63
|
import {
|
|
59
64
|
resolveSameOriginRedirect,
|
|
@@ -62,7 +67,52 @@ import {
|
|
|
62
67
|
import { nonce as nonceToken } from "./nonce.js";
|
|
63
68
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
64
69
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
65
|
-
import type { ShellCacheEntry } from "../cache/types.js";
|
|
70
|
+
import type { ShellCacheEntry, ShellSnapshotRecord } from "../cache/types.js";
|
|
71
|
+
|
|
72
|
+
function resolveDevShellLookup(
|
|
73
|
+
reqCtx: RequestContext<any>,
|
|
74
|
+
pprConfig: ResolvedPprConfig,
|
|
75
|
+
): DevShellLookup | undefined {
|
|
76
|
+
if (process.env.NODE_ENV === "production") return undefined;
|
|
77
|
+
return {
|
|
78
|
+
isPrerenderRoute: reqCtx._classifiedRoute?.matched?.pr === true,
|
|
79
|
+
routeName: reqCtx._classifiedRoute?.routeKey,
|
|
80
|
+
ttl: pprConfig.ttl,
|
|
81
|
+
swr: pprConfig.swr,
|
|
82
|
+
tags: pprConfig.tags,
|
|
83
|
+
maxSnapshotBytes: pprConfig.maxSnapshotBytes,
|
|
84
|
+
captureTimeout: pprConfig.captureTimeout,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function replayableShellSnapshot(
|
|
89
|
+
entry: ShellCacheEntry | undefined,
|
|
90
|
+
buildVersion: string,
|
|
91
|
+
): ShellSnapshotRecord[] | undefined {
|
|
92
|
+
if (
|
|
93
|
+
!entry ||
|
|
94
|
+
!isValidShellHit(entry, buildVersion) ||
|
|
95
|
+
entry.handlerLiveHoles ||
|
|
96
|
+
entry.transitionWhen
|
|
97
|
+
) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const snapshot = entry.snapshot;
|
|
101
|
+
const hasSegments = snapshot?.some((record) => {
|
|
102
|
+
if (
|
|
103
|
+
!record ||
|
|
104
|
+
typeof record !== "object" ||
|
|
105
|
+
record.family !== "segment" ||
|
|
106
|
+
typeof record.value !== "object" ||
|
|
107
|
+
record.value === null
|
|
108
|
+
) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const segments = (record.value as { segments?: unknown }).segments;
|
|
112
|
+
return Array.isArray(segments) && segments.length > 0;
|
|
113
|
+
});
|
|
114
|
+
return hasSegments ? snapshot : undefined;
|
|
115
|
+
}
|
|
66
116
|
|
|
67
117
|
export function handleRscRendering<TEnv>(
|
|
68
118
|
ctx: HandlerContext<TEnv>,
|
|
@@ -109,21 +159,10 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
109
159
|
|
|
110
160
|
// --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
|
|
111
161
|
//
|
|
112
|
-
// COMMIT POINT
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// not a single shell byte can precede a guard decision, on MISS or HIT.
|
|
117
|
-
//
|
|
118
|
-
// PPR is opt-in per PAGE ROUTE via the `ppr` path option (read off the classified
|
|
119
|
-
// route snapshot — the same matched entry match() will resolve). No `ppr` option
|
|
120
|
-
// means pure axis 1: no store read, no capture, no logs, zero cost.
|
|
121
|
-
//
|
|
122
|
-
// On a valid HIT the composed response is committed HERE — the stored prelude
|
|
123
|
-
// bytes flush immediately while match()/segment resolution/Flight render/resume
|
|
124
|
-
// run behind them inside the response stream (ring-3 reads and render setup hide
|
|
125
|
-
// behind wire bytes). On a MISS the request continues as plain axis 1 and a
|
|
126
|
-
// background capture is scheduled after the response is built.
|
|
162
|
+
// COMMIT POINT: this render pass runs strictly AFTER the whole middleware
|
|
163
|
+
// chain (executeRender wraps it), so no shell byte can precede a guard
|
|
164
|
+
// decision — that ordering is what makes a shared shell safe. Routes
|
|
165
|
+
// without the `ppr` option stay pure axis 1 at zero cost.
|
|
127
166
|
let pprMiss: {
|
|
128
167
|
descriptor: ShellCaptureDescriptor;
|
|
129
168
|
ssrModule: SSRModule;
|
|
@@ -137,30 +176,18 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
137
176
|
) {
|
|
138
177
|
const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
|
|
139
178
|
if (pprConfig) {
|
|
140
|
-
// A per-request CSP nonce pins the route to axis 1:
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
// createRouter({ nonce }) provider (threaded here as `nonce`), and a direct
|
|
146
|
-
// token write in middleware (ctx.set(nonce, value)). The token is only
|
|
147
|
-
// visible in the post-middleware request variables — and this commit point
|
|
148
|
-
// runs AFTER the whole middleware chain (see the block header), so it is
|
|
149
|
-
// present here. Reading it closes the gap the provider-only check left open
|
|
150
|
-
// (issue #656). The threaded-param check stays first: the provider path is
|
|
151
|
-
// resolved before any variable read and short-circuits cheaply.
|
|
179
|
+
// A per-request CSP nonce pins the route to axis 1: a shared shell would
|
|
180
|
+
// freeze the capture request's nonce and CSP would reject it for every
|
|
181
|
+
// other visitor. BOTH nonce sources must gate — the createRouter({ nonce })
|
|
182
|
+
// provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
|
|
183
|
+
// the provider-only check missed the latter (issue #656).
|
|
152
184
|
const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
|
|
153
185
|
const store = reqCtx._cacheStore;
|
|
154
186
|
const key = buildShellKey(url);
|
|
155
|
-
// Dev Server-Timing mirror (issue #651): a capture
|
|
187
|
+
// Dev Server-Timing mirror (issue #651): a capture completes AFTER its
|
|
156
188
|
// triggering response committed, so its outcome can only ride a LATER
|
|
157
|
-
// response's header.
|
|
158
|
-
// (
|
|
159
|
-
// key into THIS request's Server-Timing as `ppr-capture;dur=<attempt
|
|
160
|
-
// ms>;desc="<outcome + sizes + waits>"`. Consuming (read-and-clear)
|
|
161
|
-
// keeps one capture = one report. Dev-only: the buffer is only written
|
|
162
|
-
// in dev (see takeCaptureDebugEventForTiming), and production folds the
|
|
163
|
-
// whole branch away.
|
|
189
|
+
// response's header. Read-and-clear keeps one capture = one report;
|
|
190
|
+
// dev-only (see takeCaptureDebugEventForTiming).
|
|
164
191
|
if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
|
|
165
192
|
const lastCapture = takeCaptureDebugEventForTiming(key);
|
|
166
193
|
if (lastCapture) {
|
|
@@ -303,16 +330,11 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
303
330
|
}
|
|
304
331
|
}
|
|
305
332
|
// Build-time shell read-through (producer B, #699): on a runtime
|
|
306
|
-
// store MISS
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
// markers) and fails to null — the ordinary MISS path below takes
|
|
312
|
-
// over. Past ppr.ttl the baked entry still serves but a runtime
|
|
313
|
-
// recapture is scheduled: SWR is the UPGRADE path from build entry
|
|
314
|
-
// to fresher runtime entry (the runtime store read above wins once
|
|
315
|
-
// the capture lands).
|
|
333
|
+
// store MISS a Prerender+ppr route serves its `vite build`-baked
|
|
334
|
+
// shell through the SAME serveShellHit. lookupBuildShell owns every
|
|
335
|
+
// gate and fails to null (ordinary MISS path takes over); past
|
|
336
|
+
// ppr.ttl the baked entry still serves while SWR recaptures — the
|
|
337
|
+
// upgrade path from build entry to runtime entry.
|
|
316
338
|
const buildHit = await lookupBuildShell(
|
|
317
339
|
url,
|
|
318
340
|
ctx.version,
|
|
@@ -321,18 +343,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
321
343
|
// the dev server's /__rsc_shell endpoint for PRERENDERED routes
|
|
322
344
|
// only (production's exact candidate set). Folded away in
|
|
323
345
|
// production builds (NODE_ENV is a compile-time constant).
|
|
324
|
-
|
|
325
|
-
? {
|
|
326
|
-
isPrerenderRoute:
|
|
327
|
-
reqCtx._classifiedRoute?.matched?.pr === true,
|
|
328
|
-
routeName: reqCtx._classifiedRoute?.routeKey,
|
|
329
|
-
ttl: pprConfig.ttl,
|
|
330
|
-
swr: pprConfig.swr,
|
|
331
|
-
tags: pprConfig.tags,
|
|
332
|
-
maxSnapshotBytes: pprConfig.maxSnapshotBytes,
|
|
333
|
-
captureTimeout: pprConfig.captureTimeout,
|
|
334
|
-
}
|
|
335
|
-
: undefined,
|
|
346
|
+
resolveDevShellLookup(reqCtx, pprConfig),
|
|
336
347
|
);
|
|
337
348
|
if (buildHit) {
|
|
338
349
|
// Past ppr.ttl: still serve the baked entry, recapture upgrades it.
|
|
@@ -348,7 +359,14 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
348
359
|
|
|
349
360
|
if (isPartial) {
|
|
350
361
|
// Partial render (navigation)
|
|
351
|
-
const result = await
|
|
362
|
+
const result = await matchPartialWithPprReplay(
|
|
363
|
+
ctx,
|
|
364
|
+
request,
|
|
365
|
+
env,
|
|
366
|
+
url,
|
|
367
|
+
reqCtx,
|
|
368
|
+
nonce,
|
|
369
|
+
);
|
|
352
370
|
|
|
353
371
|
if (!result) {
|
|
354
372
|
// Fall back to full render
|
|
@@ -517,13 +535,9 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
517
535
|
);
|
|
518
536
|
|
|
519
537
|
// --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
// does NOT flow through the HTTP pipeline: scheduleShellCapture re-derives the
|
|
524
|
-
// page via router.match() under a derived context (fresh handle store,
|
|
525
|
-
// _shellCaptureRun: true) — middleware never re-runs; it already ran for this
|
|
526
|
-
// request and guarding is serve-time.
|
|
538
|
+
// Capture only a 200 HTML document (a 404/error render is not a cacheable
|
|
539
|
+
// shell). Capture does not flow through the HTTP pipeline — middleware never
|
|
540
|
+
// re-runs (it already ran for this request; guarding is serve-time).
|
|
527
541
|
if (pprMiss && !reqCtx._dynamic) {
|
|
528
542
|
if (
|
|
529
543
|
response.status === 200 &&
|
|
@@ -546,16 +560,83 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
546
560
|
}
|
|
547
561
|
|
|
548
562
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
563
|
+
* Reuse a PPR capture's canonical segment record for a partial navigation.
|
|
564
|
+
* The ordinary matchPartial pipeline remains authoritative: it projects the
|
|
565
|
+
* cached target tree against the client's segment ids, evaluates revalidation,
|
|
566
|
+
* and resolves every loader fresh. Only segment-family records are seeded;
|
|
567
|
+
* captured item/response/loader values belong to document parity and must not
|
|
568
|
+
* pin navigation data.
|
|
569
|
+
*/
|
|
570
|
+
async function matchPartialWithPprReplay<TEnv>(
|
|
571
|
+
ctx: HandlerContext<TEnv>,
|
|
572
|
+
request: Request,
|
|
573
|
+
env: TEnv,
|
|
574
|
+
url: URL,
|
|
575
|
+
reqCtx: RequestContext<any>,
|
|
576
|
+
nonce: string | undefined,
|
|
577
|
+
) {
|
|
578
|
+
const runMatch = () => ctx.router.matchPartial(request, { env });
|
|
579
|
+
const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
|
|
580
|
+
const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
|
|
581
|
+
const store = reqCtx._cacheStore;
|
|
582
|
+
|
|
583
|
+
if (
|
|
584
|
+
request.method !== "GET" ||
|
|
585
|
+
!pprConfig ||
|
|
586
|
+
reqCtx._dynamic ||
|
|
587
|
+
activeNonce !== undefined ||
|
|
588
|
+
!hasShellFamily(store) ||
|
|
589
|
+
store.supportsPassiveShellReads !== true
|
|
590
|
+
) {
|
|
591
|
+
return runMatch();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const key = buildShellKey(url);
|
|
595
|
+
let cached: Awaited<ReturnType<typeof store.getShell>> = null;
|
|
596
|
+
try {
|
|
597
|
+
cached = await store.getShell(key, { claimRevalidation: false });
|
|
598
|
+
} catch (error) {
|
|
599
|
+
reportCacheError(error, "cache-read", "[NavigationPPR] getShell");
|
|
600
|
+
return runMatch();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let snapshot = cached?.shouldRevalidate
|
|
604
|
+
? undefined
|
|
605
|
+
: replayableShellSnapshot(cached?.entry, ctx.version);
|
|
606
|
+
|
|
607
|
+
if (!snapshot) {
|
|
608
|
+
// Production build manifests are local module data. In dev, resolving a
|
|
609
|
+
// missing build shell would foreground-fetch /__rsc_shell and block an
|
|
610
|
+
// otherwise ordinary navigation on capture, so replay remains runtime-only.
|
|
611
|
+
const buildHit = await lookupBuildShell(url, ctx.version, store);
|
|
612
|
+
if (!buildHit?.stale) {
|
|
613
|
+
snapshot = replayableShellSnapshot(buildHit?.entry, ctx.version);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!snapshot) return runMatch();
|
|
618
|
+
|
|
619
|
+
const previousImplicitCache = reqCtx._shellImplicitCache;
|
|
620
|
+
reqCtx._shellImplicitCache = {
|
|
621
|
+
ttl: pprConfig.ttl,
|
|
622
|
+
swr: pprConfig.swr,
|
|
623
|
+
store: new SeededShellStore(store, snapshot, { segmentsOnly: true }),
|
|
624
|
+
keyPrefix: "doc",
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
return await runMatch();
|
|
629
|
+
} finally {
|
|
630
|
+
reqCtx._shellImplicitCache = previousImplicitCache;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Neutralize the shell-HIT degradation redirect target. The inline
|
|
636
|
+
* `location.replace` in a committed 200 body bypasses the 3xx chokepoint
|
|
637
|
+
* (guardOutgoingRedirect only sees 3xx + Location), so this reuses the same
|
|
638
|
+
* same-origin resolver directly: unsafe targets neutralize to the
|
|
639
|
+
* redirect-guard.ts landing instead of navigating the user off-host.
|
|
559
640
|
*/
|
|
560
641
|
export function resolveShellHitRedirectTarget(
|
|
561
642
|
rawTarget: string,
|
|
@@ -569,23 +650,13 @@ export function resolveShellHitRedirectTarget(
|
|
|
569
650
|
}
|
|
570
651
|
|
|
571
652
|
/**
|
|
572
|
-
* Serve a validated shell HIT: commit the
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
*
|
|
579
|
-
* Status and headers are committed at the flush: middleware already ran (their
|
|
580
|
-
* ctx.res headers merge in via createResponseWithMergedHeaders), and route
|
|
581
|
-
* middleware code after its next() can still adjust headers on the returned
|
|
582
|
-
* Response object. A failing hole cannot become a 500/redirect after this point —
|
|
583
|
-
* error UI renders inline via Suspense/error boundaries, the documented PPR
|
|
584
|
-
* constraint.
|
|
585
|
-
*
|
|
586
|
-
* The tail promise is kicked off SYNCHRONOUSLY so match/Flight/resume run inside
|
|
587
|
-
* the current ALS request-context frame (the stream may be pulled by the server
|
|
588
|
-
* adapter outside it).
|
|
653
|
+
* Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
|
|
654
|
+
* live tail behind them inside the response stream. Plain byte concatenation is
|
|
655
|
+
* correct — React foster-parents content streamed after the prelude's closing
|
|
656
|
+
* `</body></html>`. After the flush a failing hole cannot become a 500/redirect
|
|
657
|
+
* (error UI renders inline — the documented PPR constraint). The tail promise
|
|
658
|
+
* is kicked off SYNCHRONOUSLY so it runs inside the current ALS request-context
|
|
659
|
+
* frame; the adapter may pull the stream outside it.
|
|
589
660
|
*/
|
|
590
661
|
function serveShellHit(
|
|
591
662
|
ctx: HandlerContext<any>,
|
|
@@ -612,6 +683,15 @@ function serveShellHit(
|
|
|
612
683
|
: null;
|
|
613
684
|
const tailT0 = tailTiming ? performance.now() : 0;
|
|
614
685
|
|
|
686
|
+
const createTailContext = (): RequestContext<any> => {
|
|
687
|
+
const tailCtx: RequestContext<any> = Object.create(reqCtx);
|
|
688
|
+
// Matching writes render state onto the derived context. Its barrier must
|
|
689
|
+
// close over that same context or a streaming tail inherits the base
|
|
690
|
+
// context's premature non-streaming handle snapshot.
|
|
691
|
+
wireRenderBarrier(tailCtx, handleStore);
|
|
692
|
+
return tailCtx;
|
|
693
|
+
};
|
|
694
|
+
|
|
615
695
|
const renderTail = async (
|
|
616
696
|
activeCtx: RequestContext<any>,
|
|
617
697
|
): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
|
|
@@ -628,14 +708,10 @@ function serveShellHit(
|
|
|
628
708
|
if (match.redirect) return { redirect: match.redirect };
|
|
629
709
|
setRequestContextParams(match.params, match.routeName);
|
|
630
710
|
const payload = buildFullPayload(match, ctx, url, activeCtx, handleStore);
|
|
631
|
-
// Theme fidelity for resume:
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
// tree AND client hydration both read it) so the trees agree by
|
|
636
|
-
// construction. The visitor still sees THEIR theme: the FOUC script in the
|
|
637
|
-
// prelude applies it pre-paint from the cookie, and ThemeProvider re-syncs
|
|
638
|
-
// its state from the cookie post-mount.
|
|
711
|
+
// Theme fidelity for resume: replay the CAPTURE's initialTheme into the
|
|
712
|
+
// payload so the resume/hydration trees match the frozen prelude by
|
|
713
|
+
// construction. The visitor still sees THEIR theme — the prelude's FOUC
|
|
714
|
+
// script applies the cookie pre-paint and ThemeProvider re-syncs post-mount.
|
|
639
715
|
if (payload.metadata) {
|
|
640
716
|
payload.metadata.initialTheme = entry.initialTheme as
|
|
641
717
|
| import("../theme/types.js").Theme
|
|
@@ -687,18 +763,13 @@ function serveShellHit(
|
|
|
687
763
|
const tailPromise: Promise<
|
|
688
764
|
ReadableStream<Uint8Array> | { redirect: string }
|
|
689
765
|
> = (async () => {
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
// overlay
|
|
694
|
-
//
|
|
695
|
-
// even after the underlying cache entries drifted (expired/recomputed/
|
|
696
|
-
// tag-invalidated). Everything not pinned (the holes — masked loaders were
|
|
697
|
-
// never recorded) falls through to the real store and stays LIVE. The
|
|
698
|
-
// overlay lives on a DERIVED context (own _cacheStore), so the shared reqCtx
|
|
699
|
-
// is untouched; an entry without a snapshot keeps the pre-snapshot behavior.
|
|
766
|
+
// Snapshot seeding (docs/design/ppr-shell-resume.md): the tail render must
|
|
767
|
+
// match the frozen prelude, so pinned cache reads replay their capture-time
|
|
768
|
+
// values via a SeededShellStore overlay while unpinned reads (the holes)
|
|
769
|
+
// stay live. The overlay lives on a DERIVED context so the shared reqCtx is
|
|
770
|
+
// untouched.
|
|
700
771
|
if (entry.snapshot && entry.snapshot.length > 0) {
|
|
701
|
-
const seededCtx
|
|
772
|
+
const seededCtx = createTailContext();
|
|
702
773
|
if (reqCtx._cacheStore) {
|
|
703
774
|
seededCtx._cacheStore = new SeededShellStore(
|
|
704
775
|
reqCtx._cacheStore,
|
|
@@ -720,19 +791,16 @@ function serveShellHit(
|
|
|
720
791
|
);
|
|
721
792
|
}
|
|
722
793
|
if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
|
|
723
|
-
// Shell fast path (serve side):
|
|
724
|
-
//
|
|
725
|
-
//
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
// by buildFullPayload as always). A route with handler-live holes, a
|
|
730
|
-
// route-derived cache scope, or a missing/corrupt record degrades to
|
|
731
|
-
// the full tail (handler re-run — today's behavior) automatically.
|
|
732
|
-
if (!entry.handlerLiveHoles) {
|
|
794
|
+
// Shell fast path (serve side): if the capture recorded the implicit doc
|
|
795
|
+
// segment record and the handler layer declared no liveness, the tail's
|
|
796
|
+
// cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
|
|
797
|
+
// not re-executed (loaders still run fresh). Anything else degrades to
|
|
798
|
+
// the full tail automatically.
|
|
799
|
+
if (!entry.handlerLiveHoles && !entry.transitionWhen) {
|
|
733
800
|
seededCtx._shellImplicitCache = {
|
|
734
801
|
ttl: descriptor.ttl,
|
|
735
802
|
swr: descriptor.swr,
|
|
803
|
+
keyPrefix: "doc",
|
|
736
804
|
};
|
|
737
805
|
if (INTERNAL_RANGO_DEBUG) {
|
|
738
806
|
console.log(
|
|
@@ -741,25 +809,21 @@ function serveShellHit(
|
|
|
741
809
|
}
|
|
742
810
|
} else if (INTERNAL_RANGO_DEBUG) {
|
|
743
811
|
console.log(
|
|
744
|
-
`[Server][ppr] shell HIT: fast path declined —
|
|
812
|
+
`[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
|
|
745
813
|
);
|
|
746
814
|
}
|
|
747
|
-
// Fragment splice (issue #700):
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
// context so it can never leak into a capture render (which serializes
|
|
752
|
-
// segments and must see real elements).
|
|
815
|
+
// Fragment splice (issue #700): store hits in THIS tail emit their stored
|
|
816
|
+
// segment fragments verbatim (expanded by segment-fragments.ts). The flag
|
|
817
|
+
// lives on the derived context so it can never leak into a capture render,
|
|
818
|
+
// which serializes segments and must see real elements.
|
|
753
819
|
seededCtx._shellFragmentPayload = true;
|
|
754
820
|
return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
|
|
755
821
|
}
|
|
756
|
-
// No snapshot (e.g. a producer B entry
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
|
|
761
|
-
// reqCtx and the flag must not be inherited there.
|
|
762
|
-
const fragmentCtx: RequestContext<any> = Object.create(reqCtx);
|
|
822
|
+
// No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
|
|
823
|
+
// fragment splice on a derived context — never the shared reqCtx, from which
|
|
824
|
+
// scheduleShellCapture derives the capture context (the flag must not be
|
|
825
|
+
// inherited there).
|
|
826
|
+
const fragmentCtx = createTailContext();
|
|
763
827
|
fragmentCtx._shellFragmentPayload = true;
|
|
764
828
|
return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
|
|
765
829
|
})();
|
|
@@ -853,15 +917,12 @@ function serveShellHit(
|
|
|
853
917
|
if (tailTiming) publishShellTailTiming(tailTiming);
|
|
854
918
|
controller.close();
|
|
855
919
|
} catch (error) {
|
|
856
|
-
// Self-heal on a failed tail: the pre-commit gates
|
|
857
|
-
//
|
|
858
|
-
//
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
// produced. A client disconnect mid-stream also lands here and
|
|
863
|
-
// schedules a spurious-but-idempotent recapture — bounded by the
|
|
864
|
-
// stampede guard + backoff inside scheduleShellCapture.
|
|
920
|
+
// Self-heal on a failed tail: errors the pre-commit gates cannot catch
|
|
921
|
+
// (mismatched postponed blob, hard render error above the holes) throw
|
|
922
|
+
// here AFTER the 200 + prelude flushed and would re-fail on every
|
|
923
|
+
// request until the entry ages out — recapture overwrites the entry.
|
|
924
|
+
// Client disconnects land here too; the recapture is idempotent and
|
|
925
|
+
// bounded by scheduleShellCapture's stampede guard + backoff.
|
|
865
926
|
scheduleShellCapture(
|
|
866
927
|
ctx,
|
|
867
928
|
request,
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -476,6 +476,8 @@ export interface ShellCaptureDebugEvent {
|
|
|
476
476
|
snapshotBytes?: number;
|
|
477
477
|
/** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
|
|
478
478
|
snapshotSkipped?: boolean;
|
|
479
|
+
/** Outcome reported by a store that supports shell-write acknowledgements. */
|
|
480
|
+
storeWrite?: "stored" | "invalidated";
|
|
479
481
|
/** Consecutive failure count in the key's backoff entry, when one exists. */
|
|
480
482
|
backoffFailures?: number;
|
|
481
483
|
/** Ms remaining in the key's backoff window, when one exists. */
|
|
@@ -516,6 +518,9 @@ export function describeShellCaptureEvent(
|
|
|
516
518
|
`snapshot=${event.snapshotBytes}b${event.snapshotSkipped ? " (over cap, skipped)" : ""}`,
|
|
517
519
|
);
|
|
518
520
|
}
|
|
521
|
+
if (event.storeWrite !== undefined) {
|
|
522
|
+
parts.push(`store-write=${event.storeWrite}`);
|
|
523
|
+
}
|
|
519
524
|
if (event.backoffFailures !== undefined) {
|
|
520
525
|
parts.push(`backoff-failures=${event.backoffFailures}`);
|
|
521
526
|
}
|
|
@@ -941,13 +946,15 @@ type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
|
|
|
941
946
|
* bag, not a return value: captureAndStoreShell's outcome type stays a string
|
|
942
947
|
* union its existing callers (producer B, tests) consume unchanged.
|
|
943
948
|
*/
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
949
|
+
type CaptureAttemptStats = Pick<
|
|
950
|
+
ShellCaptureDebugEvent,
|
|
951
|
+
| "barrierWaitMs"
|
|
952
|
+
| "writeSettleMs"
|
|
953
|
+
| "preludeBytes"
|
|
954
|
+
| "snapshotBytes"
|
|
955
|
+
| "snapshotSkipped"
|
|
956
|
+
| "storeWrite"
|
|
957
|
+
>;
|
|
951
958
|
|
|
952
959
|
/**
|
|
953
960
|
* Run the shell capture with a single in-place retry, then store the result.
|
|
@@ -1116,6 +1123,7 @@ async function attemptCapture(
|
|
|
1116
1123
|
reqCtx,
|
|
1117
1124
|
descriptor,
|
|
1118
1125
|
);
|
|
1126
|
+
const captureStartedAt = Date.now();
|
|
1119
1127
|
|
|
1120
1128
|
return runWithRequestContext(derivedCtx, async () => {
|
|
1121
1129
|
const match = await ctx.router.match(request, { env });
|
|
@@ -1157,6 +1165,7 @@ async function attemptCapture(
|
|
|
1157
1165
|
derivedCtx,
|
|
1158
1166
|
descriptor,
|
|
1159
1167
|
stats,
|
|
1168
|
+
captureStartedAt,
|
|
1160
1169
|
);
|
|
1161
1170
|
});
|
|
1162
1171
|
}
|
|
@@ -1329,6 +1338,7 @@ export function deriveShellCaptureContext(
|
|
|
1329
1338
|
ttl: descriptor.ttl,
|
|
1330
1339
|
swr: descriptor.swr,
|
|
1331
1340
|
store: new SnapshotOnlySegmentStore(recordingStore),
|
|
1341
|
+
keyPrefix: "doc",
|
|
1332
1342
|
};
|
|
1333
1343
|
}
|
|
1334
1344
|
|
|
@@ -1355,6 +1365,7 @@ async function captureAndStoreShell(
|
|
|
1355
1365
|
reqCtx: RequestContext<any>,
|
|
1356
1366
|
capture: ShellCaptureDescriptor,
|
|
1357
1367
|
stats?: CaptureAttemptStats,
|
|
1368
|
+
captureStartedAt: number = Date.now(),
|
|
1358
1369
|
): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
|
|
1359
1370
|
const captureShellHTML = ssrModule.captureShellHTML!;
|
|
1360
1371
|
|
|
@@ -1661,15 +1672,26 @@ async function captureAndStoreShell(
|
|
|
1661
1672
|
handlerLiveHoles: handlerLayerIsLive(
|
|
1662
1673
|
reqCtx._shellCaptureHandleLiveness,
|
|
1663
1674
|
),
|
|
1664
|
-
|
|
1675
|
+
transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
|
|
1676
|
+
createdAt: captureStartedAt,
|
|
1665
1677
|
};
|
|
1666
|
-
await store.putShell(
|
|
1678
|
+
const storeWrite = await store.putShell(
|
|
1667
1679
|
capture.key,
|
|
1668
1680
|
entry,
|
|
1669
1681
|
capture.ttl,
|
|
1670
1682
|
capture.swr,
|
|
1671
1683
|
shellTags,
|
|
1672
1684
|
);
|
|
1685
|
+
if (stats && storeWrite) stats.storeWrite = storeWrite;
|
|
1686
|
+
if (storeWrite === "invalidated") {
|
|
1687
|
+
warnCaptureRefusedOnce(
|
|
1688
|
+
capture.key,
|
|
1689
|
+
"one of the shell's tags was invalidated after this capture generation started, so the store rejected the write. " +
|
|
1690
|
+
"If capture code deterministically calls updateTag() on its own shell tag, move that mutation out of the render; " +
|
|
1691
|
+
"otherwise every generation is invalidated before it can be served.",
|
|
1692
|
+
);
|
|
1693
|
+
return "refused";
|
|
1694
|
+
}
|
|
1673
1695
|
} catch (error) {
|
|
1674
1696
|
// Best-effort: a failed put must never throw out of the background task.
|
|
1675
1697
|
reportCacheError(
|
|
@@ -1680,8 +1702,9 @@ async function captureAndStoreShell(
|
|
|
1680
1702
|
);
|
|
1681
1703
|
}
|
|
1682
1704
|
}
|
|
1683
|
-
// A shell was captured (
|
|
1684
|
-
//
|
|
1705
|
+
// A shell was captured (ordinary store I/O failure is reported, not retried).
|
|
1706
|
+
// An acknowledged invalidation rejection returned `refused` above so the
|
|
1707
|
+
// scheduler backs the key off instead of recapturing on every request.
|
|
1685
1708
|
return "stored";
|
|
1686
1709
|
} finally {
|
|
1687
1710
|
// Stop the hop loop for the pathological never-quiets path (quiesce never
|
package/src/rsc/types.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface RscPayload {
|
|
|
41
41
|
handles?: AsyncGenerator<HandleData, void, unknown>;
|
|
42
42
|
/** RSC version string for cache invalidation */
|
|
43
43
|
version?: string;
|
|
44
|
+
/** Cloudflare dev worker generation used for stale-document convergence. */
|
|
45
|
+
devDiscoveryEpoch?: number;
|
|
44
46
|
/** TTL in milliseconds for the client-side in-memory prefetch cache */
|
|
45
47
|
prefetchCacheTTL?: number;
|
|
46
48
|
/** Max entries in the client-side in-memory prefetch cache (FIFO eviction) */
|