@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
package/src/server/pipeline.ts
CHANGED
|
@@ -4,25 +4,21 @@
|
|
|
4
4
|
* Pipeline stages (in order):
|
|
5
5
|
* proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* The phase functions live in `pipeline-phases.ts` so each phase can be
|
|
8
|
+
* tested in isolation. The terminal `outcomeToResponse` translator and
|
|
9
|
+
* stateless helpers live in `pipeline-phases.ts` and `pipeline-helpers.ts`
|
|
10
|
+
* respectively. This file owns only the public type surface and the
|
|
11
|
+
* `createPipeline` entry point: trace ID setup, request-context ALS,
|
|
12
|
+
* Server-Timing wrapping, and the activeRequests counter.
|
|
9
13
|
*
|
|
10
14
|
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
|
|
11
15
|
* and design/17-logging.md §"Production Logging"
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
runWithRequestContext,
|
|
20
|
-
applyRequestHeaderOverlay,
|
|
21
|
-
setMutableCookieContext,
|
|
22
|
-
getSetCookieHeaders,
|
|
23
|
-
markResponseFlushed,
|
|
24
|
-
setSegmentParams,
|
|
25
|
-
} from './request-context.js';
|
|
18
|
+
import type { ProxyExport } from './proxy.js';
|
|
19
|
+
import type { MiddlewareFn } from './middleware-runner.js';
|
|
20
|
+
import { runWithTimingCollector, getServerTimingHeader } from './server-timing.js';
|
|
21
|
+
import { runWithRequestContext } from './request-context.js';
|
|
26
22
|
import {
|
|
27
23
|
generateTraceId,
|
|
28
24
|
runWithTraceId,
|
|
@@ -30,55 +26,35 @@ import {
|
|
|
30
26
|
replaceTraceId,
|
|
31
27
|
withSpan,
|
|
32
28
|
setSpanAttribute,
|
|
33
|
-
getTraceId,
|
|
34
29
|
} from './tracing.js';
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
logRenderError,
|
|
43
|
-
} from './logger.js';
|
|
44
|
-
import { callOnRequestError } from './instrumentation.js';
|
|
45
|
-
import { RedirectSignal, DenySignal } from './primitives.js';
|
|
46
|
-
import { ParamCoercionError } from './route-element-builder.js';
|
|
47
|
-
import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
|
|
48
|
-
import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
|
|
49
|
-
import { loadModule } from './safe-load.js';
|
|
50
|
-
import { findInterceptionMatch } from './pipeline-interception.js';
|
|
51
|
-
import type { MiddlewareContext } from './types.js';
|
|
52
|
-
import type { SegmentNode } from '../routing/types.js';
|
|
30
|
+
import { logRequestReceived, logRequestCompleted, logSlowRequest } from './logger.js';
|
|
31
|
+
import { DenySignal } from './primitives.js';
|
|
32
|
+
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
33
|
+
import { makeProxyResolver } from './pipeline-helpers.js';
|
|
34
|
+
import { handleRequest, outcomeToResponse, runProxyPhase } from './pipeline-phases.js';
|
|
35
|
+
|
|
36
|
+
// ─── Re-exports for backwards compatibility ────────────────────────────────
|
|
53
37
|
|
|
54
|
-
//
|
|
38
|
+
// `safeMerge` and `coerceSegmentParams` were originally exported from this
|
|
39
|
+
// module. They now live in pipeline-helpers.ts and pipeline-phases.ts; the
|
|
40
|
+
// re-exports preserve `import { safeMerge } from './pipeline.js'` callers.
|
|
41
|
+
export { safeMerge } from './pipeline-helpers.js';
|
|
42
|
+
export { coerceSegmentParams } from './pipeline-phases.js';
|
|
55
43
|
|
|
56
|
-
|
|
57
|
-
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
44
|
+
// ─── Route Match Result ────────────────────────────────────────────────────
|
|
58
45
|
|
|
59
46
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* Used instead of Object.assign when the source object comes from
|
|
63
|
-
* user-authored codec output (segmentParams.parse), which could
|
|
64
|
-
* contain __proto__, constructor, or prototype keys.
|
|
47
|
+
* Result of matching a canonical pathname against the route tree.
|
|
65
48
|
*
|
|
66
|
-
*
|
|
49
|
+
* `segments` is the runtime (`ManifestFile`-specialized) shape — the same
|
|
50
|
+
* nodes carried in the virtual route manifest. TIM-863 unified this: the
|
|
51
|
+
* matcher produces `ManifestSegmentNode[]` directly and every consumer
|
|
52
|
+
* (render, slots, params coercion, early hints, deny fallback) sees the
|
|
53
|
+
* same structural type with no `as unknown as` laundering.
|
|
67
54
|
*/
|
|
68
|
-
export function safeMerge(target: Record<string, unknown>, source: Record<string, unknown>): void {
|
|
69
|
-
for (const key of Object.keys(source)) {
|
|
70
|
-
if (!DANGEROUS_KEYS.has(key)) {
|
|
71
|
-
target[key] = source[key];
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ─── Route Match Result ────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
/** Result of matching a canonical pathname against the route tree. */
|
|
79
55
|
export interface RouteMatch {
|
|
80
56
|
/** The matched segment chain from root to leaf. */
|
|
81
|
-
segments:
|
|
57
|
+
segments: ManifestSegmentNode[];
|
|
82
58
|
/** Extracted segment params (catch-all segments produce string[]). */
|
|
83
59
|
segmentParams: Record<string, string | string[]>;
|
|
84
60
|
/** Middleware chain from the segment tree, ordered root-to-leaf. */
|
|
@@ -117,11 +93,32 @@ export type EarlyHintsEmitter = (
|
|
|
117
93
|
|
|
118
94
|
// ─── Pipeline Configuration ────────────────────────────────────────────────
|
|
119
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Proxy source — a tagged union so the choice between "already-resolved
|
|
98
|
+
* export" and "lazy HMR-friendly loader" is encoded in the type, not
|
|
99
|
+
* inferred per-request.
|
|
100
|
+
*
|
|
101
|
+
* - `static` — the proxy export is already resolved (production, tests).
|
|
102
|
+
* - `lazy` — a loader is called per-request for HMR freshness (dev).
|
|
103
|
+
*
|
|
104
|
+
* `PipelineConfig.proxy` also accepts a bare `ProxyExport` (a function or
|
|
105
|
+
* function array) as shorthand for the static variant — convenient for tests
|
|
106
|
+
* that construct a `createPipeline` config inline. Omit the field entirely
|
|
107
|
+
* when the app has no `proxy.ts`.
|
|
108
|
+
*
|
|
109
|
+
* See design/07-routing.md §"proxy.ts — Global Middleware".
|
|
110
|
+
*/
|
|
111
|
+
export type ProxyConfig =
|
|
112
|
+
| { kind: 'static'; export: ProxyExport }
|
|
113
|
+
| { kind: 'lazy'; loader: () => Promise<{ default: ProxyExport }> };
|
|
114
|
+
|
|
120
115
|
export interface PipelineConfig {
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
116
|
+
/**
|
|
117
|
+
* proxy.ts source. Undefined if the app has no proxy.ts. Accepts either a
|
|
118
|
+
* tagged `ProxyConfig` (canonical) or a bare `ProxyExport` as sugar for the
|
|
119
|
+
* static variant.
|
|
120
|
+
*/
|
|
121
|
+
proxy?: ProxyConfig | ProxyExport;
|
|
125
122
|
/** Route matcher — resolves a canonical pathname to a RouteMatch. */
|
|
126
123
|
matchRoute: RouteMatcher;
|
|
127
124
|
/** Metadata route matcher — resolves metadata route pathnames (sitemap.xml, robots.txt, etc.) */
|
|
@@ -209,70 +206,26 @@ export interface PipelineConfig {
|
|
|
209
206
|
) => Response | Promise<Response>;
|
|
210
207
|
}
|
|
211
208
|
|
|
212
|
-
// ─── Param Coercion ────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Run segment param coercion on the matched route's segments.
|
|
216
|
-
*
|
|
217
|
-
* Loads params.ts modules from segments that have them, extracts the
|
|
218
|
-
* segmentParams definition, and coerces raw string params through codecs.
|
|
219
|
-
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
220
|
-
*
|
|
221
|
-
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
222
|
-
* See design/07-routing.md §"Where Coercion Runs"
|
|
223
|
-
*/
|
|
224
|
-
export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
|
|
225
|
-
const segments = match.segments as unknown as import('./route-matcher.js').ManifestSegmentNode[];
|
|
226
|
-
|
|
227
|
-
for (const segment of segments) {
|
|
228
|
-
// Only process segments that have a params.ts convention file
|
|
229
|
-
if (!segment.params) continue;
|
|
230
|
-
|
|
231
|
-
let mod: Record<string, unknown>;
|
|
232
|
-
try {
|
|
233
|
-
mod = await loadModule(segment.params);
|
|
234
|
-
} catch (err) {
|
|
235
|
-
throw new ParamCoercionError(
|
|
236
|
-
`Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const segmentParamsDef = mod.segmentParams as
|
|
241
|
-
| { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
|
|
242
|
-
| undefined;
|
|
243
|
-
|
|
244
|
-
if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
|
|
245
|
-
|
|
246
|
-
try {
|
|
247
|
-
const coerced = segmentParamsDef.parse(match.segmentParams);
|
|
248
|
-
// Merge coerced values back — use safeMerge to prevent prototype pollution
|
|
249
|
-
// from malicious/buggy codec output. See TIM-655.
|
|
250
|
-
safeMerge(match.segmentParams, coerced as Record<string, unknown>);
|
|
251
|
-
} catch (err) {
|
|
252
|
-
throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
209
|
// ─── Pipeline ──────────────────────────────────────────────────────────────
|
|
258
210
|
|
|
259
211
|
/**
|
|
260
212
|
* Create the request handler from a pipeline configuration.
|
|
261
213
|
*
|
|
262
|
-
* Returns a function that processes an incoming Request through all pipeline
|
|
263
|
-
* and produces a Response. This is the top-level entry point for the
|
|
214
|
+
* Returns a function that processes an incoming Request through all pipeline
|
|
215
|
+
* stages and produces a Response. This is the top-level entry point for the
|
|
216
|
+
* server. The body is intentionally small — phase logic lives in
|
|
217
|
+
* `pipeline-phases.ts`. This function only owns the per-request setup that
|
|
218
|
+
* has to wrap the entire dispatch: trace ID, request context ALS, span
|
|
219
|
+
* scope, Server-Timing header emission, and the active-request counter.
|
|
264
220
|
*/
|
|
265
221
|
export function createPipeline(config: PipelineConfig): (req: Request) => Promise<Response> {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
serverTiming = 'total',
|
|
274
|
-
onPipelineError,
|
|
275
|
-
} = config;
|
|
222
|
+
// Resolve the proxy source once. The request hot path calls this closure
|
|
223
|
+
// directly with no discriminant check — the branch is taken here during
|
|
224
|
+
// setup. For the lazy variant, `loader()` still runs per-request so HMR
|
|
225
|
+
// continues to re-import the user's proxy.ts.
|
|
226
|
+
const proxyResolver = makeProxyResolver(config.proxy);
|
|
227
|
+
const slowRequestMs = config.slowRequestMs ?? 3000;
|
|
228
|
+
const serverTiming = config.serverTiming ?? 'total';
|
|
276
229
|
|
|
277
230
|
// Concurrent request counter — tracks how many requests are in-flight.
|
|
278
231
|
// Logged with each request for diagnosing resource contention.
|
|
@@ -311,10 +264,11 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
311
264
|
}
|
|
312
265
|
|
|
313
266
|
let result: Response;
|
|
314
|
-
if (
|
|
315
|
-
|
|
267
|
+
if (proxyResolver) {
|
|
268
|
+
const outcome = await runProxyPhase(config, proxyResolver, req, method, path);
|
|
269
|
+
result = await outcomeToResponse(config, outcome, { req, method, path });
|
|
316
270
|
} else {
|
|
317
|
-
result = await handleRequest(req, method, path);
|
|
271
|
+
result = await handleRequest(config, req, method, path);
|
|
318
272
|
}
|
|
319
273
|
|
|
320
274
|
// Set response status on the root span before it ends —
|
|
@@ -322,13 +276,14 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
322
276
|
await setSpanAttribute('http.response.status_code', result.status);
|
|
323
277
|
|
|
324
278
|
// Append Server-Timing header based on configured mode.
|
|
325
|
-
//
|
|
326
|
-
//
|
|
279
|
+
// Header mutability is guaranteed by the producer-side clone
|
|
280
|
+
// in `outcomeToResponse` and the metadata-route / auto-sitemap
|
|
281
|
+
// user-handler clones in `handleRequest`, so we can write
|
|
282
|
+
// directly without a runtime probe. See TIM-866.
|
|
327
283
|
if (serverTiming === 'detailed') {
|
|
328
284
|
// Detailed: per-phase breakdown (proxy, middleware, render).
|
|
329
285
|
const timingHeader = getServerTimingHeader();
|
|
330
286
|
if (timingHeader) {
|
|
331
|
-
result = ensureMutableResponse(result);
|
|
332
287
|
result.headers.set('Server-Timing', timingHeader);
|
|
333
288
|
}
|
|
334
289
|
} else if (serverTiming === 'total') {
|
|
@@ -336,7 +291,6 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
336
291
|
// Prevents information disclosure while giving browser
|
|
337
292
|
// DevTools useful timing data.
|
|
338
293
|
const totalMs = Math.round(performance.now() - startTime);
|
|
339
|
-
result = ensureMutableResponse(result);
|
|
340
294
|
result.headers.set('Server-Timing', `total;dur=${totalMs}`);
|
|
341
295
|
}
|
|
342
296
|
// serverTiming === false: no header at all
|
|
@@ -363,421 +317,4 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
363
317
|
});
|
|
364
318
|
});
|
|
365
319
|
};
|
|
366
|
-
|
|
367
|
-
async function runProxyPhase(req: Request, method: string, path: string): Promise<Response> {
|
|
368
|
-
try {
|
|
369
|
-
// Resolve the proxy export. When a proxyLoader is provided (lazy import),
|
|
370
|
-
// it is called per-request so HMR updates in dev take effect immediately.
|
|
371
|
-
let proxyExport: ProxyExport;
|
|
372
|
-
if (config.proxyLoader) {
|
|
373
|
-
const mod = await config.proxyLoader();
|
|
374
|
-
proxyExport = mod.default;
|
|
375
|
-
} else {
|
|
376
|
-
proxyExport = config.proxy!;
|
|
377
|
-
}
|
|
378
|
-
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
379
|
-
return await withSpan('timber.proxy', {}, () =>
|
|
380
|
-
serverTiming === 'detailed' ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
|
|
381
|
-
);
|
|
382
|
-
} catch (error) {
|
|
383
|
-
// Uncaught proxy.ts error → bare HTTP 500
|
|
384
|
-
logProxyError({ error });
|
|
385
|
-
await fireOnRequestError(error, req, 'proxy');
|
|
386
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, 'proxy');
|
|
387
|
-
return new Response(null, { status: 500 });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Build a redirect Response from a RedirectSignal.
|
|
393
|
-
*
|
|
394
|
-
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
395
|
-
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
396
|
-
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
397
|
-
* createFromFetch. See design/19-client-navigation.md.
|
|
398
|
-
*/
|
|
399
|
-
function buildRedirectResponse(signal: RedirectSignal, req: Request, headers: Headers): Response {
|
|
400
|
-
const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
401
|
-
if (isRsc) {
|
|
402
|
-
headers.set('X-Timber-Redirect', signal.location);
|
|
403
|
-
return new Response(null, { status: 204, headers });
|
|
404
|
-
}
|
|
405
|
-
headers.set('Location', signal.location);
|
|
406
|
-
return new Response(null, { status: signal.status, headers });
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async function handleRequest(req: Request, method: string, path: string): Promise<Response> {
|
|
410
|
-
// Stage 1: URL canonicalization
|
|
411
|
-
const url = new URL(req.url);
|
|
412
|
-
const result = canonicalize(url.pathname, stripTrailingSlash);
|
|
413
|
-
if (!result.ok) {
|
|
414
|
-
return new Response(null, { status: result.status });
|
|
415
|
-
}
|
|
416
|
-
const canonicalPathname = result.pathname;
|
|
417
|
-
|
|
418
|
-
// Stage 1b: Metadata route matching — runs before regular route matching.
|
|
419
|
-
// Metadata routes skip middleware.ts and access.ts (public endpoints for crawlers).
|
|
420
|
-
// See design/16-metadata.md §"Pipeline Integration"
|
|
421
|
-
if (config.matchMetadataRoute) {
|
|
422
|
-
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
423
|
-
if (metaMatch) {
|
|
424
|
-
try {
|
|
425
|
-
// Static metadata files (.xml, .txt, .png, .ico, etc.) are served
|
|
426
|
-
// directly from disk. Dynamic metadata routes (.ts, .tsx) export a
|
|
427
|
-
// handler function that generates the response.
|
|
428
|
-
if (metaMatch.isStatic) {
|
|
429
|
-
return await serveStaticMetadataFile(metaMatch);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const mod = await loadModule<{ default?: Function }>(metaMatch.file);
|
|
433
|
-
if (typeof mod.default !== 'function') {
|
|
434
|
-
return new Response('Metadata route must export a default function', { status: 500 });
|
|
435
|
-
}
|
|
436
|
-
const handlerResult = await mod.default();
|
|
437
|
-
// If the handler returns a Response, use it directly
|
|
438
|
-
if (handlerResult instanceof Response) {
|
|
439
|
-
return handlerResult;
|
|
440
|
-
}
|
|
441
|
-
// Otherwise, serialize based on content type
|
|
442
|
-
const contentType = metaMatch.contentType;
|
|
443
|
-
let body: string;
|
|
444
|
-
if (typeof handlerResult === 'string') {
|
|
445
|
-
body = handlerResult;
|
|
446
|
-
} else if (contentType === 'application/xml') {
|
|
447
|
-
body = serializeSitemap(handlerResult);
|
|
448
|
-
} else if (contentType === 'application/manifest+json') {
|
|
449
|
-
body = JSON.stringify(handlerResult, null, 2);
|
|
450
|
-
} else {
|
|
451
|
-
body = typeof handlerResult === 'string' ? handlerResult : String(handlerResult);
|
|
452
|
-
}
|
|
453
|
-
return new Response(body, {
|
|
454
|
-
status: 200,
|
|
455
|
-
headers: { 'Content-Type': `${contentType}; charset=utf-8` },
|
|
456
|
-
});
|
|
457
|
-
} catch (error) {
|
|
458
|
-
logRenderError({ method, path, error });
|
|
459
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, 'metadata-route');
|
|
460
|
-
return new Response(null, { status: 500 });
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Stage 1b.2: Auto-generated sitemap — serves /sitemap.xml and /sitemap/N.xml
|
|
466
|
-
// when sitemap generation is enabled and no user-authored sitemap exists.
|
|
467
|
-
// Runs after metadata route matching so user sitemaps always take precedence.
|
|
468
|
-
// See design/16-metadata.md §"Auto-generated Sitemap"
|
|
469
|
-
if (config.autoSitemapHandler) {
|
|
470
|
-
try {
|
|
471
|
-
const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
|
|
472
|
-
if (sitemapResponse) return sitemapResponse;
|
|
473
|
-
} catch (error) {
|
|
474
|
-
logRenderError({ method, path, error });
|
|
475
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, 'auto-sitemap');
|
|
476
|
-
return new Response(null, { status: 500 });
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// Stage 1c: Version skew detection (TIM-446).
|
|
481
|
-
// For RSC payload requests (client navigation), check if the client's
|
|
482
|
-
// deployment ID matches the current build. On mismatch, signal the
|
|
483
|
-
// client to do a full page reload instead of returning an RSC payload
|
|
484
|
-
// that references mismatched module IDs.
|
|
485
|
-
const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
486
|
-
if (isRscRequest) {
|
|
487
|
-
const skewCheck = checkVersionSkew(req);
|
|
488
|
-
if (!skewCheck.ok) {
|
|
489
|
-
const reloadHeaders = new Headers();
|
|
490
|
-
applyReloadHeaders(reloadHeaders);
|
|
491
|
-
return new Response(null, { status: 204, headers: reloadHeaders });
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Stage 2: Route matching
|
|
496
|
-
let match = matchRoute(canonicalPathname);
|
|
497
|
-
let interception: InterceptionContext | undefined;
|
|
498
|
-
|
|
499
|
-
// Stage 2a: Intercepting route resolution (modal pattern).
|
|
500
|
-
// On soft navigation, check if an intercepting route should render instead.
|
|
501
|
-
// The client sends X-Timber-URL with the current pathname (where they're
|
|
502
|
-
// navigating FROM). If a rewrite matches, re-route to the source URL so
|
|
503
|
-
// the source layout renders with the intercepted content in the slot.
|
|
504
|
-
const sourceUrl = req.headers.get('X-Timber-URL');
|
|
505
|
-
if (sourceUrl && config.interceptionRewrites?.length) {
|
|
506
|
-
const intercepted = findInterceptionMatch(
|
|
507
|
-
canonicalPathname,
|
|
508
|
-
sourceUrl,
|
|
509
|
-
config.interceptionRewrites
|
|
510
|
-
);
|
|
511
|
-
if (intercepted) {
|
|
512
|
-
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
513
|
-
if (sourceMatch) {
|
|
514
|
-
match = sourceMatch;
|
|
515
|
-
interception = { targetPathname: canonicalPathname };
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
if (!match) {
|
|
521
|
-
// No route matched — render 404.tsx in root layout if available,
|
|
522
|
-
// otherwise fall back to a bare 404 Response.
|
|
523
|
-
if (config.renderNoMatch) {
|
|
524
|
-
const responseHeaders = new Headers();
|
|
525
|
-
return config.renderNoMatch(req, responseHeaders);
|
|
526
|
-
}
|
|
527
|
-
return new Response(null, { status: 404 });
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// Response and request header containers — created before early hints so
|
|
531
|
-
// the emitter can append Link headers (e.g. for Cloudflare CDN → 103).
|
|
532
|
-
const responseHeaders = new Headers();
|
|
533
|
-
const requestHeaderOverlay = new Headers();
|
|
534
|
-
|
|
535
|
-
// Set Cache-Control for dynamic HTML responses. Without this header,
|
|
536
|
-
// CDNs (particularly Cloudflare) may attempt to buffer/process the
|
|
537
|
-
// response differently, causing intermittent multi-second delays.
|
|
538
|
-
// This matches Next.js's default behavior.
|
|
539
|
-
responseHeaders.set('Cache-Control', 'private, no-cache, no-store, max-age=0, must-revalidate');
|
|
540
|
-
|
|
541
|
-
// Stage 2b: 103 Early Hints (before middleware, after match)
|
|
542
|
-
// Fires before middleware so the browser can begin fetching critical
|
|
543
|
-
// assets while middleware runs. Non-fatal — a failing emitter never
|
|
544
|
-
// blocks the request.
|
|
545
|
-
if (earlyHints) {
|
|
546
|
-
try {
|
|
547
|
-
await earlyHints(match, req, responseHeaders);
|
|
548
|
-
} catch {
|
|
549
|
-
// Early hints failure is non-fatal
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Stage 2c: Param coercion (before middleware)
|
|
554
|
-
// Load params.ts modules from matched segments and coerce raw string
|
|
555
|
-
// params through defineSegmentParams codecs. Coercion failure → 404
|
|
556
|
-
// (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
|
|
557
|
-
try {
|
|
558
|
-
await coerceSegmentParams(match);
|
|
559
|
-
} catch (error) {
|
|
560
|
-
if (error instanceof ParamCoercionError) {
|
|
561
|
-
// For API routes (route.ts), return a bare 404 — not an HTML page.
|
|
562
|
-
// API consumers expect JSON/empty responses, not rendered HTML.
|
|
563
|
-
const leafSegment = match.segments[match.segments.length - 1];
|
|
564
|
-
if (
|
|
565
|
-
(leafSegment as { route?: unknown }).route &&
|
|
566
|
-
!(leafSegment as { page?: unknown }).page
|
|
567
|
-
) {
|
|
568
|
-
return new Response(null, { status: 404 });
|
|
569
|
-
}
|
|
570
|
-
// Route through the app's 404 page (404.tsx in root layout) instead of
|
|
571
|
-
// returning a bare empty 404 Response. Falls back to bare 404 only if
|
|
572
|
-
// no renderNoMatch renderer is configured.
|
|
573
|
-
if (config.renderNoMatch) {
|
|
574
|
-
return config.renderNoMatch(req, responseHeaders);
|
|
575
|
-
}
|
|
576
|
-
return new Response(null, { status: 404 });
|
|
577
|
-
}
|
|
578
|
-
throw error;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Store coerced segment params in ALS so components can access them
|
|
582
|
-
// via getSegmentParams() instead of receiving them as a prop.
|
|
583
|
-
// See design/07-routing.md §"params.ts — Convention File for Typed Params"
|
|
584
|
-
setSegmentParams(match.segmentParams);
|
|
585
|
-
|
|
586
|
-
// Stage 3: Middleware chain (root-to-leaf, short-circuits on first Response)
|
|
587
|
-
if (match.middlewareChain.length > 0) {
|
|
588
|
-
const ctx: MiddlewareContext = {
|
|
589
|
-
req,
|
|
590
|
-
requestHeaders: requestHeaderOverlay,
|
|
591
|
-
headers: responseHeaders,
|
|
592
|
-
segmentParams: match.segmentParams,
|
|
593
|
-
earlyHints: (hints) => {
|
|
594
|
-
for (const hint of hints) {
|
|
595
|
-
// Match Cloudflare's cached Early Hints attribute order: `as` before `rel`.
|
|
596
|
-
// Cloudflare caches Link headers and re-emits them on subsequent 200s.
|
|
597
|
-
// If our order differs, the browser sees duplicate preloads and warns.
|
|
598
|
-
let value: string;
|
|
599
|
-
if (hint.as !== undefined) {
|
|
600
|
-
value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
601
|
-
} else {
|
|
602
|
-
value = `<${hint.href}>; rel=${hint.rel}`;
|
|
603
|
-
}
|
|
604
|
-
if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
|
|
605
|
-
if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
606
|
-
responseHeaders.append('Link', value);
|
|
607
|
-
}
|
|
608
|
-
},
|
|
609
|
-
};
|
|
610
|
-
|
|
611
|
-
try {
|
|
612
|
-
// Enable cookie mutation during middleware (design/29-cookies.md §"Context Tracking")
|
|
613
|
-
setMutableCookieContext(true);
|
|
614
|
-
const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
|
|
615
|
-
const middlewareResponse = await withSpan('timber.middleware', {}, () =>
|
|
616
|
-
serverTiming === 'detailed' ? withTiming('mw', 'middleware.ts', chainFn) : chainFn()
|
|
617
|
-
);
|
|
618
|
-
setMutableCookieContext(false);
|
|
619
|
-
if (middlewareResponse) {
|
|
620
|
-
// Apply cookie jar to short-circuit response.
|
|
621
|
-
// Response.redirect() creates immutable headers, so ensure
|
|
622
|
-
// mutability before appending Set-Cookie entries.
|
|
623
|
-
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
624
|
-
applyCookieJar(finalResponse.headers);
|
|
625
|
-
// Merge parent-set responseHeaders onto the short-circuit response.
|
|
626
|
-
// Child-set headers take precedence — only add headers not already present.
|
|
627
|
-
// Snapshot existing keys first so multi-value headers (Set-Cookie, Link)
|
|
628
|
-
// from the parent are all appended when the child didn't set that key.
|
|
629
|
-
const existingKeys = new Set(
|
|
630
|
-
[...finalResponse.headers.keys()].map((k) => k.toLowerCase())
|
|
631
|
-
);
|
|
632
|
-
for (const [key, value] of responseHeaders.entries()) {
|
|
633
|
-
if (!existingKeys.has(key.toLowerCase())) {
|
|
634
|
-
finalResponse.headers.append(key, value);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
logMiddlewareShortCircuit({ method, path, status: finalResponse.status });
|
|
638
|
-
return finalResponse;
|
|
639
|
-
}
|
|
640
|
-
// Middleware chain completed without short-circuiting — apply any
|
|
641
|
-
// injected request headers so getHeaders() returns them downstream.
|
|
642
|
-
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
643
|
-
} catch (error) {
|
|
644
|
-
setMutableCookieContext(false);
|
|
645
|
-
// RedirectSignal from middleware → HTTP redirect (not an error)
|
|
646
|
-
if (error instanceof RedirectSignal) {
|
|
647
|
-
applyCookieJar(responseHeaders);
|
|
648
|
-
return buildRedirectResponse(error, req, responseHeaders);
|
|
649
|
-
}
|
|
650
|
-
// DenySignal from middleware → render deny page with correct status code.
|
|
651
|
-
// Previously returned bare Response(null) — now renders 403.tsx etc.
|
|
652
|
-
if (error instanceof DenySignal) {
|
|
653
|
-
applyCookieJar(responseHeaders);
|
|
654
|
-
if (config.renderDenyFallback) {
|
|
655
|
-
try {
|
|
656
|
-
return await config.renderDenyFallback(error, req, responseHeaders, match);
|
|
657
|
-
} catch {
|
|
658
|
-
// Deny page rendering failed — fall through to bare response
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
662
|
-
}
|
|
663
|
-
// Middleware throw → HTTP 500 (middleware runs before rendering,
|
|
664
|
-
// no error boundary to catch it)
|
|
665
|
-
logMiddlewareError({ method, path, error });
|
|
666
|
-
await fireOnRequestError(error, req, 'handler');
|
|
667
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, 'middleware');
|
|
668
|
-
return new Response(null, { status: 500 });
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Apply cookie jar to response headers before render commits them.
|
|
673
|
-
// Middleware may have set cookies; they need to be on responseHeaders
|
|
674
|
-
// before flushResponse creates the Response object.
|
|
675
|
-
applyCookieJar(responseHeaders);
|
|
676
|
-
|
|
677
|
-
// Stage 4: Render (access gates + element tree + renderToReadableStream)
|
|
678
|
-
try {
|
|
679
|
-
const renderFn = () =>
|
|
680
|
-
render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
681
|
-
const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
|
|
682
|
-
serverTiming === 'detailed'
|
|
683
|
-
? withTiming('render', 'RSC + SSR render', renderFn)
|
|
684
|
-
: renderFn()
|
|
685
|
-
);
|
|
686
|
-
markResponseFlushed();
|
|
687
|
-
return response;
|
|
688
|
-
} catch (error) {
|
|
689
|
-
// DenySignal leaked from render (e.g. notFound() in metadata()).
|
|
690
|
-
// Render the deny page with the correct status code.
|
|
691
|
-
if (error instanceof DenySignal) {
|
|
692
|
-
if (config.renderDenyFallback) {
|
|
693
|
-
try {
|
|
694
|
-
return await config.renderDenyFallback(error, req, responseHeaders, match);
|
|
695
|
-
} catch {
|
|
696
|
-
// Deny page rendering failed — fall through to bare response
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
700
|
-
}
|
|
701
|
-
// RedirectSignal leaked from render — honour the redirect
|
|
702
|
-
if (error instanceof RedirectSignal) {
|
|
703
|
-
return buildRedirectResponse(error, req, responseHeaders);
|
|
704
|
-
}
|
|
705
|
-
logRenderError({ method, path, error });
|
|
706
|
-
await fireOnRequestError(error, req, 'render');
|
|
707
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, 'render');
|
|
708
|
-
// Try fallback error page before bare 500
|
|
709
|
-
if (config.renderFallbackError) {
|
|
710
|
-
try {
|
|
711
|
-
return await config.renderFallbackError(error, req, responseHeaders);
|
|
712
|
-
} catch {
|
|
713
|
-
// Fallback rendering itself failed — fall through to bare 500
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
return new Response(null, { status: 500 });
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Fire the user's onRequestError hook with request context.
|
|
723
|
-
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
724
|
-
*/
|
|
725
|
-
async function fireOnRequestError(
|
|
726
|
-
error: unknown,
|
|
727
|
-
req: Request,
|
|
728
|
-
phase: 'proxy' | 'handler' | 'render' | 'action' | 'route'
|
|
729
|
-
): Promise<void> {
|
|
730
|
-
const url = new URL(req.url);
|
|
731
|
-
const headersObj: Record<string, string> = {};
|
|
732
|
-
req.headers.forEach((v, k) => {
|
|
733
|
-
headersObj[k] = v;
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
await callOnRequestError(
|
|
737
|
-
error,
|
|
738
|
-
{ method: req.method, path: url.pathname, headers: headersObj },
|
|
739
|
-
{ phase, routePath: url.pathname, routeType: 'page', traceId: getTraceId() }
|
|
740
|
-
);
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// ─── Cookie Helpers ──────────────────────────────────────────────────────
|
|
744
|
-
|
|
745
|
-
/**
|
|
746
|
-
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
747
|
-
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
748
|
-
*/
|
|
749
|
-
function applyCookieJar(headers: Headers): void {
|
|
750
|
-
for (const value of getSetCookieHeaders()) {
|
|
751
|
-
headers.append('Set-Cookie', value);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// ─── Immutable Response Helpers ──────────────────────────────────────────
|
|
756
|
-
|
|
757
|
-
/**
|
|
758
|
-
* Ensure a Response has mutable headers so the pipeline can safely append
|
|
759
|
-
* Set-Cookie and Server-Timing entries.
|
|
760
|
-
*
|
|
761
|
-
* `Response.redirect()` and some platform-level responses return objects
|
|
762
|
-
* with immutable headers. Calling `.set()` or `.append()` on them throws
|
|
763
|
-
* `TypeError: immutable`. This helper detects the immutable case by
|
|
764
|
-
* attempting a no-op write and, on failure, clones into a fresh Response
|
|
765
|
-
* with mutable headers.
|
|
766
|
-
*/
|
|
767
|
-
function ensureMutableResponse(response: Response): Response {
|
|
768
|
-
try {
|
|
769
|
-
// Probe mutability with a benign operation that we immediately undo.
|
|
770
|
-
// We pick a header name that is extremely unlikely to collide with
|
|
771
|
-
// anything meaningful and delete it right away.
|
|
772
|
-
response.headers.set('X-Timber-Probe', '1');
|
|
773
|
-
response.headers.delete('X-Timber-Probe');
|
|
774
|
-
return response;
|
|
775
|
-
} catch {
|
|
776
|
-
// Headers are immutable — rebuild with mutable headers.
|
|
777
|
-
return new Response(response.body, {
|
|
778
|
-
status: response.status,
|
|
779
|
-
statusText: response.statusText,
|
|
780
|
-
headers: new Headers(response.headers),
|
|
781
|
-
});
|
|
782
|
-
}
|
|
783
320
|
}
|