@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.
@@ -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 during pre-render (no request). Runs at request time for loaders. |
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 _routeAncestry or _prerenderDefs should use
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 { _routeAncestry, _prerenderDefs, ...publicManifest } =
308
- await generateManifestFull(urlpatterns, mountIndex);
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,
@@ -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. Each trie leaf embeds the route's ancestry
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 route ancestry (no routes), matching
168
- * the prior guard at both call sites.
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
- const ancestry = manifest._routeAncestry;
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,
@@ -23,7 +23,6 @@ const RESERVED_SEARCH_PARAMS = new Set([
23
23
  "__no_cache",
24
24
  "__rsc",
25
25
  "__html",
26
- "__debug_manifest",
27
26
  "__prerender_collect",
28
27
  ]);
29
28
 
@@ -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). The differences are only the base
10
- * context (a synthetic build request created via createRequestContext over the
11
- * build env no ambient identity, so the identity guard is trivially
12
- * satisfied) and the sink (an entry collector instead of a runtime store).
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: (opts.buildEnv ?? {}) as any,
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
- const match = await router.match(request, { env: opts.buildEnv ?? {} });
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
- mismatchedRouteName = match.routeName;
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
- return captureAndStoreShell(
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
- if (outcome === "stored" && collected !== null) {
246
- const hit: { entry: ShellCacheEntry; tags?: string[] } = collected;
247
- return { outcome, entry: hit.entry, tags: hit.tags };
248
- }
249
- if (outcome === "route-mismatch") {
250
- return { outcome, matchedRouteName: mismatchedRouteName };
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 { outcome };
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;
@@ -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