@timber-js/app 0.2.0-alpha.96 → 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.
- package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
- package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
- package/dist/_chunks/{interception-BsLCA9gk.js → walkers-VOXgavMF.js} +66 -92
- package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +55 -5
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +189 -62
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-report.d.ts +6 -4
- package/dist/plugins/build-report.d.ts.map +1 -1
- package/dist/plugins/dev-404-page.d.ts +8 -18
- package/dist/plugins/dev-404-page.d.ts.map +1 -1
- package/dist/routing/index.d.ts +5 -3
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts +1 -10
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/segment-classify.d.ts +37 -8
- package/dist/routing/segment-classify.d.ts.map +1 -1
- package/dist/routing/types.d.ts +63 -23
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/routing/walkers.d.ts +51 -0
- package/dist/routing/walkers.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/dev-holding-server.d.ts +4 -2
- package/dist/server/dev-holding-server.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +212 -0
- package/dist/server/html-injector-core.d.ts.map +1 -0
- package/dist/server/html-injectors.d.ts +59 -59
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/internal.js +710 -563
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +46 -49
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +88 -0
- package/dist/server/pipeline-helpers.d.ts.map +1 -0
- package/dist/server/pipeline-phases.d.ts +97 -0
- package/dist/server/pipeline-phases.d.ts.map +1 -0
- package/dist/server/pipeline.d.ts +53 -32
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/port-resolution.d.ts +117 -0
- package/dist/server/port-resolution.d.ts.map +1 -0
- package/dist/server/route-matcher.d.ts +20 -47
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
- package/dist/server/status-code-resolver.d.ts +16 -11
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/utils/directive-parser.d.ts +0 -45
- package/dist/utils/directive-parser.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/adapters/nitro.ts +55 -5
- package/src/cli.ts +0 -0
- package/src/index.ts +84 -31
- package/src/plugins/build-report.ts +13 -22
- package/src/plugins/dev-404-page.ts +15 -41
- package/src/plugins/routing.ts +14 -12
- package/src/routing/codegen.ts +1 -1
- package/src/routing/convention-lint.ts +4 -4
- package/src/routing/index.ts +5 -3
- package/src/routing/interception.ts +1 -1
- package/src/routing/link-codegen.ts +25 -13
- package/src/routing/scanner.ts +17 -93
- package/src/routing/segment-classify.ts +107 -8
- package/src/routing/status-file-lint.ts +3 -3
- package/src/routing/types.ts +63 -23
- package/src/routing/walkers.ts +90 -0
- package/src/server/action-handler.ts +6 -0
- package/src/server/deny-renderer.ts +5 -5
- package/src/server/dev-holding-server.ts +4 -2
- package/src/server/fallback-error.ts +1 -1
- package/src/server/html-injector-core.ts +403 -0
- package/src/server/html-injectors.ts +158 -297
- package/src/server/node-stream-transforms.ts +108 -248
- package/src/server/pipeline-helpers.ts +180 -0
- package/src/server/pipeline-phases.ts +591 -0
- package/src/server/pipeline.ts +76 -539
- package/src/server/port-resolution.ts +215 -0
- package/src/server/route-element-builder.ts +1 -1
- package/src/server/route-matcher.ts +28 -60
- package/src/server/rsc-entry/api-handler.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +1 -1
- package/src/server/rsc-entry/index.ts +52 -98
- package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
- package/src/server/sitemap-generator.ts +1 -1
- package/src/server/slot-resolver.ts +1 -1
- package/src/server/status-code-resolver.ts +112 -128
- package/src/server/tree-builder.ts +6 -4
- package/src/utils/directive-parser.ts +0 -392
- package/LICENSE +0 -8
- package/dist/_chunks/interception-BsLCA9gk.js.map +0 -1
- package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
- package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
- package/dist/server/manifest-status-resolver.d.ts +0 -58
- package/dist/server/manifest-status-resolver.d.ts.map +0 -1
- 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
|
+
}
|