@timber-js/app 0.2.0-alpha.97 → 0.2.0-alpha.98

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.
Files changed (102) hide show
  1. package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
  2. package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  3. package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
  4. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
  5. package/dist/_chunks/{interception-BbqMCVXa.js → walkers-VOXgavMF.js} +61 -85
  6. package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
  7. package/dist/adapters/nitro.d.ts.map +1 -1
  8. package/dist/adapters/nitro.js +55 -5
  9. package/dist/adapters/nitro.js.map +1 -1
  10. package/dist/client/index.js +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +189 -62
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugins/build-report.d.ts +6 -4
  15. package/dist/plugins/build-report.d.ts.map +1 -1
  16. package/dist/plugins/dev-404-page.d.ts +8 -18
  17. package/dist/plugins/dev-404-page.d.ts.map +1 -1
  18. package/dist/routing/index.d.ts +5 -3
  19. package/dist/routing/index.d.ts.map +1 -1
  20. package/dist/routing/index.js +3 -3
  21. package/dist/routing/scanner.d.ts +1 -10
  22. package/dist/routing/scanner.d.ts.map +1 -1
  23. package/dist/routing/segment-classify.d.ts +37 -8
  24. package/dist/routing/segment-classify.d.ts.map +1 -1
  25. package/dist/routing/types.d.ts +63 -23
  26. package/dist/routing/types.d.ts.map +1 -1
  27. package/dist/routing/walkers.d.ts +51 -0
  28. package/dist/routing/walkers.d.ts.map +1 -0
  29. package/dist/server/action-handler.d.ts.map +1 -1
  30. package/dist/server/dev-holding-server.d.ts +4 -2
  31. package/dist/server/dev-holding-server.d.ts.map +1 -1
  32. package/dist/server/html-injector-core.d.ts +212 -0
  33. package/dist/server/html-injector-core.d.ts.map +1 -0
  34. package/dist/server/html-injectors.d.ts +59 -59
  35. package/dist/server/html-injectors.d.ts.map +1 -1
  36. package/dist/server/internal.js +710 -563
  37. package/dist/server/internal.js.map +1 -1
  38. package/dist/server/node-stream-transforms.d.ts +46 -49
  39. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  40. package/dist/server/pipeline-helpers.d.ts +88 -0
  41. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  42. package/dist/server/pipeline-phases.d.ts +97 -0
  43. package/dist/server/pipeline-phases.d.ts.map +1 -0
  44. package/dist/server/pipeline.d.ts +53 -32
  45. package/dist/server/pipeline.d.ts.map +1 -1
  46. package/dist/server/port-resolution.d.ts +117 -0
  47. package/dist/server/port-resolution.d.ts.map +1 -0
  48. package/dist/server/route-matcher.d.ts +20 -47
  49. package/dist/server/route-matcher.d.ts.map +1 -1
  50. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  53. package/dist/server/status-code-resolver.d.ts +16 -11
  54. package/dist/server/status-code-resolver.d.ts.map +1 -1
  55. package/dist/server/tree-builder.d.ts.map +1 -1
  56. package/dist/utils/directive-parser.d.ts +0 -45
  57. package/dist/utils/directive-parser.d.ts.map +1 -1
  58. package/package.json +7 -6
  59. package/src/adapters/nitro.ts +55 -5
  60. package/src/cli.ts +0 -0
  61. package/src/index.ts +84 -31
  62. package/src/plugins/build-report.ts +13 -22
  63. package/src/plugins/dev-404-page.ts +15 -41
  64. package/src/plugins/routing.ts +14 -12
  65. package/src/routing/codegen.ts +1 -1
  66. package/src/routing/convention-lint.ts +4 -4
  67. package/src/routing/index.ts +5 -3
  68. package/src/routing/interception.ts +1 -1
  69. package/src/routing/scanner.ts +17 -93
  70. package/src/routing/segment-classify.ts +107 -8
  71. package/src/routing/status-file-lint.ts +3 -3
  72. package/src/routing/types.ts +63 -23
  73. package/src/routing/walkers.ts +90 -0
  74. package/src/server/action-handler.ts +6 -0
  75. package/src/server/deny-renderer.ts +5 -5
  76. package/src/server/dev-holding-server.ts +4 -2
  77. package/src/server/fallback-error.ts +1 -1
  78. package/src/server/html-injector-core.ts +403 -0
  79. package/src/server/html-injectors.ts +158 -297
  80. package/src/server/node-stream-transforms.ts +108 -248
  81. package/src/server/pipeline-helpers.ts +180 -0
  82. package/src/server/pipeline-phases.ts +591 -0
  83. package/src/server/pipeline.ts +76 -539
  84. package/src/server/port-resolution.ts +215 -0
  85. package/src/server/route-element-builder.ts +1 -1
  86. package/src/server/route-matcher.ts +28 -60
  87. package/src/server/rsc-entry/api-handler.ts +2 -2
  88. package/src/server/rsc-entry/error-renderer.ts +1 -1
  89. package/src/server/rsc-entry/index.ts +52 -98
  90. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  91. package/src/server/sitemap-generator.ts +1 -1
  92. package/src/server/slot-resolver.ts +1 -1
  93. package/src/server/status-code-resolver.ts +112 -128
  94. package/src/server/tree-builder.ts +6 -4
  95. package/src/utils/directive-parser.ts +0 -392
  96. package/LICENSE +0 -8
  97. package/dist/_chunks/interception-BbqMCVXa.js.map +0 -1
  98. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  99. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  100. package/dist/server/manifest-status-resolver.d.ts +0 -58
  101. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  102. package/src/server/manifest-status-resolver.ts +0 -215
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Pipeline phase functions — module-level free functions that take their
3
+ * dependencies as explicit parameters. Each phase returns a `PhaseOutcome`
4
+ * (a discriminated union over response / redirect / deny / error). The
5
+ * terminal `outcomeToResponse` translates outcomes into Responses.
6
+ *
7
+ * Lifted out of `createPipeline` so each phase can be unit-tested in
8
+ * isolation. The lift is mechanical — these functions used to be closures
9
+ * over `config`; they now take `config` as an explicit parameter.
10
+ *
11
+ * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
12
+ */
13
+
14
+ import { canonicalize } from './canonicalize.js';
15
+ import { runProxy } from './proxy.js';
16
+ import { runMiddlewareChain } from './middleware-runner.js';
17
+ import { withTiming } from './server-timing.js';
18
+ import {
19
+ applyRequestHeaderOverlay,
20
+ setMutableCookieContext,
21
+ markResponseFlushed,
22
+ setSegmentParams,
23
+ } from './request-context.js';
24
+ import { withSpan } from './tracing.js';
25
+ import {
26
+ logProxyError,
27
+ logMiddlewareError,
28
+ logMiddlewareShortCircuit,
29
+ logRenderError,
30
+ } from './logger.js';
31
+ import { RedirectSignal, DenySignal } from './primitives.js';
32
+ import { ParamCoercionError } from './route-element-builder.js';
33
+ import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
34
+ import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
35
+ import { loadModule } from './safe-load.js';
36
+ import { findInterceptionMatch } from './pipeline-interception.js';
37
+ import {
38
+ applyCookieJar,
39
+ buildRedirectResponse,
40
+ cloneWithMutableHeaders,
41
+ fireOnRequestError,
42
+ mergeMissingHeaders,
43
+ safeMerge,
44
+ type ProxyResolver,
45
+ } from './pipeline-helpers.js';
46
+ import type { InterceptionContext, PipelineConfig, RouteMatch } from './pipeline.js';
47
+ import type { MiddlewareContext } from './types.js';
48
+
49
+ // ─── Phase Outcome ─────────────────────────────────────────────────────────
50
+
51
+ export type PhaseName = 'proxy' | 'middleware' | 'render';
52
+
53
+ export type PhaseOutcome =
54
+ | { kind: 'response'; phase: PhaseName; response: Response }
55
+ | { kind: 'redirect'; phase: PhaseName; signal: RedirectSignal }
56
+ | { kind: 'deny'; phase: PhaseName; signal: DenySignal }
57
+ | { kind: 'error'; phase: PhaseName; error: unknown };
58
+
59
+ export interface OutcomeContext {
60
+ req: Request;
61
+ method: string;
62
+ path: string;
63
+ responseHeaders?: Headers;
64
+ match?: RouteMatch;
65
+ }
66
+
67
+ interface RenderContext {
68
+ canonicalPathname: string;
69
+ interception?: InterceptionContext;
70
+ }
71
+
72
+ // ─── Param Coercion ────────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Run segment param coercion on the matched route's segments.
76
+ *
77
+ * Loads params.ts modules from segments that have them, extracts the
78
+ * segmentParams definition, and coerces raw string params through codecs.
79
+ * Throws ParamCoercionError if any codec fails (→ 404).
80
+ *
81
+ * This runs BEFORE middleware, so ctx.segmentParams is already typed.
82
+ * See design/07-routing.md §"Where Coercion Runs"
83
+ */
84
+ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
85
+ const segments = match.segments;
86
+ let mergeTarget = match.segmentParams as Record<string, unknown>;
87
+ let usesNullPrototypeTarget = Object.getPrototypeOf(mergeTarget) === null;
88
+
89
+ for (const segment of segments) {
90
+ // Only process segments that have a params.ts convention file
91
+ if (!segment.params) continue;
92
+
93
+ let mod: Record<string, unknown>;
94
+ try {
95
+ mod = await loadModule(segment.params);
96
+ } catch (err) {
97
+ throw new ParamCoercionError(
98
+ `Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`
99
+ );
100
+ }
101
+
102
+ const segmentParamsDef = mod.segmentParams as
103
+ | { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
104
+ | undefined;
105
+
106
+ if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
107
+
108
+ try {
109
+ const coerced = segmentParamsDef.parse(match.segmentParams);
110
+
111
+ if (!usesNullPrototypeTarget) {
112
+ mergeTarget = Object.create(null) as Record<string, unknown>;
113
+ safeMerge(mergeTarget, match.segmentParams as Record<string, unknown>);
114
+ match.segmentParams = mergeTarget as RouteMatch['segmentParams'];
115
+ usesNullPrototypeTarget = true;
116
+ }
117
+
118
+ // safeMerge blocks shallow prototype-polluting keys from codec output.
119
+ // The null-prototype target above provides the deeper guarantee for
120
+ // nested values without paying the cost of a deep sanitizer.
121
+ safeMerge(mergeTarget, coerced as Record<string, unknown>);
122
+ } catch (err) {
123
+ throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
124
+ }
125
+ }
126
+ }
127
+
128
+ // ─── Phase: Proxy ──────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Run the proxy.ts phase. Calls user proxy code and uses `handleRequest` as
132
+ * the inner `next()` continuation. The proxy resolver was picked at pipeline
133
+ * construction time so the hot path sees no per-request branching on the
134
+ * `ProxyConfig` discriminant.
135
+ */
136
+ export async function runProxyPhase(
137
+ config: PipelineConfig,
138
+ getProxy: ProxyResolver,
139
+ req: Request,
140
+ method: string,
141
+ path: string
142
+ ): Promise<PhaseOutcome> {
143
+ const detailed = config.serverTiming === 'detailed';
144
+ try {
145
+ const proxyExport = await getProxy();
146
+ const proxyFn = () =>
147
+ runProxy(proxyExport, req, () => handleRequest(config, req, method, path));
148
+ const response = await withSpan('timber.proxy', {}, () =>
149
+ detailed ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
150
+ );
151
+ return { kind: 'response', phase: 'proxy', response };
152
+ } catch (error) {
153
+ return { kind: 'error', phase: 'proxy', error };
154
+ }
155
+ }
156
+
157
+ // ─── Phase: Middleware ─────────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Run the middleware chain phase. If the chain short-circuits with a Response,
161
+ * returns it as a 'response' outcome. Otherwise applies the request header
162
+ * overlay and falls through to the render phase.
163
+ */
164
+ export async function runMiddlewarePhase(
165
+ config: PipelineConfig,
166
+ req: Request,
167
+ match: RouteMatch,
168
+ responseHeaders: Headers,
169
+ requestHeaderOverlay: Headers,
170
+ renderContext: RenderContext
171
+ ): Promise<PhaseOutcome> {
172
+ const detailed = config.serverTiming === 'detailed';
173
+ const ctx: MiddlewareContext = {
174
+ req,
175
+ requestHeaders: requestHeaderOverlay,
176
+ headers: responseHeaders,
177
+ segmentParams: match.segmentParams,
178
+ earlyHints: (hints) => {
179
+ for (const hint of hints) {
180
+ // Match Cloudflare's cached Early Hints attribute order: `as` before `rel`.
181
+ // Cloudflare caches Link headers and re-emits them on subsequent 200s.
182
+ // If our order differs, the browser sees duplicate preloads and warns.
183
+ let value: string;
184
+ if (hint.as !== undefined) {
185
+ value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
186
+ } else {
187
+ value = `<${hint.href}>; rel=${hint.rel}`;
188
+ }
189
+ if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
190
+ if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
191
+ responseHeaders.append('Link', value);
192
+ }
193
+ },
194
+ };
195
+
196
+ try {
197
+ const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
198
+ // Enable cookie mutation during middleware (design/29-cookies.md §"Context Tracking")
199
+ const middlewareResponse = await (async () => {
200
+ setMutableCookieContext(true);
201
+ try {
202
+ return await withSpan('timber.middleware', {}, () =>
203
+ detailed ? withTiming('mw', 'middleware.ts', chainFn) : chainFn()
204
+ );
205
+ } finally {
206
+ setMutableCookieContext(false);
207
+ }
208
+ })();
209
+ if (middlewareResponse) {
210
+ return { kind: 'response', phase: 'middleware', response: middlewareResponse };
211
+ }
212
+ // Middleware chain completed without short-circuiting — apply any
213
+ // injected request headers so getHeaders() returns them downstream.
214
+ applyRequestHeaderOverlay(requestHeaderOverlay);
215
+
216
+ // Apply cookie jar to response headers before render commits them.
217
+ // This preserves the historical ordering where middleware cookie writes
218
+ // are visible to route-handler header merging, while handler Set-Cookie
219
+ // values still come after middleware cookies and therefore take precedence.
220
+ applyCookieJar(responseHeaders);
221
+
222
+ return runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext);
223
+ } catch (error) {
224
+ if (error instanceof RedirectSignal) {
225
+ return { kind: 'redirect', phase: 'middleware', signal: error };
226
+ }
227
+ if (error instanceof DenySignal) {
228
+ return { kind: 'deny', phase: 'middleware', signal: error };
229
+ }
230
+ return { kind: 'error', phase: 'middleware', error };
231
+ }
232
+ }
233
+
234
+ // ─── Phase: Render ─────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Run the render phase. Wraps the configured renderer in a span and a
238
+ * timing scope, and translates thrown signals into outcome variants.
239
+ */
240
+ export async function runRenderPhase(
241
+ config: PipelineConfig,
242
+ req: Request,
243
+ match: RouteMatch,
244
+ responseHeaders: Headers,
245
+ requestHeaderOverlay: Headers,
246
+ { canonicalPathname, interception }: RenderContext
247
+ ): Promise<PhaseOutcome> {
248
+ const detailed = config.serverTiming === 'detailed';
249
+ try {
250
+ const renderFn = () =>
251
+ config.render(req, match, responseHeaders, requestHeaderOverlay, interception);
252
+ const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
253
+ detailed ? withTiming('render', 'RSC + SSR render', renderFn) : renderFn()
254
+ );
255
+ return { kind: 'response', phase: 'render', response };
256
+ } catch (error) {
257
+ if (error instanceof DenySignal) {
258
+ return { kind: 'deny', phase: 'render', signal: error };
259
+ }
260
+ if (error instanceof RedirectSignal) {
261
+ return { kind: 'redirect', phase: 'render', signal: error };
262
+ }
263
+ return { kind: 'error', phase: 'render', error };
264
+ }
265
+ }
266
+
267
+ // ─── Request Handler ───────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Process a single request from canonicalization through phase dispatch.
271
+ *
272
+ * Stages: canonicalize → metadata routes → auto-sitemap → version skew →
273
+ * route match → interception → early hints → param coercion → middleware →
274
+ * render → outcome translation. Pre-routing short-circuits return Responses
275
+ * directly; post-match dispatch goes through `outcomeToResponse`.
276
+ *
277
+ * Used both as the top-level entry (when no proxy.ts is configured) and as
278
+ * the `next()` continuation passed to `runProxy()`.
279
+ */
280
+ export async function handleRequest(
281
+ config: PipelineConfig,
282
+ req: Request,
283
+ method: string,
284
+ path: string
285
+ ): Promise<Response> {
286
+ const stripTrailingSlash = config.stripTrailingSlash ?? true;
287
+
288
+ // Stage 1: URL canonicalization
289
+ const url = new URL(req.url);
290
+ const result = canonicalize(url.pathname, stripTrailingSlash);
291
+ if (!result.ok) {
292
+ return new Response(null, { status: result.status });
293
+ }
294
+ const canonicalPathname = result.pathname;
295
+
296
+ // Stage 1b: Metadata route matching — runs before regular route matching.
297
+ // Metadata routes skip middleware.ts and access.ts (public endpoints for crawlers).
298
+ // See design/16-metadata.md §"Pipeline Integration"
299
+ if (config.matchMetadataRoute) {
300
+ const metaMatch = config.matchMetadataRoute(canonicalPathname);
301
+ if (metaMatch) {
302
+ try {
303
+ // Static metadata files (.xml, .txt, .png, .ico, etc.) are served
304
+ // directly from disk. Dynamic metadata routes (.ts, .tsx) export a
305
+ // handler function that generates the response.
306
+ if (metaMatch.isStatic) {
307
+ return await serveStaticMetadataFile(metaMatch);
308
+ }
309
+
310
+ const mod = await loadModule<{ default?: Function }>(metaMatch.file);
311
+ if (typeof mod.default !== 'function') {
312
+ return new Response('Metadata route must export a default function', { status: 500 });
313
+ }
314
+ const handlerResult = await mod.default();
315
+ // If the handler returns a Response, normalize headers so the
316
+ // outer Server-Timing writer can append without hitting an
317
+ // immutable header bag (e.g. user returns Response.redirect()).
318
+ if (handlerResult instanceof Response) {
319
+ return cloneWithMutableHeaders(handlerResult);
320
+ }
321
+ // Otherwise, serialize based on content type
322
+ const contentType = metaMatch.contentType;
323
+ let body: string;
324
+ if (typeof handlerResult === 'string') {
325
+ body = handlerResult;
326
+ } else if (contentType === 'application/xml') {
327
+ body = serializeSitemap(handlerResult);
328
+ } else if (contentType === 'application/manifest+json') {
329
+ body = JSON.stringify(handlerResult, null, 2);
330
+ } else {
331
+ body = typeof handlerResult === 'string' ? handlerResult : String(handlerResult);
332
+ }
333
+ return new Response(body, {
334
+ status: 200,
335
+ headers: { 'Content-Type': `${contentType}; charset=utf-8` },
336
+ });
337
+ } catch (error) {
338
+ logRenderError({ method, path, error });
339
+ if (config.onPipelineError && error instanceof Error)
340
+ config.onPipelineError(error, 'metadata-route');
341
+ return new Response(null, { status: 500 });
342
+ }
343
+ }
344
+ }
345
+
346
+ // Stage 1b.2: Auto-generated sitemap — serves /sitemap.xml and /sitemap/N.xml
347
+ // when sitemap generation is enabled and no user-authored sitemap exists.
348
+ // Runs after metadata route matching so user sitemaps always take precedence.
349
+ // See design/16-metadata.md §"Auto-generated Sitemap"
350
+ if (config.autoSitemapHandler) {
351
+ try {
352
+ const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
353
+ if (sitemapResponse) return cloneWithMutableHeaders(sitemapResponse);
354
+ } catch (error) {
355
+ logRenderError({ method, path, error });
356
+ if (config.onPipelineError && error instanceof Error)
357
+ config.onPipelineError(error, 'auto-sitemap');
358
+ return new Response(null, { status: 500 });
359
+ }
360
+ }
361
+
362
+ // Stage 1c: Version skew detection (TIM-446).
363
+ // For RSC payload requests (client navigation), check if the client's
364
+ // deployment ID matches the current build. On mismatch, signal the
365
+ // client to do a full page reload instead of returning an RSC payload
366
+ // that references mismatched module IDs.
367
+ const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
368
+ if (isRscRequest) {
369
+ const skewCheck = checkVersionSkew(req);
370
+ if (!skewCheck.ok) {
371
+ const reloadHeaders = new Headers();
372
+ applyReloadHeaders(reloadHeaders);
373
+ return new Response(null, { status: 204, headers: reloadHeaders });
374
+ }
375
+ }
376
+
377
+ // Stage 2: Route matching
378
+ let match = config.matchRoute(canonicalPathname);
379
+ let interception: InterceptionContext | undefined;
380
+
381
+ // Stage 2a: Intercepting route resolution (modal pattern).
382
+ // On soft navigation, check if an intercepting route should render instead.
383
+ // The client sends X-Timber-URL with the current pathname (where they're
384
+ // navigating FROM). If a rewrite matches, re-route to the source URL so
385
+ // the source layout renders with the intercepted content in the slot.
386
+ const sourceUrl = req.headers.get('X-Timber-URL');
387
+ if (sourceUrl && config.interceptionRewrites?.length) {
388
+ const intercepted = findInterceptionMatch(
389
+ canonicalPathname,
390
+ sourceUrl,
391
+ config.interceptionRewrites
392
+ );
393
+ if (intercepted) {
394
+ const sourceMatch = config.matchRoute(intercepted.sourcePathname);
395
+ if (sourceMatch) {
396
+ match = sourceMatch;
397
+ interception = { targetPathname: canonicalPathname };
398
+ }
399
+ }
400
+ }
401
+
402
+ if (!match) {
403
+ // No route matched — render 404.tsx in root layout if available,
404
+ // otherwise fall back to a bare 404 Response.
405
+ if (config.renderNoMatch) {
406
+ const responseHeaders = new Headers();
407
+ return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
408
+ }
409
+ return new Response(null, { status: 404 });
410
+ }
411
+
412
+ // Response and request header containers — created before early hints so
413
+ // the emitter can append Link headers (e.g. for Cloudflare CDN → 103).
414
+ const responseHeaders = new Headers();
415
+ const requestHeaderOverlay = new Headers();
416
+
417
+ // Set Cache-Control for dynamic HTML responses. Without this header,
418
+ // CDNs (particularly Cloudflare) may attempt to buffer/process the
419
+ // response differently, causing intermittent multi-second delays.
420
+ // This matches Next.js's default behavior.
421
+ responseHeaders.set('Cache-Control', 'private, no-cache, no-store, max-age=0, must-revalidate');
422
+
423
+ // Stage 2b: 103 Early Hints (before middleware, after match)
424
+ // Fires before middleware so the browser can begin fetching critical
425
+ // assets while middleware runs. Non-fatal — a failing emitter never
426
+ // blocks the request.
427
+ if (config.earlyHints) {
428
+ try {
429
+ await config.earlyHints(match, req, responseHeaders);
430
+ } catch {
431
+ // Early hints failure is non-fatal
432
+ }
433
+ }
434
+
435
+ // Stage 2c: Param coercion (before middleware)
436
+ // Load params.ts modules from matched segments and coerce raw string
437
+ // params through defineSegmentParams codecs. Coercion failure → 404
438
+ // (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
439
+ try {
440
+ await coerceSegmentParams(match);
441
+ } catch (error) {
442
+ if (error instanceof ParamCoercionError) {
443
+ // For API routes (route.ts), return a bare 404 — not an HTML page.
444
+ // API consumers expect JSON/empty responses, not rendered HTML.
445
+ const leafSegment = match.segments[match.segments.length - 1];
446
+ if ((leafSegment as { route?: unknown }).route && !(leafSegment as { page?: unknown }).page) {
447
+ return new Response(null, { status: 404 });
448
+ }
449
+ // Route through the app's 404 page (404.tsx in root layout) instead of
450
+ // returning a bare empty 404 Response. Falls back to bare 404 only if
451
+ // no renderNoMatch renderer is configured.
452
+ if (config.renderNoMatch) {
453
+ return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
454
+ }
455
+ return new Response(null, { status: 404 });
456
+ }
457
+ throw error;
458
+ }
459
+
460
+ // Store coerced segment params in ALS so components can access them
461
+ // via getSegmentParams() instead of receiving them as a prop.
462
+ // See design/07-routing.md §"params.ts — Convention File for Typed Params"
463
+ setSegmentParams(match.segmentParams);
464
+
465
+ const outcome =
466
+ match.middlewareChain.length > 0
467
+ ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
468
+ canonicalPathname,
469
+ interception,
470
+ })
471
+ : await runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, {
472
+ canonicalPathname,
473
+ interception,
474
+ });
475
+
476
+ return outcomeToResponse(config, outcome, {
477
+ req,
478
+ method,
479
+ path,
480
+ responseHeaders,
481
+ match,
482
+ });
483
+ }
484
+
485
+ // ─── Outcome Translation ───────────────────────────────────────────────────
486
+
487
+ /**
488
+ * Terminal outcome handler — converts a `PhaseOutcome` into a final
489
+ * `Response`, applying cookies, building redirects, rendering deny pages
490
+ * and fallback error pages, and firing instrumentation hooks.
491
+ *
492
+ * This is the single source of truth for how phase outputs become wire
493
+ * responses; the per-phase try/catch blocks now produce values, not
494
+ * Responses, so the conversion logic lives in exactly one place.
495
+ */
496
+ export async function outcomeToResponse(
497
+ config: PipelineConfig,
498
+ outcome: PhaseOutcome,
499
+ ctx: OutcomeContext
500
+ ): Promise<Response> {
501
+ switch (outcome.kind) {
502
+ case 'response': {
503
+ // Clone unconditionally so downstream code (cookie/header merge,
504
+ // Server-Timing in createPipeline) can write headers without paying
505
+ // for a try/catch immutability probe per request. User middleware,
506
+ // proxy, and route code may all return `Response.redirect()` or
507
+ // platform-level responses with frozen header bags. See TIM-866.
508
+ const finalResponse = cloneWithMutableHeaders(outcome.response);
509
+
510
+ if (outcome.phase === 'proxy') return finalResponse;
511
+
512
+ if (outcome.phase === 'middleware' && ctx.responseHeaders) {
513
+ applyCookieJar(finalResponse.headers);
514
+ mergeMissingHeaders(finalResponse.headers, ctx.responseHeaders);
515
+ logMiddlewareShortCircuit({
516
+ method: ctx.method,
517
+ path: ctx.path,
518
+ status: finalResponse.status,
519
+ });
520
+ }
521
+
522
+ if (outcome.phase === 'render') {
523
+ markResponseFlushed();
524
+ }
525
+
526
+ return finalResponse;
527
+ }
528
+
529
+ case 'redirect': {
530
+ const headers = ctx.responseHeaders ?? new Headers();
531
+ applyCookieJar(headers);
532
+ return buildRedirectResponse(outcome.signal, ctx.req, headers);
533
+ }
534
+
535
+ case 'deny': {
536
+ const headers = ctx.responseHeaders ?? new Headers();
537
+ applyCookieJar(headers);
538
+ if (config.renderDenyFallback) {
539
+ try {
540
+ // Clone user-supplied deny-page responses so downstream
541
+ // Server-Timing writes are safe against frozen header bags
542
+ // (e.g. user returned Response.redirect from the hook).
543
+ return cloneWithMutableHeaders(
544
+ await config.renderDenyFallback(outcome.signal, ctx.req, headers, ctx.match)
545
+ );
546
+ } catch {
547
+ // Deny page rendering failed — fall through to bare response
548
+ }
549
+ }
550
+ return new Response(null, { status: outcome.signal.status, headers });
551
+ }
552
+
553
+ case 'error': {
554
+ if (outcome.phase === 'proxy') {
555
+ logProxyError({ error: outcome.error });
556
+ await fireOnRequestError(outcome.error, ctx.req, 'proxy');
557
+ if (config.onPipelineError && outcome.error instanceof Error)
558
+ config.onPipelineError(outcome.error, 'proxy');
559
+ return new Response(null, { status: 500 });
560
+ }
561
+
562
+ if (outcome.phase === 'middleware') {
563
+ logMiddlewareError({ method: ctx.method, path: ctx.path, error: outcome.error });
564
+ await fireOnRequestError(outcome.error, ctx.req, 'handler');
565
+ if (config.onPipelineError && outcome.error instanceof Error) {
566
+ config.onPipelineError(outcome.error, 'middleware');
567
+ }
568
+ return new Response(null, { status: 500 });
569
+ }
570
+
571
+ const headers = ctx.responseHeaders ?? new Headers();
572
+ applyCookieJar(headers);
573
+ logRenderError({ method: ctx.method, path: ctx.path, error: outcome.error });
574
+ await fireOnRequestError(outcome.error, ctx.req, 'render');
575
+ if (config.onPipelineError && outcome.error instanceof Error)
576
+ config.onPipelineError(outcome.error, 'render');
577
+ if (config.renderFallbackError) {
578
+ try {
579
+ // Clone user-supplied fallback error responses so downstream
580
+ // Server-Timing writes are safe against frozen header bags.
581
+ return cloneWithMutableHeaders(
582
+ await config.renderFallbackError(outcome.error, ctx.req, headers)
583
+ );
584
+ } catch {
585
+ // Fallback rendering itself failed — fall through to bare 500
586
+ }
587
+ }
588
+ return new Response(null, { status: 500 });
589
+ }
590
+ }
591
+ }