@rangojs/router 0.1.0 → 0.2.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 +2 -1
- 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/document-cache.d.ts +3 -1
- 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 +25 -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-definition/helpers-types.d.ts +6 -6
- package/dist/types/route-map-builder.d.ts +4 -29
- package/dist/types/router/match-handlers.d.ts +2 -3
- package/dist/types/router/prerender-match.d.ts +4 -1
- package/dist/types/router/router-interfaces.d.ts +3 -1
- package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
- package/dist/types/router/transition-when.d.ts +13 -0
- 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/shell-serve.d.ts +2 -0
- package/dist/types/rsc/transition-gate.d.ts +10 -14
- 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 +22 -8
- package/dist/types/server.d.ts +1 -1
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -2
- package/dist/types/testing/run-transition-when.d.ts +6 -5
- package/dist/types/testing/shell-status.d.ts +23 -3
- package/dist/types/types/route-entry.d.ts +3 -0
- package/dist/types/types/segments.d.ts +27 -22
- package/dist/types/urls/path-helper-types.d.ts +4 -3
- 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 +24 -20
- package/skills/cache-guide/SKILL.md +6 -3
- package/skills/caching/SKILL.md +1 -1
- package/skills/catalog.json +7 -1
- package/skills/deployment-caching/SKILL.md +176 -0
- package/skills/document-cache/SKILL.md +30 -3
- package/skills/ppr/SKILL.md +90 -14
- package/skills/prerender/SKILL.md +15 -8
- package/skills/rango/SKILL.md +20 -17
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/cache-prerender.md +5 -1
- package/skills/vercel/SKILL.md +22 -1
- package/skills/view-transitions/SKILL.md +12 -8
- 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 +23 -7
- package/src/cache/cf/cf-cache-store.ts +63 -39
- package/src/cache/document-cache.ts +4 -2
- package/src/cache/memory-segment-store.ts +17 -6
- package/src/cache/shell-snapshot.ts +45 -15
- package/src/cache/types.ts +24 -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-definition/helpers-types.ts +6 -6
- 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/match-handlers.ts +44 -6
- package/src/router/match-middleware/cache-lookup.ts +10 -3
- 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/segment-resolution/view-transition-default.ts +9 -5
- package/src/router/transition-when.ts +76 -0
- 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/progressive-enhancement.ts +9 -14
- package/src/rsc/render-pipeline.ts +10 -2
- package/src/rsc/rsc-rendering.ts +319 -147
- package/src/rsc/shell-capture.ts +34 -11
- package/src/rsc/shell-serve.ts +3 -0
- package/src/rsc/transition-gate.ts +37 -40
- package/src/rsc/types.ts +2 -0
- package/src/server/context.ts +28 -0
- package/src/server/request-context.ts +38 -10
- package/src/server.ts +0 -2
- package/src/testing/dispatch.ts +1 -0
- package/src/testing/e2e/index.ts +5 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run-transition-when.ts +42 -9
- package/src/testing/shell-status.ts +92 -3
- package/src/types/route-entry.ts +3 -0
- package/src/types/segments.ts +27 -22
- package/src/urls/include-helper.ts +1 -0
- package/src/urls/path-helper-types.ts +4 -3
- 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,
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
type ShellCaptureDescriptor,
|
|
40
41
|
} from "./shell-capture.js";
|
|
41
42
|
import {
|
|
43
|
+
PPR_REPLAY_STATUS_HEADER,
|
|
42
44
|
SHELL_STATUS_HEADER,
|
|
43
45
|
resolvePprConfig,
|
|
44
46
|
buildShellKey,
|
|
@@ -51,9 +53,13 @@ import {
|
|
|
51
53
|
describeShellTailTiming,
|
|
52
54
|
publishShellTailTiming,
|
|
53
55
|
takeShellTailTimingForServerTiming,
|
|
56
|
+
type ResolvedPprConfig,
|
|
54
57
|
type ShellTailTiming,
|
|
55
58
|
} from "./shell-serve.js";
|
|
56
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
lookupBuildShell,
|
|
61
|
+
type DevShellLookup,
|
|
62
|
+
} from "./shell-build-manifest.js";
|
|
57
63
|
import { contextGet } from "../context-var.js";
|
|
58
64
|
import {
|
|
59
65
|
resolveSameOriginRedirect,
|
|
@@ -62,7 +68,88 @@ import {
|
|
|
62
68
|
import { nonce as nonceToken } from "./nonce.js";
|
|
63
69
|
import { reportCacheError } from "../cache/cache-error.js";
|
|
64
70
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
65
|
-
import type { ShellCacheEntry } from "../cache/types.js";
|
|
71
|
+
import type { ShellCacheEntry, ShellSnapshotRecord } from "../cache/types.js";
|
|
72
|
+
|
|
73
|
+
type PprReplayBypassReason =
|
|
74
|
+
| "method"
|
|
75
|
+
| "dynamic"
|
|
76
|
+
| "nonce"
|
|
77
|
+
| "store-unavailable"
|
|
78
|
+
| "passive-read-unsupported"
|
|
79
|
+
| "read-error"
|
|
80
|
+
| "no-entry"
|
|
81
|
+
| "invalid-version"
|
|
82
|
+
| "corrupt-entry"
|
|
83
|
+
| "handler-live-holes"
|
|
84
|
+
| "transition-when"
|
|
85
|
+
| "no-segment-snapshot"
|
|
86
|
+
| "snapshot-miss"
|
|
87
|
+
| "stale-build-entry";
|
|
88
|
+
|
|
89
|
+
type PprReplayStatus =
|
|
90
|
+
| { outcome: "HIT"; freshness: "fresh" | "stale" }
|
|
91
|
+
| { outcome: "BYPASS"; reason: PprReplayBypassReason };
|
|
92
|
+
|
|
93
|
+
type ShellReplayDecision =
|
|
94
|
+
| { snapshot: ShellSnapshotRecord[] }
|
|
95
|
+
| { reason: PprReplayBypassReason };
|
|
96
|
+
|
|
97
|
+
function serializePprReplayStatus(status: PprReplayStatus): string {
|
|
98
|
+
return status.outcome === "HIT"
|
|
99
|
+
? `HIT; freshness=${status.freshness}`
|
|
100
|
+
: `BYPASS; reason=${status.reason}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describePprReplayStatus(status: PprReplayStatus): string {
|
|
104
|
+
return status.outcome === "HIT"
|
|
105
|
+
? status.freshness
|
|
106
|
+
: `bypass:${status.reason}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveDevShellLookup(
|
|
110
|
+
reqCtx: RequestContext<any>,
|
|
111
|
+
pprConfig: ResolvedPprConfig,
|
|
112
|
+
): DevShellLookup | undefined {
|
|
113
|
+
if (process.env.NODE_ENV === "production") return undefined;
|
|
114
|
+
return {
|
|
115
|
+
isPrerenderRoute: reqCtx._classifiedRoute?.matched?.pr === true,
|
|
116
|
+
routeName: reqCtx._classifiedRoute?.routeKey,
|
|
117
|
+
ttl: pprConfig.ttl,
|
|
118
|
+
swr: pprConfig.swr,
|
|
119
|
+
tags: pprConfig.tags,
|
|
120
|
+
maxSnapshotBytes: pprConfig.maxSnapshotBytes,
|
|
121
|
+
captureTimeout: pprConfig.captureTimeout,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function replayableShellSnapshot(
|
|
126
|
+
entry: ShellCacheEntry,
|
|
127
|
+
buildVersion: string,
|
|
128
|
+
): ShellReplayDecision {
|
|
129
|
+
if (!isValidShellHit(entry, buildVersion)) {
|
|
130
|
+
return { reason: "invalid-version" };
|
|
131
|
+
}
|
|
132
|
+
if (!hasIntactShellPayload(entry)) return { reason: "corrupt-entry" };
|
|
133
|
+
if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
|
|
134
|
+
if (entry.transitionWhen) return { reason: "transition-when" };
|
|
135
|
+
const snapshot = entry.snapshot;
|
|
136
|
+
const hasSegments = snapshot?.some((record) => {
|
|
137
|
+
if (
|
|
138
|
+
!record ||
|
|
139
|
+
typeof record !== "object" ||
|
|
140
|
+
record.family !== "segment" ||
|
|
141
|
+
typeof record.value !== "object" ||
|
|
142
|
+
record.value === null
|
|
143
|
+
) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const segments = (record.value as { segments?: unknown }).segments;
|
|
147
|
+
return Array.isArray(segments) && segments.length > 0;
|
|
148
|
+
});
|
|
149
|
+
return hasSegments && snapshot
|
|
150
|
+
? { snapshot }
|
|
151
|
+
: { reason: "no-segment-snapshot" };
|
|
152
|
+
}
|
|
66
153
|
|
|
67
154
|
export function handleRscRendering<TEnv>(
|
|
68
155
|
ctx: HandlerContext<TEnv>,
|
|
@@ -106,24 +193,14 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
106
193
|
|
|
107
194
|
let payload: RscPayload;
|
|
108
195
|
let hasInterceptSlots = false;
|
|
196
|
+
let pprReplayStatus: PprReplayStatus | undefined;
|
|
109
197
|
|
|
110
198
|
// --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
|
|
111
199
|
//
|
|
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.
|
|
200
|
+
// COMMIT POINT: this render pass runs strictly AFTER the whole middleware
|
|
201
|
+
// chain (executeRender wraps it), so no shell byte can precede a guard
|
|
202
|
+
// decision — that ordering is what makes a shared shell safe. Routes
|
|
203
|
+
// without the `ppr` option stay pure axis 1 at zero cost.
|
|
127
204
|
let pprMiss: {
|
|
128
205
|
descriptor: ShellCaptureDescriptor;
|
|
129
206
|
ssrModule: SSRModule;
|
|
@@ -137,30 +214,18 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
137
214
|
) {
|
|
138
215
|
const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
|
|
139
216
|
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.
|
|
217
|
+
// A per-request CSP nonce pins the route to axis 1: a shared shell would
|
|
218
|
+
// freeze the capture request's nonce and CSP would reject it for every
|
|
219
|
+
// other visitor. BOTH nonce sources must gate — the createRouter({ nonce })
|
|
220
|
+
// provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
|
|
221
|
+
// the provider-only check missed the latter (issue #656).
|
|
152
222
|
const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
|
|
153
223
|
const store = reqCtx._cacheStore;
|
|
154
224
|
const key = buildShellKey(url);
|
|
155
|
-
// Dev Server-Timing mirror (issue #651): a capture
|
|
225
|
+
// Dev Server-Timing mirror (issue #651): a capture completes AFTER its
|
|
156
226
|
// 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.
|
|
227
|
+
// response's header. Read-and-clear keeps one capture = one report;
|
|
228
|
+
// dev-only (see takeCaptureDebugEventForTiming).
|
|
164
229
|
if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
|
|
165
230
|
const lastCapture = takeCaptureDebugEventForTiming(key);
|
|
166
231
|
if (lastCapture) {
|
|
@@ -303,16 +368,11 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
303
368
|
}
|
|
304
369
|
}
|
|
305
370
|
// 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).
|
|
371
|
+
// store MISS a Prerender+ppr route serves its `vite build`-baked
|
|
372
|
+
// shell through the SAME serveShellHit. lookupBuildShell owns every
|
|
373
|
+
// gate and fails to null (ordinary MISS path takes over); past
|
|
374
|
+
// ppr.ttl the baked entry still serves while SWR recaptures — the
|
|
375
|
+
// upgrade path from build entry to runtime entry.
|
|
316
376
|
const buildHit = await lookupBuildShell(
|
|
317
377
|
url,
|
|
318
378
|
ctx.version,
|
|
@@ -321,18 +381,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
321
381
|
// the dev server's /__rsc_shell endpoint for PRERENDERED routes
|
|
322
382
|
// only (production's exact candidate set). Folded away in
|
|
323
383
|
// 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,
|
|
384
|
+
resolveDevShellLookup(reqCtx, pprConfig),
|
|
336
385
|
);
|
|
337
386
|
if (buildHit) {
|
|
338
387
|
// Past ppr.ttl: still serve the baked entry, recapture upgrades it.
|
|
@@ -348,7 +397,16 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
348
397
|
|
|
349
398
|
if (isPartial) {
|
|
350
399
|
// Partial render (navigation)
|
|
351
|
-
const
|
|
400
|
+
const replay = await matchPartialWithPprReplay(
|
|
401
|
+
ctx,
|
|
402
|
+
request,
|
|
403
|
+
env,
|
|
404
|
+
url,
|
|
405
|
+
reqCtx,
|
|
406
|
+
nonce,
|
|
407
|
+
);
|
|
408
|
+
const result = replay.result;
|
|
409
|
+
pprReplayStatus = replay.status;
|
|
352
410
|
|
|
353
411
|
if (!result) {
|
|
354
412
|
// Fall back to full render
|
|
@@ -360,10 +418,17 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
360
418
|
// perform SPA navigation. A raw 308 would be auto-followed by
|
|
361
419
|
// fetch, hitting the target without _rsc_partial. Resolve the
|
|
362
420
|
// target server-side (same open-redirect policy as 3xx).
|
|
363
|
-
|
|
421
|
+
const redirectResponse = createSimpleRedirectResponse(match.redirect, {
|
|
364
422
|
requestOrigin: url.origin,
|
|
365
423
|
basename: ctx.router.basename,
|
|
366
424
|
});
|
|
425
|
+
if (pprReplayStatus) {
|
|
426
|
+
redirectResponse.headers.set(
|
|
427
|
+
PPR_REPLAY_STATUS_HEADER,
|
|
428
|
+
serializePprReplayStatus(pprReplayStatus),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return redirectResponse;
|
|
367
432
|
}
|
|
368
433
|
|
|
369
434
|
payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
|
|
@@ -466,6 +531,10 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
466
531
|
// are deliberately NOT stamped. See browser/response-adapter.ts.
|
|
467
532
|
"X-RSC-Router-Id": ctx.router.id,
|
|
468
533
|
};
|
|
534
|
+
if (pprReplayStatus) {
|
|
535
|
+
rscHeaders[PPR_REPLAY_STATUS_HEADER] =
|
|
536
|
+
serializePprReplayStatus(pprReplayStatus);
|
|
537
|
+
}
|
|
469
538
|
// Tell the client's prefetch cache to scope this response to its source
|
|
470
539
|
// URL (instead of the default source-agnostic wildcard). Intercept
|
|
471
540
|
// responses depend on the source page matching an intercept rule, so
|
|
@@ -517,13 +586,9 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
517
586
|
);
|
|
518
587
|
|
|
519
588
|
// --- 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.
|
|
589
|
+
// Capture only a 200 HTML document (a 404/error render is not a cacheable
|
|
590
|
+
// shell). Capture does not flow through the HTTP pipeline — middleware never
|
|
591
|
+
// re-runs (it already ran for this request; guarding is serve-time).
|
|
527
592
|
if (pprMiss && !reqCtx._dynamic) {
|
|
528
593
|
if (
|
|
529
594
|
response.status === 200 &&
|
|
@@ -546,16 +611,143 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
546
611
|
}
|
|
547
612
|
|
|
548
613
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
614
|
+
* Reuse a PPR capture's canonical segment record for a partial navigation.
|
|
615
|
+
* The ordinary matchPartial pipeline remains authoritative: it projects the
|
|
616
|
+
* cached target tree against the client's segment ids, evaluates revalidation,
|
|
617
|
+
* and resolves every loader fresh. Only segment-family records are seeded;
|
|
618
|
+
* captured item/response/loader values belong to document parity and must not
|
|
619
|
+
* pin navigation data.
|
|
620
|
+
*/
|
|
621
|
+
async function matchPartialWithPprReplay<TEnv>(
|
|
622
|
+
ctx: HandlerContext<TEnv>,
|
|
623
|
+
request: Request,
|
|
624
|
+
env: TEnv,
|
|
625
|
+
url: URL,
|
|
626
|
+
reqCtx: RequestContext<any>,
|
|
627
|
+
nonce: string | undefined,
|
|
628
|
+
) {
|
|
629
|
+
const replayStart = reqCtx._metricsStore ? performance.now() : 0;
|
|
630
|
+
const recordReplayStatus = (status: PprReplayStatus): void => {
|
|
631
|
+
if (!reqCtx._metricsStore) return;
|
|
632
|
+
appendMetric(
|
|
633
|
+
reqCtx._metricsStore,
|
|
634
|
+
"ppr:navigation-replay",
|
|
635
|
+
replayStart,
|
|
636
|
+
performance.now() - replayStart,
|
|
637
|
+
undefined,
|
|
638
|
+
describePprReplayStatus(status),
|
|
639
|
+
);
|
|
640
|
+
};
|
|
641
|
+
const runMatch = async (status?: PprReplayStatus) => {
|
|
642
|
+
const result = await ctx.router.matchPartial(request, { env });
|
|
643
|
+
if (status) recordReplayStatus(status);
|
|
644
|
+
return { result, status };
|
|
645
|
+
};
|
|
646
|
+
const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
|
|
647
|
+
const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
|
|
648
|
+
const store = reqCtx._cacheStore;
|
|
649
|
+
|
|
650
|
+
if (!pprConfig) return runMatch();
|
|
651
|
+
if (request.method !== "GET") {
|
|
652
|
+
return runMatch({ outcome: "BYPASS", reason: "method" });
|
|
653
|
+
}
|
|
654
|
+
if (reqCtx._dynamic) {
|
|
655
|
+
return runMatch({ outcome: "BYPASS", reason: "dynamic" });
|
|
656
|
+
}
|
|
657
|
+
if (activeNonce !== undefined) {
|
|
658
|
+
return runMatch({ outcome: "BYPASS", reason: "nonce" });
|
|
659
|
+
}
|
|
660
|
+
if (!hasShellFamily(store)) {
|
|
661
|
+
return runMatch({ outcome: "BYPASS", reason: "store-unavailable" });
|
|
662
|
+
}
|
|
663
|
+
if (store.supportsPassiveShellReads !== true) {
|
|
664
|
+
return runMatch({
|
|
665
|
+
outcome: "BYPASS",
|
|
666
|
+
reason: "passive-read-unsupported",
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const key = buildShellKey(url);
|
|
671
|
+
let cached: Awaited<ReturnType<typeof store.getShell>> = null;
|
|
672
|
+
try {
|
|
673
|
+
cached = await store.getShell(key, { claimRevalidation: false });
|
|
674
|
+
} catch (error) {
|
|
675
|
+
reportCacheError(error, "cache-read", "[NavigationPPR] getShell");
|
|
676
|
+
return runMatch({ outcome: "BYPASS", reason: "read-error" });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
let bypassReason: PprReplayBypassReason | undefined;
|
|
680
|
+
let snapshot: ShellSnapshotRecord[] | undefined;
|
|
681
|
+
let freshness: "fresh" | "stale" = "fresh";
|
|
682
|
+
if (cached) {
|
|
683
|
+
const decision = replayableShellSnapshot(cached.entry, ctx.version);
|
|
684
|
+
if ("snapshot" in decision) {
|
|
685
|
+
snapshot = decision.snapshot;
|
|
686
|
+
freshness = cached.shouldRevalidate ? "stale" : "fresh";
|
|
687
|
+
} else {
|
|
688
|
+
bypassReason = decision.reason;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (!snapshot) {
|
|
693
|
+
// Production build manifests are local module data. In dev, resolving a
|
|
694
|
+
// missing build shell would foreground-fetch /__rsc_shell and block an
|
|
695
|
+
// otherwise ordinary navigation on capture, so replay remains runtime-only.
|
|
696
|
+
const buildHit = await lookupBuildShell(url, ctx.version, store);
|
|
697
|
+
if (buildHit?.stale) {
|
|
698
|
+
bypassReason ??= "stale-build-entry";
|
|
699
|
+
} else if (buildHit) {
|
|
700
|
+
const buildDecision = replayableShellSnapshot(
|
|
701
|
+
buildHit.entry,
|
|
702
|
+
ctx.version,
|
|
703
|
+
);
|
|
704
|
+
if ("snapshot" in buildDecision) {
|
|
705
|
+
snapshot = buildDecision.snapshot;
|
|
706
|
+
} else {
|
|
707
|
+
bypassReason ??= buildDecision.reason;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!snapshot) {
|
|
713
|
+
return runMatch({
|
|
714
|
+
outcome: "BYPASS",
|
|
715
|
+
reason: bypassReason ?? "no-entry",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const previousImplicitCache = reqCtx._shellImplicitCache;
|
|
720
|
+
let segmentReplayHit = false;
|
|
721
|
+
reqCtx._shellImplicitCache = {
|
|
722
|
+
ttl: pprConfig.ttl,
|
|
723
|
+
swr: pprConfig.swr,
|
|
724
|
+
store: new SeededShellStore(store, snapshot, {
|
|
725
|
+
segmentsOnly: true,
|
|
726
|
+
}),
|
|
727
|
+
keyPrefix: "doc",
|
|
728
|
+
onHit: () => {
|
|
729
|
+
segmentReplayHit = true;
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
try {
|
|
734
|
+
const result = await ctx.router.matchPartial(request, { env });
|
|
735
|
+
const status: PprReplayStatus = segmentReplayHit
|
|
736
|
+
? { outcome: "HIT", freshness }
|
|
737
|
+
: { outcome: "BYPASS", reason: "snapshot-miss" };
|
|
738
|
+
recordReplayStatus(status);
|
|
739
|
+
return { result, status };
|
|
740
|
+
} finally {
|
|
741
|
+
reqCtx._shellImplicitCache = previousImplicitCache;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Neutralize the shell-HIT degradation redirect target. The inline
|
|
747
|
+
* `location.replace` in a committed 200 body bypasses the 3xx chokepoint
|
|
748
|
+
* (guardOutgoingRedirect only sees 3xx + Location), so this reuses the same
|
|
749
|
+
* same-origin resolver directly: unsafe targets neutralize to the
|
|
750
|
+
* redirect-guard.ts landing instead of navigating the user off-host.
|
|
559
751
|
*/
|
|
560
752
|
export function resolveShellHitRedirectTarget(
|
|
561
753
|
rawTarget: string,
|
|
@@ -569,23 +761,13 @@ export function resolveShellHitRedirectTarget(
|
|
|
569
761
|
}
|
|
570
762
|
|
|
571
763
|
/**
|
|
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).
|
|
764
|
+
* Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
|
|
765
|
+
* live tail behind them inside the response stream. Plain byte concatenation is
|
|
766
|
+
* correct — React foster-parents content streamed after the prelude's closing
|
|
767
|
+
* `</body></html>`. After the flush a failing hole cannot become a 500/redirect
|
|
768
|
+
* (error UI renders inline — the documented PPR constraint). The tail promise
|
|
769
|
+
* is kicked off SYNCHRONOUSLY so it runs inside the current ALS request-context
|
|
770
|
+
* frame; the adapter may pull the stream outside it.
|
|
589
771
|
*/
|
|
590
772
|
function serveShellHit(
|
|
591
773
|
ctx: HandlerContext<any>,
|
|
@@ -612,6 +794,15 @@ function serveShellHit(
|
|
|
612
794
|
: null;
|
|
613
795
|
const tailT0 = tailTiming ? performance.now() : 0;
|
|
614
796
|
|
|
797
|
+
const createTailContext = (): RequestContext<any> => {
|
|
798
|
+
const tailCtx: RequestContext<any> = Object.create(reqCtx);
|
|
799
|
+
// Matching writes render state onto the derived context. Its barrier must
|
|
800
|
+
// close over that same context or a streaming tail inherits the base
|
|
801
|
+
// context's premature non-streaming handle snapshot.
|
|
802
|
+
wireRenderBarrier(tailCtx, handleStore);
|
|
803
|
+
return tailCtx;
|
|
804
|
+
};
|
|
805
|
+
|
|
615
806
|
const renderTail = async (
|
|
616
807
|
activeCtx: RequestContext<any>,
|
|
617
808
|
): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
|
|
@@ -628,14 +819,10 @@ function serveShellHit(
|
|
|
628
819
|
if (match.redirect) return { redirect: match.redirect };
|
|
629
820
|
setRequestContextParams(match.params, match.routeName);
|
|
630
821
|
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.
|
|
822
|
+
// Theme fidelity for resume: replay the CAPTURE's initialTheme into the
|
|
823
|
+
// payload so the resume/hydration trees match the frozen prelude by
|
|
824
|
+
// construction. The visitor still sees THEIR theme — the prelude's FOUC
|
|
825
|
+
// script applies the cookie pre-paint and ThemeProvider re-syncs post-mount.
|
|
639
826
|
if (payload.metadata) {
|
|
640
827
|
payload.metadata.initialTheme = entry.initialTheme as
|
|
641
828
|
| import("../theme/types.js").Theme
|
|
@@ -687,18 +874,13 @@ function serveShellHit(
|
|
|
687
874
|
const tailPromise: Promise<
|
|
688
875
|
ReadableStream<Uint8Array> | { redirect: string }
|
|
689
876
|
> = (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.
|
|
877
|
+
// Snapshot seeding (docs/design/ppr-shell-resume.md): the tail render must
|
|
878
|
+
// match the frozen prelude, so pinned cache reads replay their capture-time
|
|
879
|
+
// values via a SeededShellStore overlay while unpinned reads (the holes)
|
|
880
|
+
// stay live. The overlay lives on a DERIVED context so the shared reqCtx is
|
|
881
|
+
// untouched.
|
|
700
882
|
if (entry.snapshot && entry.snapshot.length > 0) {
|
|
701
|
-
const seededCtx
|
|
883
|
+
const seededCtx = createTailContext();
|
|
702
884
|
if (reqCtx._cacheStore) {
|
|
703
885
|
seededCtx._cacheStore = new SeededShellStore(
|
|
704
886
|
reqCtx._cacheStore,
|
|
@@ -720,19 +902,16 @@ function serveShellHit(
|
|
|
720
902
|
);
|
|
721
903
|
}
|
|
722
904
|
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) {
|
|
905
|
+
// Shell fast path (serve side): if the capture recorded the implicit doc
|
|
906
|
+
// segment record and the handler layer declared no liveness, the tail's
|
|
907
|
+
// cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
|
|
908
|
+
// not re-executed (loaders still run fresh). Anything else degrades to
|
|
909
|
+
// the full tail automatically.
|
|
910
|
+
if (!entry.handlerLiveHoles && !entry.transitionWhen) {
|
|
733
911
|
seededCtx._shellImplicitCache = {
|
|
734
912
|
ttl: descriptor.ttl,
|
|
735
913
|
swr: descriptor.swr,
|
|
914
|
+
keyPrefix: "doc",
|
|
736
915
|
};
|
|
737
916
|
if (INTERNAL_RANGO_DEBUG) {
|
|
738
917
|
console.log(
|
|
@@ -741,25 +920,21 @@ function serveShellHit(
|
|
|
741
920
|
}
|
|
742
921
|
} else if (INTERNAL_RANGO_DEBUG) {
|
|
743
922
|
console.log(
|
|
744
|
-
`[Server][ppr] shell HIT: fast path declined —
|
|
923
|
+
`[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
|
|
745
924
|
);
|
|
746
925
|
}
|
|
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).
|
|
926
|
+
// Fragment splice (issue #700): store hits in THIS tail emit their stored
|
|
927
|
+
// segment fragments verbatim (expanded by segment-fragments.ts). The flag
|
|
928
|
+
// lives on the derived context so it can never leak into a capture render,
|
|
929
|
+
// which serializes segments and must see real elements.
|
|
753
930
|
seededCtx._shellFragmentPayload = true;
|
|
754
931
|
return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
|
|
755
932
|
}
|
|
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);
|
|
933
|
+
// No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
|
|
934
|
+
// fragment splice on a derived context — never the shared reqCtx, from which
|
|
935
|
+
// scheduleShellCapture derives the capture context (the flag must not be
|
|
936
|
+
// inherited there).
|
|
937
|
+
const fragmentCtx = createTailContext();
|
|
763
938
|
fragmentCtx._shellFragmentPayload = true;
|
|
764
939
|
return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
|
|
765
940
|
})();
|
|
@@ -853,15 +1028,12 @@ function serveShellHit(
|
|
|
853
1028
|
if (tailTiming) publishShellTailTiming(tailTiming);
|
|
854
1029
|
controller.close();
|
|
855
1030
|
} 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.
|
|
1031
|
+
// Self-heal on a failed tail: errors the pre-commit gates cannot catch
|
|
1032
|
+
// (mismatched postponed blob, hard render error above the holes) throw
|
|
1033
|
+
// here AFTER the 200 + prelude flushed and would re-fail on every
|
|
1034
|
+
// request until the entry ages out — recapture overwrites the entry.
|
|
1035
|
+
// Client disconnects land here too; the recapture is idempotent and
|
|
1036
|
+
// bounded by scheduleShellCapture's stampede guard + backoff.
|
|
865
1037
|
scheduleShellCapture(
|
|
866
1038
|
ctx,
|
|
867
1039
|
request,
|