@rangojs/router 0.0.0-experimental.150 → 0.0.0-experimental.151
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/vite/index.js +146 -76
- package/package.json +2 -2
- package/skills/debug-manifest/SKILL.md +2 -10
- package/skills/middleware/SKILL.md +33 -0
- package/skills/ppr/SKILL.md +18 -3
- package/skills/prerender/SKILL.md +44 -1
- package/src/build/generate-manifest.ts +5 -36
- package/src/build/route-trie.ts +5 -50
- package/src/cache/cache-key-utils.ts +0 -1
- package/src/prerender/build-shell-capture.ts +215 -21
- package/src/router/handler-context.ts +10 -1
- package/src/router/middleware-types.ts +17 -0
- package/src/router/middleware.ts +4 -0
- package/src/router/prerender-match.ts +7 -0
- package/src/router/router-interfaces.ts +0 -6
- package/src/router/router-options.ts +0 -8
- package/src/router.ts +0 -4
- package/src/rsc/handler.ts +55 -51
- package/src/rsc/helpers.ts +461 -0
- package/src/rsc/progressive-enhancement.ts +66 -27
- package/src/rsc/rsc-rendering.ts +91 -57
- package/src/rsc/server-action.ts +49 -46
- package/src/rsc/shell-build-manifest.ts +50 -8
- package/src/rsc/shell-capture.ts +17 -6
- package/src/server/context.ts +34 -10
- package/src/server/request-context.ts +31 -0
- package/src/testing/internal/context.ts +9 -0
- package/src/testing/render-handler.ts +14 -0
- package/src/testing/run-middleware.ts +14 -0
- package/src/types/handler-context.ts +12 -1
- package/src/vite/discovery/discover-routers.ts +44 -52
- package/src/vite/discovery/virtual-module-codegen.ts +112 -2
- package/src/vite/rango.ts +31 -0
- package/src/vite/router-discovery.ts +131 -22
|
@@ -138,6 +138,7 @@ interface BuildContext<TParams> {
|
|
|
138
138
|
search?: Record<string, unknown>,
|
|
139
139
|
): string; // URL generation
|
|
140
140
|
passthrough(): PrerenderPassthroughResult; // Skip local artifact (Passthrough routes only)
|
|
141
|
+
dynamic(): void; // No-op in Prerender/Static handlers; use middleware for PPR shell opt-out
|
|
141
142
|
env: DefaultEnv; // Available when buildEnv is configured in rango() (throws otherwise)
|
|
142
143
|
// NOT available: request, headers, cookies (always throw)
|
|
143
144
|
}
|
|
@@ -257,7 +258,7 @@ path("/blog/:slug", BlogPost, { name: "blog.post" }, () => [
|
|
|
257
258
|
| `cache()` | Orthogonal -- use on parent layouts and loaders. |
|
|
258
259
|
| `layout()` | Child layouts inside path are pre-rendered. Parent layouts are live. |
|
|
259
260
|
| `parallel()` | Parallel slots inside path are pre-rendered. |
|
|
260
|
-
| `middleware()` | Skipped
|
|
261
|
+
| `middleware()` | Skipped while collecting build-time Flight payloads (no request). For `Prerender` + `ppr`, producer B replays global and route middleware during build-shell capture with `ctx.build === true`; `ctx.dynamic()` skips that shell. Runs at request time for loaders. |
|
|
261
262
|
| `loading()` | Ignored without Passthrough. Works for live fallback with Passthrough. |
|
|
262
263
|
| `intercept()` | Pre-rendered at build time. Intercept variant stored under `/i` key alongside main segments. At runtime, the correct variant is served based on `ctx.isIntercept`. `when` config conditions are skipped at build time (all intercepts are pre-rendered unconditionally). |
|
|
263
264
|
|
|
@@ -265,6 +266,48 @@ When Passthrough revalidation is enabled, remember that revalidation is
|
|
|
265
266
|
still partial: opting a child segment into revalidation does not
|
|
266
267
|
implicitly re-run outer prerender-derived handlers/layouts.
|
|
267
268
|
|
|
269
|
+
## Prerender + PPR Build Shells
|
|
270
|
+
|
|
271
|
+
A `Prerender` page may also declare `ppr` on the path option. The build still
|
|
272
|
+
stores the Flight payload first. After that, producer B tries to bake the HTML
|
|
273
|
+
shell for each generated URL so the first document request can be an
|
|
274
|
+
`x-rango-shell: HIT`.
|
|
275
|
+
|
|
276
|
+
That shell capture is request-shaped enough to run middleware safely:
|
|
277
|
+
|
|
278
|
+
- global and route middleware run before shell capture;
|
|
279
|
+
- middleware sees `ctx.build === true`;
|
|
280
|
+
- `ctx.waitUntil()` is inert during build;
|
|
281
|
+
- `ctx.dynamic()` skips the baked shell for that URL.
|
|
282
|
+
|
|
283
|
+
Use `ctx.build` inside middleware to avoid runtime-only side effects during
|
|
284
|
+
build shell capture, or call `ctx.dynamic()` to leave that route to runtime
|
|
285
|
+
PPR. Runtime requests still run the normal middleware chain.
|
|
286
|
+
|
|
287
|
+
### Freshness of a build shell
|
|
288
|
+
|
|
289
|
+
A build-baked shell is not on a wall clock the way a pure runtime `ppr` shell is.
|
|
290
|
+
It serves from the first request after a deploy and keeps serving until one of:
|
|
291
|
+
|
|
292
|
+
- **a redeploy** — the `buildVersion` gate retires every build entry (like a
|
|
293
|
+
React-version bump); the new build re-bakes;
|
|
294
|
+
- **`updateTag`** on a tag the shell carries — drops it, so the next request
|
|
295
|
+
MISSes and a runtime capture takes over.
|
|
296
|
+
|
|
297
|
+
`ppr.ttl` / `swr` are staleness-only here, NOT an expiry: past `ttl` the baked
|
|
298
|
+
entry STILL serves and a runtime recapture is scheduled that upgrades it in place
|
|
299
|
+
(SWR is the upgrade path from build entry → fresher runtime entry). Because the
|
|
300
|
+
`Prerender` handler is evicted from the production bundle, that recapture never
|
|
301
|
+
re-runs the handler — it replays the same build-time segments and only refreshes
|
|
302
|
+
`cache()`-scoped data baked into the shell. **If nothing in the shell is
|
|
303
|
+
`cache()`-backed, `ttl` has nothing to refresh** — reach for `updateTag` (or a
|
|
304
|
+
redeploy) instead of a shorter `ttl`.
|
|
305
|
+
|
|
306
|
+
`ctx.dynamic()` opts a request off the shell axis ONLY. A `Prerender` route has
|
|
307
|
+
no live handler to fall back to (it was evicted), so a `dynamic()` request still
|
|
308
|
+
serves the build-baked B-segments — fresh loaders in their holes, not a fresh
|
|
309
|
+
handler render. There is no "fully dynamic" render for a prerendered route.
|
|
310
|
+
|
|
268
311
|
## Dev Mode
|
|
269
312
|
|
|
270
313
|
In dev mode there is no production-style prerender build pass and no handler
|
|
@@ -107,7 +107,6 @@ async function buildPrefixTreeNode(
|
|
|
107
107
|
namePrefix: string | undefined,
|
|
108
108
|
patternsOrProvider: UrlPatterns<any> | IncludeProvider<any>,
|
|
109
109
|
routeManifest: Record<string, string>,
|
|
110
|
-
routeAncestry: Record<string, string[]>, // internal: feeds trie building, not exported
|
|
111
110
|
mountIndex: number,
|
|
112
111
|
visited: Set<unknown> = new Set(),
|
|
113
112
|
routeTrailingSlash?: Record<string, string>,
|
|
@@ -189,9 +188,6 @@ async function buildPrefixTreeNode(
|
|
|
189
188
|
}
|
|
190
189
|
}
|
|
191
190
|
|
|
192
|
-
// Capture ancestry from manifest entries' parent chains
|
|
193
|
-
captureAncestry(manifest, routeAncestry);
|
|
194
|
-
|
|
195
191
|
// Collect prerender route names and handler definitions from manifest entries
|
|
196
192
|
if (prerenderRoutes) {
|
|
197
193
|
for (const [name, entry] of manifest) {
|
|
@@ -223,7 +219,6 @@ async function buildPrefixTreeNode(
|
|
|
223
219
|
include.namePrefix,
|
|
224
220
|
include.patterns as UrlPatterns<any> | IncludeProvider<any>,
|
|
225
221
|
routeManifest,
|
|
226
|
-
routeAncestry,
|
|
227
222
|
mountIndex,
|
|
228
223
|
visited,
|
|
229
224
|
routeTrailingSlash,
|
|
@@ -250,32 +245,11 @@ async function buildPrefixTreeNode(
|
|
|
250
245
|
}
|
|
251
246
|
}
|
|
252
247
|
|
|
253
|
-
/**
|
|
254
|
-
* Walk parent chains of route entries to extract ancestry shortCodes.
|
|
255
|
-
*/
|
|
256
|
-
function captureAncestry(
|
|
257
|
-
manifest: Map<string, EntryData>,
|
|
258
|
-
routeAncestry: Record<string, string[]>,
|
|
259
|
-
): void {
|
|
260
|
-
for (const [routeName, entry] of manifest) {
|
|
261
|
-
if (entry.type === "route") {
|
|
262
|
-
const ancestry: string[] = [];
|
|
263
|
-
let current: EntryData | null = entry;
|
|
264
|
-
while (current) {
|
|
265
|
-
ancestry.unshift(current.shortCode);
|
|
266
|
-
current = current.parent;
|
|
267
|
-
}
|
|
268
|
-
routeAncestry[routeName] = ancestry;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
248
|
/**
|
|
274
249
|
* Internal manifest result including build-pipeline-only fields.
|
|
275
250
|
* Not part of the public API — use generateManifest() for the public surface.
|
|
276
251
|
*/
|
|
277
252
|
export interface FullManifest extends GeneratedManifest {
|
|
278
|
-
_routeAncestry: Record<string, string[]>;
|
|
279
253
|
_prerenderDefs?: Record<string, any>;
|
|
280
254
|
}
|
|
281
255
|
|
|
@@ -283,8 +257,7 @@ export interface FullManifest extends GeneratedManifest {
|
|
|
283
257
|
* Generate manifest from UrlPatterns (public API).
|
|
284
258
|
*
|
|
285
259
|
* Returns only the public GeneratedManifest fields. Internal build pipeline
|
|
286
|
-
* consumers that need
|
|
287
|
-
* generateManifestFull() instead.
|
|
260
|
+
* consumers that need _prerenderDefs should use generateManifestFull() instead.
|
|
288
261
|
*
|
|
289
262
|
* @example
|
|
290
263
|
* ```typescript
|
|
@@ -304,8 +277,10 @@ export async function generateManifest<TEnv>(
|
|
|
304
277
|
urlpatterns: UrlPatterns<TEnv, any>,
|
|
305
278
|
mountIndex: number = 0,
|
|
306
279
|
): Promise<GeneratedManifest> {
|
|
307
|
-
const {
|
|
308
|
-
|
|
280
|
+
const { _prerenderDefs, ...publicManifest } = await generateManifestFull(
|
|
281
|
+
urlpatterns,
|
|
282
|
+
mountIndex,
|
|
283
|
+
);
|
|
309
284
|
return publicManifest;
|
|
310
285
|
}
|
|
311
286
|
|
|
@@ -332,7 +307,6 @@ export async function generateManifestFull<TEnv>(
|
|
|
332
307
|
},
|
|
333
308
|
): Promise<FullManifest> {
|
|
334
309
|
const routeManifest: Record<string, string> = {};
|
|
335
|
-
const routeAncestry: Record<string, string[]> = {};
|
|
336
310
|
const prefixTree: Record<string, PrefixTreeNode> = {};
|
|
337
311
|
|
|
338
312
|
// Run the root patterns handler with tracking enabled
|
|
@@ -392,9 +366,6 @@ export async function generateManifestFull<TEnv>(
|
|
|
392
366
|
Record<string, string>
|
|
393
367
|
> = Object.fromEntries(searchSchemasMap);
|
|
394
368
|
|
|
395
|
-
// Capture ancestry from manifest entries' parent chains
|
|
396
|
-
captureAncestry(manifest, routeAncestry);
|
|
397
|
-
|
|
398
369
|
// Collect prerender route names and handler definitions across all levels
|
|
399
370
|
const prerenderRoutes: string[] = [];
|
|
400
371
|
const prerenderDefs: Record<string, any> = {};
|
|
@@ -423,7 +394,6 @@ export async function generateManifestFull<TEnv>(
|
|
|
423
394
|
include.namePrefix,
|
|
424
395
|
include.patterns as UrlPatterns<any> | IncludeProvider<any>,
|
|
425
396
|
routeManifest,
|
|
426
|
-
routeAncestry,
|
|
427
397
|
mountIndex,
|
|
428
398
|
visited,
|
|
429
399
|
routeTrailingSlash,
|
|
@@ -453,7 +423,6 @@ export async function generateManifestFull<TEnv>(
|
|
|
453
423
|
Object.keys(routeSearchSchemas).length > 0
|
|
454
424
|
? routeSearchSchemas
|
|
455
425
|
: undefined,
|
|
456
|
-
_routeAncestry: routeAncestry,
|
|
457
426
|
// Internal: prerender handler definitions for build-time getParams() access
|
|
458
427
|
_prerenderDefs:
|
|
459
428
|
Object.keys(prerenderDefs).length > 0 ? prerenderDefs : undefined,
|
package/src/build/route-trie.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Build-time Route Trie Construction
|
|
3
3
|
*
|
|
4
4
|
* Builds a serializable trie from the route manifest for O(path_length)
|
|
5
|
-
* route matching at runtime.
|
|
6
|
-
* shortCodes for layout pruning.
|
|
5
|
+
* route matching at runtime.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import {
|
|
@@ -33,8 +32,6 @@ export interface TrieLeaf {
|
|
|
33
32
|
n: string;
|
|
34
33
|
/** Static prefix of the entry (e.g., "/site") */
|
|
35
34
|
sp: string;
|
|
36
|
-
/** Ancestry shortCodes from root to route [M0L0, M0L0L0, M0L0L0R499] */
|
|
37
|
-
a: string[];
|
|
38
35
|
/** Constraint validation: paramName -> allowed values */
|
|
39
36
|
cv?: Record<string, string[]>;
|
|
40
37
|
/** Ordered param names for this route (positional) */
|
|
@@ -75,7 +72,6 @@ export interface TrieNode {
|
|
|
75
72
|
* Build a route trie from build-time manifest data.
|
|
76
73
|
*
|
|
77
74
|
* @param routeManifest - Map of route name to full URL pattern
|
|
78
|
-
* @param routeAncestry - Map of route name to ancestry shortCodes
|
|
79
75
|
* @param routeToStaticPrefix - Map of route name to its entry's staticPrefix
|
|
80
76
|
* @param routeTrailingSlash - Optional map of route name to trailing slash mode
|
|
81
77
|
* @param prerenderRouteNames - Optional set of prerendered route names (sets leaf.pr)
|
|
@@ -84,7 +80,6 @@ export interface TrieNode {
|
|
|
84
80
|
*/
|
|
85
81
|
export function buildRouteTrie(
|
|
86
82
|
routeManifest: Record<string, string>,
|
|
87
|
-
routeAncestry: Record<string, string[]>,
|
|
88
83
|
routeToStaticPrefix: Record<string, string>,
|
|
89
84
|
routeTrailingSlash?: Record<string, string>,
|
|
90
85
|
prerenderRouteNames?: Set<string>,
|
|
@@ -94,7 +89,6 @@ export function buildRouteTrie(
|
|
|
94
89
|
const root: TrieNode = {};
|
|
95
90
|
|
|
96
91
|
for (const [routeName, pattern] of Object.entries(routeManifest)) {
|
|
97
|
-
const ancestry = routeAncestry[routeName] || [];
|
|
98
92
|
const staticPrefix = routeToStaticPrefix[routeName] || "";
|
|
99
93
|
const trailingSlash = routeTrailingSlash?.[routeName];
|
|
100
94
|
const responseType = responseTypeRoutes?.[routeName];
|
|
@@ -107,7 +101,6 @@ export function buildRouteTrie(
|
|
|
107
101
|
insertRoute(root, segments, 0, {
|
|
108
102
|
n: routeName,
|
|
109
103
|
sp: staticPrefix,
|
|
110
|
-
a: ancestry,
|
|
111
104
|
...(trailingSlash ? { ts: trailingSlash } : {}),
|
|
112
105
|
...(prerenderRouteNames?.has(routeName) ? { pr: true } : {}),
|
|
113
106
|
...(passthroughRouteNames?.has(routeName) ? { pt: true } : {}),
|
|
@@ -161,15 +154,13 @@ function sortSuffixParams(node: TrieNode): void {
|
|
|
161
154
|
* construction path shared by build/discovery (discover-routers.ts, serialized
|
|
162
155
|
* into the production chunk) and the dev/HMR runtime rebuild
|
|
163
156
|
* (rsc/manifest-init.ts). Keeping one code path is what guarantees the dev
|
|
164
|
-
* runtime trie and the production serialized trie are byte-for-byte identical
|
|
165
|
-
* (modulo `leaf.a` ancestry, which embeds the mount index and is debug-only).
|
|
157
|
+
* runtime trie and the production serialized trie are byte-for-byte identical.
|
|
166
158
|
*
|
|
167
|
-
* Returns null when the manifest has no
|
|
168
|
-
*
|
|
159
|
+
* Returns null when the manifest has no routes, matching the prior guard at
|
|
160
|
+
* both call sites.
|
|
169
161
|
*/
|
|
170
162
|
export function buildPerRouterTrie(manifest: FullManifest): TrieNode | null {
|
|
171
|
-
|
|
172
|
-
if (!ancestry || Object.keys(ancestry).length === 0) {
|
|
163
|
+
if (Object.keys(manifest.routeManifest).length === 0) {
|
|
173
164
|
return null;
|
|
174
165
|
}
|
|
175
166
|
|
|
@@ -186,7 +177,6 @@ export function buildPerRouterTrie(manifest: FullManifest): TrieNode | null {
|
|
|
186
177
|
|
|
187
178
|
return buildRouteTrie(
|
|
188
179
|
manifest.routeManifest,
|
|
189
|
-
ancestry,
|
|
190
180
|
routeToStaticPrefix,
|
|
191
181
|
manifest.routeTrailingSlash,
|
|
192
182
|
manifest.prerenderRoutes ? new Set(manifest.prerenderRoutes) : undefined,
|
|
@@ -233,41 +223,6 @@ function insertRoute(
|
|
|
233
223
|
insertSegments(node, segments, index, leafBase, []);
|
|
234
224
|
}
|
|
235
225
|
|
|
236
|
-
/**
|
|
237
|
-
* Extract ancestry map from a built trie by visiting all leaf nodes.
|
|
238
|
-
* Returns { routeName: ancestryShortCodes[] } for every route in the trie.
|
|
239
|
-
*/
|
|
240
|
-
export function extractAncestryFromTrie(
|
|
241
|
-
root: TrieNode,
|
|
242
|
-
): Record<string, string[]> {
|
|
243
|
-
const result: Record<string, string[]> = {};
|
|
244
|
-
|
|
245
|
-
function visit(node: TrieNode): void {
|
|
246
|
-
if (node.r) {
|
|
247
|
-
result[node.r.n] = node.r.a;
|
|
248
|
-
}
|
|
249
|
-
if (node.w) {
|
|
250
|
-
result[node.w.n] = node.w.a;
|
|
251
|
-
}
|
|
252
|
-
if (node.s) {
|
|
253
|
-
for (const child of Object.values(node.s)) {
|
|
254
|
-
visit(child);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
if (node.xp) {
|
|
258
|
-
for (const child of Object.values(node.xp)) {
|
|
259
|
-
visit(child.c);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
if (node.p) {
|
|
263
|
-
visit(node.p.c);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
visit(root);
|
|
268
|
-
return result;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
226
|
/**
|
|
272
227
|
* Merge a new leaf with an existing leaf, handling content negotiation.
|
|
273
228
|
* When an RSC route and response-type routes share the same URL pattern,
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* chunk preloads — that only exist post-client-build). The capture core is
|
|
7
7
|
* producer A's, verbatim: deriveShellCaptureContext (mask funnel, liveness,
|
|
8
8
|
* snapshot recording, implicit doc-cache scope) + captureAndStoreShell (gates,
|
|
9
|
-
* quiesce, tags union, putShell barrier).
|
|
10
|
-
*
|
|
11
|
-
* build
|
|
12
|
-
*
|
|
9
|
+
* quiesce, tags union, putShell barrier). Build capture first replays global
|
|
10
|
+
* and route middleware with a synthetic build request context
|
|
11
|
+
* (`ctx.build === true`, inert `ctx.waitUntil()`); middleware can seed vars or
|
|
12
|
+
* call `ctx.dynamic()` to skip this URL. The sink is an entry collector instead
|
|
13
|
+
* of a runtime store.
|
|
13
14
|
*
|
|
14
15
|
* The capture's match() re-enters withCacheLookup, HITs the in-realm prerender
|
|
15
16
|
* store seeded from the just-collected Flight payloads, and REPLAYS the
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
createRequestContext,
|
|
26
27
|
runWithRequestContext,
|
|
27
28
|
setRequestContextParams,
|
|
29
|
+
type RequestContext,
|
|
28
30
|
} from "../server/request-context.js";
|
|
29
31
|
import {
|
|
30
32
|
deriveShellCaptureContext,
|
|
@@ -34,9 +36,15 @@ import {
|
|
|
34
36
|
type ShellCaptureDescriptor,
|
|
35
37
|
} from "../rsc/shell-capture.js";
|
|
36
38
|
import { buildFullPayload } from "../rsc/full-payload.js";
|
|
39
|
+
import { buildRouteMiddlewareEntries } from "../rsc/helpers.js";
|
|
37
40
|
import type { RscPayload, SSRModule } from "../rsc/types.js";
|
|
38
41
|
import type { HandlerContext } from "../rsc/handler-context.js";
|
|
39
42
|
import { renderToReadableStream } from "../deps/rsc.js";
|
|
43
|
+
import { isRouteNotFoundError } from "../errors.js";
|
|
44
|
+
import { createReverseFunction } from "../router/handler-context.js";
|
|
45
|
+
import { executeMiddleware, matchMiddleware } from "../router/middleware.js";
|
|
46
|
+
import type { MiddlewareEntry } from "../router/middleware.js";
|
|
47
|
+
import { getGlobalRouteMap } from "../route-map-builder.js";
|
|
40
48
|
import {
|
|
41
49
|
resolvePprConfig,
|
|
42
50
|
type ResolvedPprConfig,
|
|
@@ -116,6 +124,8 @@ export interface BuildShellCaptureResult {
|
|
|
116
124
|
| "no-shell"
|
|
117
125
|
| "redirect"
|
|
118
126
|
| "refused"
|
|
127
|
+
/** Middleware/handler opted this URL out of PPR shell capture. */
|
|
128
|
+
| "dynamic"
|
|
119
129
|
/** The router swept does not own this URL — try the next one. */
|
|
120
130
|
| "route-mismatch";
|
|
121
131
|
/** Present iff outcome === "stored". */
|
|
@@ -152,16 +162,19 @@ async function attemptBuildCapture(
|
|
|
152
162
|
const router = opts.router;
|
|
153
163
|
const url = new URL(opts.urlPath, "http://build.invalid");
|
|
154
164
|
const request = new Request(url, { method: "GET" });
|
|
165
|
+
const env = (opts.buildEnv ?? {}) as any;
|
|
166
|
+
const variables: Record<string, any> = {};
|
|
155
167
|
|
|
156
168
|
// Synthetic build request context: same factory the runtime handler uses,
|
|
157
169
|
// so the capture's ALS surface (cookie machinery, variables, waitUntil,
|
|
158
170
|
// theme resolution) is production-shaped. No cookie header → theme resolves
|
|
159
171
|
// to the app default, exactly like a first anonymous visitor's capture.
|
|
160
172
|
const baseCtx = createRequestContext({
|
|
161
|
-
env
|
|
173
|
+
env,
|
|
162
174
|
request,
|
|
163
175
|
url,
|
|
164
|
-
variables
|
|
176
|
+
variables,
|
|
177
|
+
build: true,
|
|
165
178
|
// Fresh empty store per attempt: cache()/"use cache" reads MISS, execute,
|
|
166
179
|
// and are recorded into the snapshot by the derivation's RecordingShell
|
|
167
180
|
// wrapper — the entry pins its own generation, nothing preexisting leaks.
|
|
@@ -171,11 +184,6 @@ async function attemptBuildCapture(
|
|
|
171
184
|
version: opts.buildVersion,
|
|
172
185
|
});
|
|
173
186
|
|
|
174
|
-
const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(baseCtx, {
|
|
175
|
-
ttl: opts.ttl,
|
|
176
|
-
swr: opts.swr,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
187
|
// Entry collector: captureAndStoreShell's sink. putShell never fails here,
|
|
180
188
|
// so a "stored" outcome always carries the entry.
|
|
181
189
|
let collected: { entry: ShellCacheEntry; tags?: string[] } | null = null;
|
|
@@ -204,10 +212,177 @@ async function attemptBuildCapture(
|
|
|
204
212
|
};
|
|
205
213
|
|
|
206
214
|
let mismatchedRouteName: string | undefined;
|
|
215
|
+
const result = await runWithRequestContext(baseCtx, async () => {
|
|
216
|
+
const preview =
|
|
217
|
+
typeof router.previewMatch === "function"
|
|
218
|
+
? await router.previewMatch(request, { env })
|
|
219
|
+
: undefined;
|
|
220
|
+
// These preview-based mismatches exit before any middleware/envelope runs,
|
|
221
|
+
// so nothing consumes a response — only result.outcome is read below.
|
|
222
|
+
if (preview === null) {
|
|
223
|
+
return { outcome: "route-mismatch" } as const;
|
|
224
|
+
}
|
|
225
|
+
if (preview?.routeKey && preview.routeKey !== opts.routeName) {
|
|
226
|
+
mismatchedRouteName = preview.routeKey;
|
|
227
|
+
return { outcome: "route-mismatch" } as const;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (preview?.routeKey) {
|
|
231
|
+
setRequestContextParams(preview.params ?? {}, preview.routeKey);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const routeReverse = createReverseFunction(
|
|
235
|
+
getGlobalRouteMap(),
|
|
236
|
+
preview?.routeKey,
|
|
237
|
+
preview?.params ?? {},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
const runCapture = () =>
|
|
241
|
+
runBuildCaptureFinal({
|
|
242
|
+
baseCtx,
|
|
243
|
+
descriptor,
|
|
244
|
+
env,
|
|
245
|
+
opts,
|
|
246
|
+
request,
|
|
247
|
+
router,
|
|
248
|
+
url,
|
|
249
|
+
setMismatchedRouteName: (routeName) => {
|
|
250
|
+
mismatchedRouteName = routeName;
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const routeMiddleware =
|
|
255
|
+
preview?.routeMiddleware && preview.routeMiddleware.length > 0
|
|
256
|
+
? buildRouteMiddlewareEntries(preview.routeMiddleware)
|
|
257
|
+
: [];
|
|
258
|
+
const runRouteMiddleware = () =>
|
|
259
|
+
runBuildMiddlewareEnvelope(
|
|
260
|
+
routeMiddleware,
|
|
261
|
+
request,
|
|
262
|
+
env,
|
|
263
|
+
variables,
|
|
264
|
+
runCapture,
|
|
265
|
+
routeReverse,
|
|
266
|
+
baseCtx,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const globalMiddleware = Array.isArray(router.middleware)
|
|
270
|
+
? matchMiddleware(url.pathname, router.middleware)
|
|
271
|
+
: [];
|
|
272
|
+
return runBuildMiddlewareEnvelope(
|
|
273
|
+
globalMiddleware,
|
|
274
|
+
request,
|
|
275
|
+
env,
|
|
276
|
+
variables,
|
|
277
|
+
runRouteMiddleware,
|
|
278
|
+
routeReverse,
|
|
279
|
+
baseCtx,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const outcome = result.outcome;
|
|
284
|
+
if (outcome === "stored" && collected !== null) {
|
|
285
|
+
const hit: { entry: ShellCacheEntry; tags?: string[] } = collected;
|
|
286
|
+
return { outcome, entry: hit.entry, tags: hit.tags };
|
|
287
|
+
}
|
|
288
|
+
if (outcome === "route-mismatch") {
|
|
289
|
+
return { outcome, matchedRouteName: mismatchedRouteName };
|
|
290
|
+
}
|
|
291
|
+
return { outcome };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type BuildShellCaptureOutcome = BuildShellCaptureResult["outcome"];
|
|
295
|
+
|
|
296
|
+
interface BuildCaptureRunResult {
|
|
297
|
+
outcome: BuildShellCaptureOutcome;
|
|
298
|
+
response: Response;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface BuildCaptureFinalOptions {
|
|
302
|
+
baseCtx: RequestContext<any>;
|
|
303
|
+
descriptor: ShellCaptureDescriptor;
|
|
304
|
+
env: any;
|
|
305
|
+
opts: BuildShellCaptureOptions;
|
|
306
|
+
request: Request;
|
|
307
|
+
router: any;
|
|
308
|
+
url: URL;
|
|
309
|
+
setMismatchedRouteName(routeName: string | undefined): void;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function runBuildMiddlewareEnvelope<TEnv>(
|
|
313
|
+
middlewares: Array<{
|
|
314
|
+
entry: MiddlewareEntry<TEnv>;
|
|
315
|
+
params: Record<string, string>;
|
|
316
|
+
}>,
|
|
317
|
+
request: Request,
|
|
318
|
+
env: TEnv,
|
|
319
|
+
variables: Record<string, any>,
|
|
320
|
+
finalHandler: () => Promise<BuildCaptureRunResult>,
|
|
321
|
+
reverse: (
|
|
322
|
+
name: string,
|
|
323
|
+
params?: Record<string, string>,
|
|
324
|
+
search?: Record<string, unknown>,
|
|
325
|
+
) => string,
|
|
326
|
+
baseCtx: RequestContext<any>,
|
|
327
|
+
): Promise<BuildCaptureRunResult> {
|
|
328
|
+
let downstream: BuildCaptureRunResult | undefined;
|
|
329
|
+
const response = await executeMiddleware(
|
|
330
|
+
middlewares,
|
|
331
|
+
request,
|
|
332
|
+
env,
|
|
333
|
+
variables,
|
|
334
|
+
async () => {
|
|
335
|
+
downstream = baseCtx._dynamic
|
|
336
|
+
? {
|
|
337
|
+
outcome: "dynamic",
|
|
338
|
+
response: responseForBuildCaptureOutcome("dynamic"),
|
|
339
|
+
}
|
|
340
|
+
: await finalHandler();
|
|
341
|
+
return downstream.response;
|
|
342
|
+
},
|
|
343
|
+
reverse,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (baseCtx._dynamic) {
|
|
347
|
+
return { outcome: "dynamic", response };
|
|
348
|
+
}
|
|
349
|
+
if (response.status >= 300 && response.status < 400) {
|
|
350
|
+
return { outcome: "redirect", response };
|
|
351
|
+
}
|
|
352
|
+
return (
|
|
353
|
+
downstream ?? {
|
|
354
|
+
outcome: "no-shell",
|
|
355
|
+
response,
|
|
356
|
+
}
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function runBuildCaptureFinal(
|
|
361
|
+
options: BuildCaptureFinalOptions,
|
|
362
|
+
): Promise<BuildCaptureRunResult> {
|
|
363
|
+
const { baseCtx, descriptor, env, opts, request, router, url } = options;
|
|
364
|
+
// No baseCtx._dynamic recheck here: the only caller is the route envelope's
|
|
365
|
+
// finalHandler wrapper, which already short-circuits to "dynamic" without
|
|
366
|
+
// invoking this when baseCtx._dynamic is set. A loader/handler opting out
|
|
367
|
+
// DURING the capture render is caught by the derivedCtx._dynamic check below.
|
|
368
|
+
|
|
369
|
+
const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(baseCtx, {
|
|
370
|
+
ttl: opts.ttl,
|
|
371
|
+
swr: opts.swr,
|
|
372
|
+
});
|
|
373
|
+
|
|
207
374
|
const outcome = await runWithRequestContext(derivedCtx, async () => {
|
|
208
|
-
|
|
375
|
+
let match;
|
|
376
|
+
try {
|
|
377
|
+
match = await router.match(request, { env });
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (isPlainPathMiss(error, opts.urlPath)) {
|
|
380
|
+
return "route-mismatch" as const;
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
209
384
|
if (match.routeName !== opts.routeName) {
|
|
210
|
-
|
|
385
|
+
options.setMismatchedRouteName(match.routeName);
|
|
211
386
|
return "route-mismatch" as const;
|
|
212
387
|
}
|
|
213
388
|
if (match.redirect) return "redirect" as const;
|
|
@@ -233,21 +408,40 @@ async function attemptBuildCapture(
|
|
|
233
408
|
},
|
|
234
409
|
});
|
|
235
410
|
|
|
236
|
-
|
|
411
|
+
const captureOutcome = await captureAndStoreShell(
|
|
237
412
|
{ captureShellHTML: opts.captureShellHTML } as SSRModule,
|
|
238
413
|
rscStream,
|
|
239
414
|
freshHandleStore,
|
|
240
415
|
derivedCtx,
|
|
241
416
|
descriptor,
|
|
242
417
|
);
|
|
418
|
+
return derivedCtx._dynamic ? "dynamic" : captureOutcome;
|
|
243
419
|
});
|
|
244
420
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
421
|
+
return {
|
|
422
|
+
outcome,
|
|
423
|
+
response: responseForBuildCaptureOutcome(outcome),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function responseForBuildCaptureOutcome(
|
|
428
|
+
outcome: BuildShellCaptureOutcome,
|
|
429
|
+
): Response {
|
|
430
|
+
if (outcome === "redirect") {
|
|
431
|
+
return new Response(null, {
|
|
432
|
+
status: 302,
|
|
433
|
+
headers: { location: "http://build.invalid/" },
|
|
434
|
+
});
|
|
251
435
|
}
|
|
252
|
-
return {
|
|
436
|
+
return new Response(null, { status: 204 });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isPlainPathMiss(error: unknown, pathname: string): boolean {
|
|
440
|
+
if (!isRouteNotFoundError(error)) return false;
|
|
441
|
+
const cause = (error as { cause?: unknown }).cause;
|
|
442
|
+
return (
|
|
443
|
+
cause !== null &&
|
|
444
|
+
typeof cause === "object" &&
|
|
445
|
+
(cause as { pathname?: unknown }).pathname === pathname
|
|
446
|
+
);
|
|
253
447
|
}
|
|
@@ -243,7 +243,10 @@ export function createHandlerContext<TEnv>(
|
|
|
243
243
|
|
|
244
244
|
ctx = {
|
|
245
245
|
params,
|
|
246
|
-
build: false,
|
|
246
|
+
build: requestContext?.build ?? false,
|
|
247
|
+
dynamic(): void {
|
|
248
|
+
requestContext?.dynamic();
|
|
249
|
+
},
|
|
247
250
|
dev: false,
|
|
248
251
|
request,
|
|
249
252
|
searchParams,
|
|
@@ -344,6 +347,9 @@ export function createPrerenderContext<TEnv>(
|
|
|
344
347
|
return {
|
|
345
348
|
params,
|
|
346
349
|
build: true,
|
|
350
|
+
// Inert here: prerender/static contexts have no live PPR-shell decision to
|
|
351
|
+
// gate. dynamic() only reaches the shell axis on a live request or capture.
|
|
352
|
+
dynamic: () => {},
|
|
347
353
|
dev: devMode ?? false,
|
|
348
354
|
get request(): Request {
|
|
349
355
|
return throwUnavailable("request");
|
|
@@ -429,6 +435,9 @@ export function createStaticContext<TEnv>(
|
|
|
429
435
|
return throwUnavailable("params");
|
|
430
436
|
},
|
|
431
437
|
build: true,
|
|
438
|
+
// Inert here: prerender/static contexts have no live PPR-shell decision to
|
|
439
|
+
// gate. dynamic() only reaches the shell axis on a live request or capture.
|
|
440
|
+
dynamic: () => {},
|
|
432
441
|
dev: devMode ?? false,
|
|
433
442
|
get request(): Request {
|
|
434
443
|
return throwUnavailable("request");
|
|
@@ -39,6 +39,23 @@ export interface MiddlewareContext<
|
|
|
39
39
|
> extends RequestScope<TEnv> {
|
|
40
40
|
params: TParams;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* True for build-time render/capture requests. Live requests use false.
|
|
44
|
+
* Build-time PPR shell capture sets this while replaying middleware.
|
|
45
|
+
*/
|
|
46
|
+
readonly build: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Opt this request out of PPR shell serving/capture.
|
|
50
|
+
* During middleware this runs before the shell HIT commit point, so it can
|
|
51
|
+
* force the request onto the dynamic axis.
|
|
52
|
+
*
|
|
53
|
+
* Scope: the PPR SHELL axis only. It does NOT disable prerender B-segment
|
|
54
|
+
* (Prerender/Static) serving — a Prerender() route still replays its
|
|
55
|
+
* build-baked segments at runtime.
|
|
56
|
+
*/
|
|
57
|
+
dynamic(): void;
|
|
58
|
+
|
|
42
59
|
readonly headers: Headers;
|
|
43
60
|
|
|
44
61
|
get: GetVariableFn;
|
package/src/router/middleware.ts
CHANGED
|
@@ -238,6 +238,10 @@ export function createMiddlewareContext<TEnv>(
|
|
|
238
238
|
searchParams: url.searchParams,
|
|
239
239
|
env: env as MiddlewareContext<TEnv>["env"],
|
|
240
240
|
params,
|
|
241
|
+
build: reqCtx?.build ?? false,
|
|
242
|
+
dynamic(): void {
|
|
243
|
+
reqCtx?.dynamic();
|
|
244
|
+
},
|
|
241
245
|
executionContext: reqCtx?.executionContext,
|
|
242
246
|
waitUntil: reqCtx ? reqCtx.waitUntil.bind(reqCtx) : fireAndForgetWaitUntil,
|
|
243
247
|
// Getter: re-derives from request context on each access so that global
|