@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.
- 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-BbqMCVXa.js → walkers-VOXgavMF.js} +61 -85
- 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/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/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-BbqMCVXa.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/dist/server/internal.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as isDevMode, t as isDebug } from "../_chunks/debug-ECi_61pb.js";
|
|
2
2
|
import { a as warnRedirectInSuspense, c as warnSuspenseWrappingChildren, i as warnRedirectInAccess, n as setViteServer, o as warnSlowSlotWithoutSuspense, r as warnDenyInSuspense, s as warnStaticRequestApi, t as WarningId } from "../_chunks/dev-warnings-DpGRGoDi.js";
|
|
3
|
-
import {
|
|
4
|
-
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-
|
|
3
|
+
import { n as classifyUrlSegment } from "../_chunks/segment-classify-BjfuctV2.js";
|
|
4
|
+
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-BU684ls2.js";
|
|
5
5
|
import { a as timingAls, r as requestContextAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-HS0LGUl2.js";
|
|
6
6
|
import { f as runWithRequestContext, l as getSetCookieHeaders, m as setSegmentParams, p as setMutableCookieContext, t as applyRequestHeaderOverlay, u as markResponseFlushed } from "../_chunks/request-context-CK5tZqIP.js";
|
|
7
7
|
import { l as RenderError, n as executeAction, o as DenySignal, r as isRscActionRequest, s as RedirectSignal, t as buildNoJsResponse } from "../_chunks/actions-DLnUaR65.js";
|
|
@@ -10,120 +10,6 @@ import "../client/error-boundary.js";
|
|
|
10
10
|
import "../_chunks/segment-context-fHFLF1PE.js";
|
|
11
11
|
import { readFile } from "node:fs/promises";
|
|
12
12
|
import { createElement } from "react";
|
|
13
|
-
//#region src/server/canonicalize.ts
|
|
14
|
-
/**
|
|
15
|
-
* Encoded separators that produce a 400 rejection.
|
|
16
|
-
* %2f (/) and %5c (\) cause path-confusion attacks.
|
|
17
|
-
*/
|
|
18
|
-
var ENCODED_SEPARATOR_RE = /%2f|%5c/i;
|
|
19
|
-
/** Null byte — rejected. */
|
|
20
|
-
var NULL_BYTE_RE = /%00/i;
|
|
21
|
-
/**
|
|
22
|
-
* Canonicalize a URL pathname.
|
|
23
|
-
*
|
|
24
|
-
* 1. Reject encoded separators (%2f, %5c) and null bytes (%00)
|
|
25
|
-
* 2. Single percent-decode
|
|
26
|
-
* 3. Collapse // → /
|
|
27
|
-
* 4. Resolve .. segments (reject if escaping root)
|
|
28
|
-
* 5. Strip trailing slash (except root "/")
|
|
29
|
-
*
|
|
30
|
-
* @param rawPathname - The raw pathname from the request URL (percent-encoded)
|
|
31
|
-
* @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.
|
|
32
|
-
*/
|
|
33
|
-
function canonicalize(rawPathname, stripTrailingSlash = true) {
|
|
34
|
-
if (ENCODED_SEPARATOR_RE.test(rawPathname)) return {
|
|
35
|
-
ok: false,
|
|
36
|
-
status: 400
|
|
37
|
-
};
|
|
38
|
-
if (NULL_BYTE_RE.test(rawPathname)) return {
|
|
39
|
-
ok: false,
|
|
40
|
-
status: 400
|
|
41
|
-
};
|
|
42
|
-
let decoded;
|
|
43
|
-
try {
|
|
44
|
-
decoded = decodeURIComponent(rawPathname);
|
|
45
|
-
} catch {
|
|
46
|
-
return {
|
|
47
|
-
ok: false,
|
|
48
|
-
status: 400
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
if (decoded.includes("\0")) return {
|
|
52
|
-
ok: false,
|
|
53
|
-
status: 400
|
|
54
|
-
};
|
|
55
|
-
let pathname = decoded.replace(/\/\/+/g, "/");
|
|
56
|
-
const segments = pathname.split("/");
|
|
57
|
-
const resolved = [];
|
|
58
|
-
for (const seg of segments) if (seg === "..") {
|
|
59
|
-
if (resolved.length <= 1) return {
|
|
60
|
-
ok: false,
|
|
61
|
-
status: 400
|
|
62
|
-
};
|
|
63
|
-
resolved.pop();
|
|
64
|
-
} else if (seg !== ".") resolved.push(seg);
|
|
65
|
-
pathname = resolved.join("/") || "/";
|
|
66
|
-
if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) pathname = pathname.slice(0, -1);
|
|
67
|
-
return {
|
|
68
|
-
ok: true,
|
|
69
|
-
pathname
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
//#endregion
|
|
73
|
-
//#region src/server/proxy.ts
|
|
74
|
-
/**
|
|
75
|
-
* Run the proxy pipeline.
|
|
76
|
-
*
|
|
77
|
-
* @param proxyExport - The default export from proxy.ts (function or array)
|
|
78
|
-
* @param req - The incoming request
|
|
79
|
-
* @param next - The continuation that proceeds to route matching and rendering
|
|
80
|
-
* @returns The final response
|
|
81
|
-
*/
|
|
82
|
-
async function runProxy(proxyExport, req, next) {
|
|
83
|
-
const fns = Array.isArray(proxyExport) ? proxyExport : [proxyExport];
|
|
84
|
-
let i = fns.length;
|
|
85
|
-
let composed = next;
|
|
86
|
-
while (i--) {
|
|
87
|
-
const fn = fns[i];
|
|
88
|
-
const downstream = composed;
|
|
89
|
-
composed = () => Promise.resolve(fn(req, downstream));
|
|
90
|
-
}
|
|
91
|
-
return composed();
|
|
92
|
-
}
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region src/server/middleware-runner.ts
|
|
95
|
-
/**
|
|
96
|
-
* Run a route's middleware function.
|
|
97
|
-
*
|
|
98
|
-
* @param middlewareFn - The default export from the route's middleware.ts
|
|
99
|
-
* @param ctx - The middleware context (req, params, headers, requestHeaders, searchParams)
|
|
100
|
-
* @returns A Response if middleware short-circuited, or undefined to continue
|
|
101
|
-
*/
|
|
102
|
-
async function runMiddleware(middlewareFn, ctx) {
|
|
103
|
-
const result = await middlewareFn(ctx);
|
|
104
|
-
if (result instanceof Response) return result;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Run all middleware functions in the segment chain, root to leaf.
|
|
108
|
-
*
|
|
109
|
-
* Execution is top-down: root middleware runs first, leaf middleware runs last.
|
|
110
|
-
* All middleware share the same MiddlewareContext — a parent that sets
|
|
111
|
-
* ctx.requestHeaders makes it visible to child middleware and downstream components.
|
|
112
|
-
*
|
|
113
|
-
* Short-circuits on the first middleware that returns a Response.
|
|
114
|
-
* Remaining middleware in the chain do not execute.
|
|
115
|
-
*
|
|
116
|
-
* @param chain - Middleware functions ordered root-to-leaf
|
|
117
|
-
* @param ctx - Shared middleware context
|
|
118
|
-
* @returns A Response if any middleware short-circuited, or undefined to continue
|
|
119
|
-
*/
|
|
120
|
-
async function runMiddlewareChain(chain, ctx) {
|
|
121
|
-
for (const fn of chain) {
|
|
122
|
-
const result = await fn(ctx);
|
|
123
|
-
if (result instanceof Response) return result;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
//#endregion
|
|
127
13
|
//#region src/server/server-timing.ts
|
|
128
14
|
/**
|
|
129
15
|
* Server-Timing header — dev-mode timing breakdowns for Chrome DevTools.
|
|
@@ -532,6 +418,250 @@ function hasOnRequestError() {
|
|
|
532
418
|
return _onRequestError !== null;
|
|
533
419
|
}
|
|
534
420
|
//#endregion
|
|
421
|
+
//#region src/server/pipeline-helpers.ts
|
|
422
|
+
/** Keys that must never be merged via Object.assign — they pollute Object.prototype. */
|
|
423
|
+
var DANGEROUS_KEYS = new Set([
|
|
424
|
+
"__proto__",
|
|
425
|
+
"constructor",
|
|
426
|
+
"prototype"
|
|
427
|
+
]);
|
|
428
|
+
/**
|
|
429
|
+
* Shallow merge that skips top-level prototype-polluting keys.
|
|
430
|
+
*
|
|
431
|
+
* This is intentionally NOT a deep sanitizer. It only blocks shallow
|
|
432
|
+
* pollution via top-level `__proto__` / `constructor` / `prototype`
|
|
433
|
+
* keys. The deeper guarantee for segment params comes from merging
|
|
434
|
+
* codec output into a null-prototype target inside coerceSegmentParams().
|
|
435
|
+
*
|
|
436
|
+
* See TIM-655, TIM-855, design/13-security.md
|
|
437
|
+
*/
|
|
438
|
+
function safeMerge(target, source) {
|
|
439
|
+
for (const key of Object.keys(source)) if (!DANGEROUS_KEYS.has(key)) target[key] = source[key];
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Build a proxy resolver closure from the declared source. Called exactly
|
|
443
|
+
* once at `createPipeline` setup time, so the hot path sees only the branch
|
|
444
|
+
* that corresponds to this pipeline's configured variant.
|
|
445
|
+
*
|
|
446
|
+
* Returns `null` when the app has no proxy.ts — the hot path short-circuits
|
|
447
|
+
* around `runProxyPhase` entirely in that case.
|
|
448
|
+
*
|
|
449
|
+
* Accepts the sugar form (a bare `ProxyExport` — function or function array)
|
|
450
|
+
* and normalises it to the static variant. Functions and arrays are
|
|
451
|
+
* structurally distinct from the tagged `{ kind: 'lazy', loader }` object,
|
|
452
|
+
* so discrimination is unambiguous.
|
|
453
|
+
*/
|
|
454
|
+
function makeProxyResolver(proxy) {
|
|
455
|
+
if (proxy === void 0) return null;
|
|
456
|
+
if (typeof proxy === "function" || Array.isArray(proxy)) {
|
|
457
|
+
const exp = proxy;
|
|
458
|
+
return () => exp;
|
|
459
|
+
}
|
|
460
|
+
if (proxy.kind === "static") {
|
|
461
|
+
const exp = proxy.export;
|
|
462
|
+
return () => exp;
|
|
463
|
+
}
|
|
464
|
+
const loader = proxy.loader;
|
|
465
|
+
return async () => (await loader()).default;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
469
|
+
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
470
|
+
*/
|
|
471
|
+
function applyCookieJar(headers) {
|
|
472
|
+
for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Merge framework-managed response headers onto a terminal response without
|
|
476
|
+
* overwriting headers the terminal response already set itself.
|
|
477
|
+
*/
|
|
478
|
+
function mergeMissingHeaders(target, source) {
|
|
479
|
+
const existingKeys = new Set([...target.keys()].map((key) => key.toLowerCase()));
|
|
480
|
+
for (const [key, value] of source.entries()) if (!existingKeys.has(key.toLowerCase())) target.append(key, value);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Clone a Response into a fresh one whose header bag is guaranteed mutable.
|
|
484
|
+
*
|
|
485
|
+
* `Response.redirect()` and some platform-level passthrough responses (notably
|
|
486
|
+
* on Cloudflare Workers) return objects with frozen header bags. Calling
|
|
487
|
+
* `.set()` or `.append()` on them throws `TypeError: immutable`, which the
|
|
488
|
+
* pipeline can hit when it appends Set-Cookie or Server-Timing entries.
|
|
489
|
+
*
|
|
490
|
+
* The pipeline calls this at the producer sites where user-controlled
|
|
491
|
+
* responses enter the framework — `outcomeToResponse` for all phase outcomes,
|
|
492
|
+
* and `handleRequest` for metadata-route and auto-sitemap user handlers — so
|
|
493
|
+
* downstream code can write headers without runtime feature-detection.
|
|
494
|
+
*
|
|
495
|
+
* The clone is unconditional. This is a deliberate trade: we avoid a
|
|
496
|
+
* try/catch + thrown `TypeError` on every request (the previous probe-based
|
|
497
|
+
* approach paid that cost on the hot path) and accept one cheap Response
|
|
498
|
+
* rewrap at the framework boundary instead.
|
|
499
|
+
*/
|
|
500
|
+
function cloneWithMutableHeaders(response) {
|
|
501
|
+
return new Response(response.body, {
|
|
502
|
+
status: response.status,
|
|
503
|
+
statusText: response.statusText,
|
|
504
|
+
headers: new Headers(response.headers)
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Build a redirect Response from a RedirectSignal.
|
|
509
|
+
*
|
|
510
|
+
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
511
|
+
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
512
|
+
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
513
|
+
* createFromFetch. See design/19-client-navigation.md.
|
|
514
|
+
*/
|
|
515
|
+
function buildRedirectResponse(signal, req, headers) {
|
|
516
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
517
|
+
headers.set("X-Timber-Redirect", signal.location);
|
|
518
|
+
return new Response(null, {
|
|
519
|
+
status: 204,
|
|
520
|
+
headers
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
headers.set("Location", signal.location);
|
|
524
|
+
return new Response(null, {
|
|
525
|
+
status: signal.status,
|
|
526
|
+
headers
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Fire the user's onRequestError hook with request context.
|
|
531
|
+
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
532
|
+
*/
|
|
533
|
+
async function fireOnRequestError(error, req, phase) {
|
|
534
|
+
const url = new URL(req.url);
|
|
535
|
+
const headersObj = {};
|
|
536
|
+
req.headers.forEach((v, k) => {
|
|
537
|
+
headersObj[k] = v;
|
|
538
|
+
});
|
|
539
|
+
await callOnRequestError(error, {
|
|
540
|
+
method: req.method,
|
|
541
|
+
path: url.pathname,
|
|
542
|
+
headers: headersObj
|
|
543
|
+
}, {
|
|
544
|
+
phase,
|
|
545
|
+
routePath: url.pathname,
|
|
546
|
+
routeType: "page",
|
|
547
|
+
traceId: getTraceId()
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region src/server/canonicalize.ts
|
|
552
|
+
/**
|
|
553
|
+
* Encoded separators that produce a 400 rejection.
|
|
554
|
+
* %2f (/) and %5c (\) cause path-confusion attacks.
|
|
555
|
+
*/
|
|
556
|
+
var ENCODED_SEPARATOR_RE = /%2f|%5c/i;
|
|
557
|
+
/** Null byte — rejected. */
|
|
558
|
+
var NULL_BYTE_RE = /%00/i;
|
|
559
|
+
/**
|
|
560
|
+
* Canonicalize a URL pathname.
|
|
561
|
+
*
|
|
562
|
+
* 1. Reject encoded separators (%2f, %5c) and null bytes (%00)
|
|
563
|
+
* 2. Single percent-decode
|
|
564
|
+
* 3. Collapse // → /
|
|
565
|
+
* 4. Resolve .. segments (reject if escaping root)
|
|
566
|
+
* 5. Strip trailing slash (except root "/")
|
|
567
|
+
*
|
|
568
|
+
* @param rawPathname - The raw pathname from the request URL (percent-encoded)
|
|
569
|
+
* @param stripTrailingSlash - Whether to strip trailing slashes. Default: true.
|
|
570
|
+
*/
|
|
571
|
+
function canonicalize(rawPathname, stripTrailingSlash = true) {
|
|
572
|
+
if (ENCODED_SEPARATOR_RE.test(rawPathname)) return {
|
|
573
|
+
ok: false,
|
|
574
|
+
status: 400
|
|
575
|
+
};
|
|
576
|
+
if (NULL_BYTE_RE.test(rawPathname)) return {
|
|
577
|
+
ok: false,
|
|
578
|
+
status: 400
|
|
579
|
+
};
|
|
580
|
+
let decoded;
|
|
581
|
+
try {
|
|
582
|
+
decoded = decodeURIComponent(rawPathname);
|
|
583
|
+
} catch {
|
|
584
|
+
return {
|
|
585
|
+
ok: false,
|
|
586
|
+
status: 400
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
if (decoded.includes("\0")) return {
|
|
590
|
+
ok: false,
|
|
591
|
+
status: 400
|
|
592
|
+
};
|
|
593
|
+
let pathname = decoded.replace(/\/\/+/g, "/");
|
|
594
|
+
const segments = pathname.split("/");
|
|
595
|
+
const resolved = [];
|
|
596
|
+
for (const seg of segments) if (seg === "..") {
|
|
597
|
+
if (resolved.length <= 1) return {
|
|
598
|
+
ok: false,
|
|
599
|
+
status: 400
|
|
600
|
+
};
|
|
601
|
+
resolved.pop();
|
|
602
|
+
} else if (seg !== ".") resolved.push(seg);
|
|
603
|
+
pathname = resolved.join("/") || "/";
|
|
604
|
+
if (stripTrailingSlash && pathname.length > 1 && pathname.endsWith("/")) pathname = pathname.slice(0, -1);
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
pathname
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/server/proxy.ts
|
|
612
|
+
/**
|
|
613
|
+
* Run the proxy pipeline.
|
|
614
|
+
*
|
|
615
|
+
* @param proxyExport - The default export from proxy.ts (function or array)
|
|
616
|
+
* @param req - The incoming request
|
|
617
|
+
* @param next - The continuation that proceeds to route matching and rendering
|
|
618
|
+
* @returns The final response
|
|
619
|
+
*/
|
|
620
|
+
async function runProxy(proxyExport, req, next) {
|
|
621
|
+
const fns = Array.isArray(proxyExport) ? proxyExport : [proxyExport];
|
|
622
|
+
let i = fns.length;
|
|
623
|
+
let composed = next;
|
|
624
|
+
while (i--) {
|
|
625
|
+
const fn = fns[i];
|
|
626
|
+
const downstream = composed;
|
|
627
|
+
composed = () => Promise.resolve(fn(req, downstream));
|
|
628
|
+
}
|
|
629
|
+
return composed();
|
|
630
|
+
}
|
|
631
|
+
//#endregion
|
|
632
|
+
//#region src/server/middleware-runner.ts
|
|
633
|
+
/**
|
|
634
|
+
* Run a route's middleware function.
|
|
635
|
+
*
|
|
636
|
+
* @param middlewareFn - The default export from the route's middleware.ts
|
|
637
|
+
* @param ctx - The middleware context (req, params, headers, requestHeaders, searchParams)
|
|
638
|
+
* @returns A Response if middleware short-circuited, or undefined to continue
|
|
639
|
+
*/
|
|
640
|
+
async function runMiddleware(middlewareFn, ctx) {
|
|
641
|
+
const result = await middlewareFn(ctx);
|
|
642
|
+
if (result instanceof Response) return result;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Run all middleware functions in the segment chain, root to leaf.
|
|
646
|
+
*
|
|
647
|
+
* Execution is top-down: root middleware runs first, leaf middleware runs last.
|
|
648
|
+
* All middleware share the same MiddlewareContext — a parent that sets
|
|
649
|
+
* ctx.requestHeaders makes it visible to child middleware and downstream components.
|
|
650
|
+
*
|
|
651
|
+
* Short-circuits on the first middleware that returns a Response.
|
|
652
|
+
* Remaining middleware in the chain do not execute.
|
|
653
|
+
*
|
|
654
|
+
* @param chain - Middleware functions ordered root-to-leaf
|
|
655
|
+
* @param ctx - Shared middleware context
|
|
656
|
+
* @returns A Response if any middleware short-circuited, or undefined to continue
|
|
657
|
+
*/
|
|
658
|
+
async function runMiddlewareChain(chain, ctx) {
|
|
659
|
+
for (const fn of chain) {
|
|
660
|
+
const result = await fn(ctx);
|
|
661
|
+
if (result instanceof Response) return result;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
//#endregion
|
|
535
665
|
//#region src/server/metadata-social.ts
|
|
536
666
|
/**
|
|
537
667
|
* Render Open Graph metadata into head element descriptors.
|
|
@@ -1616,78 +1746,383 @@ function pathnameMatchesPattern(pathname, pattern) {
|
|
|
1616
1746
|
continue;
|
|
1617
1747
|
}
|
|
1618
1748
|
}
|
|
1619
|
-
return pi === pathParts.length;
|
|
1620
|
-
}
|
|
1621
|
-
//#endregion
|
|
1622
|
-
//#region src/server/pipeline.ts
|
|
1623
|
-
/**
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
*
|
|
1634
|
-
*/
|
|
1635
|
-
/**
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1749
|
+
return pi === pathParts.length;
|
|
1750
|
+
}
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region src/server/pipeline-phases.ts
|
|
1753
|
+
/**
|
|
1754
|
+
* Pipeline phase functions — module-level free functions that take their
|
|
1755
|
+
* dependencies as explicit parameters. Each phase returns a `PhaseOutcome`
|
|
1756
|
+
* (a discriminated union over response / redirect / deny / error). The
|
|
1757
|
+
* terminal `outcomeToResponse` translates outcomes into Responses.
|
|
1758
|
+
*
|
|
1759
|
+
* Lifted out of `createPipeline` so each phase can be unit-tested in
|
|
1760
|
+
* isolation. The lift is mechanical — these functions used to be closures
|
|
1761
|
+
* over `config`; they now take `config` as an explicit parameter.
|
|
1762
|
+
*
|
|
1763
|
+
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
|
|
1764
|
+
*/
|
|
1765
|
+
/**
|
|
1766
|
+
* Run segment param coercion on the matched route's segments.
|
|
1767
|
+
*
|
|
1768
|
+
* Loads params.ts modules from segments that have them, extracts the
|
|
1769
|
+
* segmentParams definition, and coerces raw string params through codecs.
|
|
1770
|
+
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
1771
|
+
*
|
|
1772
|
+
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
1773
|
+
* See design/07-routing.md §"Where Coercion Runs"
|
|
1774
|
+
*/
|
|
1775
|
+
async function coerceSegmentParams(match) {
|
|
1776
|
+
const segments = match.segments;
|
|
1777
|
+
let mergeTarget = match.segmentParams;
|
|
1778
|
+
let usesNullPrototypeTarget = Object.getPrototypeOf(mergeTarget) === null;
|
|
1779
|
+
for (const segment of segments) {
|
|
1780
|
+
if (!segment.params) continue;
|
|
1781
|
+
let mod;
|
|
1782
|
+
try {
|
|
1783
|
+
mod = await loadModule(segment.params);
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
throw new ParamCoercionError(`Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`);
|
|
1786
|
+
}
|
|
1787
|
+
const segmentParamsDef = mod.segmentParams;
|
|
1788
|
+
if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
|
|
1789
|
+
try {
|
|
1790
|
+
const coerced = segmentParamsDef.parse(match.segmentParams);
|
|
1791
|
+
if (!usesNullPrototypeTarget) {
|
|
1792
|
+
mergeTarget = Object.create(null);
|
|
1793
|
+
safeMerge(mergeTarget, match.segmentParams);
|
|
1794
|
+
match.segmentParams = mergeTarget;
|
|
1795
|
+
usesNullPrototypeTarget = true;
|
|
1796
|
+
}
|
|
1797
|
+
safeMerge(mergeTarget, coerced);
|
|
1798
|
+
} catch (err) {
|
|
1799
|
+
throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Run the proxy.ts phase. Calls user proxy code and uses `handleRequest` as
|
|
1805
|
+
* the inner `next()` continuation. The proxy resolver was picked at pipeline
|
|
1806
|
+
* construction time so the hot path sees no per-request branching on the
|
|
1807
|
+
* `ProxyConfig` discriminant.
|
|
1808
|
+
*/
|
|
1809
|
+
async function runProxyPhase(config, getProxy, req, method, path) {
|
|
1810
|
+
const detailed = config.serverTiming === "detailed";
|
|
1811
|
+
try {
|
|
1812
|
+
const proxyExport = await getProxy();
|
|
1813
|
+
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(config, req, method, path));
|
|
1814
|
+
return {
|
|
1815
|
+
kind: "response",
|
|
1816
|
+
phase: "proxy",
|
|
1817
|
+
response: await withSpan("timber.proxy", {}, () => detailed ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn())
|
|
1818
|
+
};
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
return {
|
|
1821
|
+
kind: "error",
|
|
1822
|
+
phase: "proxy",
|
|
1823
|
+
error
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Run the middleware chain phase. If the chain short-circuits with a Response,
|
|
1829
|
+
* returns it as a 'response' outcome. Otherwise applies the request header
|
|
1830
|
+
* overlay and falls through to the render phase.
|
|
1831
|
+
*/
|
|
1832
|
+
async function runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext) {
|
|
1833
|
+
const detailed = config.serverTiming === "detailed";
|
|
1834
|
+
const ctx = {
|
|
1835
|
+
req,
|
|
1836
|
+
requestHeaders: requestHeaderOverlay,
|
|
1837
|
+
headers: responseHeaders,
|
|
1838
|
+
segmentParams: match.segmentParams,
|
|
1839
|
+
earlyHints: (hints) => {
|
|
1840
|
+
for (const hint of hints) {
|
|
1841
|
+
let value;
|
|
1842
|
+
if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
1843
|
+
else value = `<${hint.href}>; rel=${hint.rel}`;
|
|
1844
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1845
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1846
|
+
responseHeaders.append("Link", value);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
try {
|
|
1851
|
+
const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
|
|
1852
|
+
const middlewareResponse = await (async () => {
|
|
1853
|
+
setMutableCookieContext(true);
|
|
1854
|
+
try {
|
|
1855
|
+
return await withSpan("timber.middleware", {}, () => detailed ? withTiming("mw", "middleware.ts", chainFn) : chainFn());
|
|
1856
|
+
} finally {
|
|
1857
|
+
setMutableCookieContext(false);
|
|
1858
|
+
}
|
|
1859
|
+
})();
|
|
1860
|
+
if (middlewareResponse) return {
|
|
1861
|
+
kind: "response",
|
|
1862
|
+
phase: "middleware",
|
|
1863
|
+
response: middlewareResponse
|
|
1864
|
+
};
|
|
1865
|
+
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
1866
|
+
applyCookieJar(responseHeaders);
|
|
1867
|
+
return runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext);
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
if (error instanceof RedirectSignal) return {
|
|
1870
|
+
kind: "redirect",
|
|
1871
|
+
phase: "middleware",
|
|
1872
|
+
signal: error
|
|
1873
|
+
};
|
|
1874
|
+
if (error instanceof DenySignal) return {
|
|
1875
|
+
kind: "deny",
|
|
1876
|
+
phase: "middleware",
|
|
1877
|
+
signal: error
|
|
1878
|
+
};
|
|
1879
|
+
return {
|
|
1880
|
+
kind: "error",
|
|
1881
|
+
phase: "middleware",
|
|
1882
|
+
error
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Run the render phase. Wraps the configured renderer in a span and a
|
|
1888
|
+
* timing scope, and translates thrown signals into outcome variants.
|
|
1889
|
+
*/
|
|
1890
|
+
async function runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, { canonicalPathname, interception }) {
|
|
1891
|
+
const detailed = config.serverTiming === "detailed";
|
|
1892
|
+
try {
|
|
1893
|
+
const renderFn = () => config.render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
1894
|
+
return {
|
|
1895
|
+
kind: "response",
|
|
1896
|
+
phase: "render",
|
|
1897
|
+
response: await withSpan("timber.render", { "http.route": canonicalPathname }, () => detailed ? withTiming("render", "RSC + SSR render", renderFn) : renderFn())
|
|
1898
|
+
};
|
|
1899
|
+
} catch (error) {
|
|
1900
|
+
if (error instanceof DenySignal) return {
|
|
1901
|
+
kind: "deny",
|
|
1902
|
+
phase: "render",
|
|
1903
|
+
signal: error
|
|
1904
|
+
};
|
|
1905
|
+
if (error instanceof RedirectSignal) return {
|
|
1906
|
+
kind: "redirect",
|
|
1907
|
+
phase: "render",
|
|
1908
|
+
signal: error
|
|
1909
|
+
};
|
|
1910
|
+
return {
|
|
1911
|
+
kind: "error",
|
|
1912
|
+
phase: "render",
|
|
1913
|
+
error
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Process a single request from canonicalization through phase dispatch.
|
|
1919
|
+
*
|
|
1920
|
+
* Stages: canonicalize → metadata routes → auto-sitemap → version skew →
|
|
1921
|
+
* route match → interception → early hints → param coercion → middleware →
|
|
1922
|
+
* render → outcome translation. Pre-routing short-circuits return Responses
|
|
1923
|
+
* directly; post-match dispatch goes through `outcomeToResponse`.
|
|
1924
|
+
*
|
|
1925
|
+
* Used both as the top-level entry (when no proxy.ts is configured) and as
|
|
1926
|
+
* the `next()` continuation passed to `runProxy()`.
|
|
1927
|
+
*/
|
|
1928
|
+
async function handleRequest(config, req, method, path) {
|
|
1929
|
+
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
1930
|
+
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
1931
|
+
if (!result.ok) return new Response(null, { status: result.status });
|
|
1932
|
+
const canonicalPathname = result.pathname;
|
|
1933
|
+
if (config.matchMetadataRoute) {
|
|
1934
|
+
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
1935
|
+
if (metaMatch) try {
|
|
1936
|
+
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
1937
|
+
const mod = await loadModule(metaMatch.file);
|
|
1938
|
+
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
1939
|
+
const handlerResult = await mod.default();
|
|
1940
|
+
if (handlerResult instanceof Response) return cloneWithMutableHeaders(handlerResult);
|
|
1941
|
+
const contentType = metaMatch.contentType;
|
|
1942
|
+
let body;
|
|
1943
|
+
if (typeof handlerResult === "string") body = handlerResult;
|
|
1944
|
+
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
1945
|
+
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
1946
|
+
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
1947
|
+
return new Response(body, {
|
|
1948
|
+
status: 200,
|
|
1949
|
+
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
1950
|
+
});
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
logRenderError({
|
|
1953
|
+
method,
|
|
1954
|
+
path,
|
|
1955
|
+
error
|
|
1956
|
+
});
|
|
1957
|
+
if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "metadata-route");
|
|
1958
|
+
return new Response(null, { status: 500 });
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (config.autoSitemapHandler) try {
|
|
1962
|
+
const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
|
|
1963
|
+
if (sitemapResponse) return cloneWithMutableHeaders(sitemapResponse);
|
|
1964
|
+
} catch (error) {
|
|
1965
|
+
logRenderError({
|
|
1966
|
+
method,
|
|
1967
|
+
path,
|
|
1968
|
+
error
|
|
1969
|
+
});
|
|
1970
|
+
if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
|
|
1971
|
+
return new Response(null, { status: 500 });
|
|
1972
|
+
}
|
|
1973
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1974
|
+
if (!checkVersionSkew(req).ok) {
|
|
1975
|
+
const reloadHeaders = new Headers();
|
|
1976
|
+
applyReloadHeaders(reloadHeaders);
|
|
1977
|
+
return new Response(null, {
|
|
1978
|
+
status: 204,
|
|
1979
|
+
headers: reloadHeaders
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
let match = config.matchRoute(canonicalPathname);
|
|
1984
|
+
let interception;
|
|
1985
|
+
const sourceUrl = req.headers.get("X-Timber-URL");
|
|
1986
|
+
if (sourceUrl && config.interceptionRewrites?.length) {
|
|
1987
|
+
const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
|
|
1988
|
+
if (intercepted) {
|
|
1989
|
+
const sourceMatch = config.matchRoute(intercepted.sourcePathname);
|
|
1990
|
+
if (sourceMatch) {
|
|
1991
|
+
match = sourceMatch;
|
|
1992
|
+
interception = { targetPathname: canonicalPathname };
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
if (!match) {
|
|
1997
|
+
if (config.renderNoMatch) {
|
|
1998
|
+
const responseHeaders = new Headers();
|
|
1999
|
+
return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
|
|
2000
|
+
}
|
|
2001
|
+
return new Response(null, { status: 404 });
|
|
2002
|
+
}
|
|
2003
|
+
const responseHeaders = new Headers();
|
|
2004
|
+
const requestHeaderOverlay = new Headers();
|
|
2005
|
+
responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
|
|
2006
|
+
if (config.earlyHints) try {
|
|
2007
|
+
await config.earlyHints(match, req, responseHeaders);
|
|
2008
|
+
} catch {}
|
|
2009
|
+
try {
|
|
2010
|
+
await coerceSegmentParams(match);
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
if (error instanceof ParamCoercionError) {
|
|
2013
|
+
const leafSegment = match.segments[match.segments.length - 1];
|
|
2014
|
+
if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
|
|
2015
|
+
if (config.renderNoMatch) return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
|
|
2016
|
+
return new Response(null, { status: 404 });
|
|
2017
|
+
}
|
|
2018
|
+
throw error;
|
|
2019
|
+
}
|
|
2020
|
+
setSegmentParams(match.segmentParams);
|
|
2021
|
+
return outcomeToResponse(config, match.middlewareChain.length > 0 ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
|
|
2022
|
+
canonicalPathname,
|
|
2023
|
+
interception
|
|
2024
|
+
}) : await runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, {
|
|
2025
|
+
canonicalPathname,
|
|
2026
|
+
interception
|
|
2027
|
+
}), {
|
|
2028
|
+
req,
|
|
2029
|
+
method,
|
|
2030
|
+
path,
|
|
2031
|
+
responseHeaders,
|
|
2032
|
+
match
|
|
2033
|
+
});
|
|
1652
2034
|
}
|
|
1653
2035
|
/**
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1656
|
-
*
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
2036
|
+
* Terminal outcome handler — converts a `PhaseOutcome` into a final
|
|
2037
|
+
* `Response`, applying cookies, building redirects, rendering deny pages
|
|
2038
|
+
* and fallback error pages, and firing instrumentation hooks.
|
|
2039
|
+
*
|
|
2040
|
+
* This is the single source of truth for how phase outputs become wire
|
|
2041
|
+
* responses; the per-phase try/catch blocks now produce values, not
|
|
2042
|
+
* Responses, so the conversion logic lives in exactly one place.
|
|
2043
|
+
*/
|
|
2044
|
+
async function outcomeToResponse(config, outcome, ctx) {
|
|
2045
|
+
switch (outcome.kind) {
|
|
2046
|
+
case "response": {
|
|
2047
|
+
const finalResponse = cloneWithMutableHeaders(outcome.response);
|
|
2048
|
+
if (outcome.phase === "proxy") return finalResponse;
|
|
2049
|
+
if (outcome.phase === "middleware" && ctx.responseHeaders) {
|
|
2050
|
+
applyCookieJar(finalResponse.headers);
|
|
2051
|
+
mergeMissingHeaders(finalResponse.headers, ctx.responseHeaders);
|
|
2052
|
+
logMiddlewareShortCircuit({
|
|
2053
|
+
method: ctx.method,
|
|
2054
|
+
path: ctx.path,
|
|
2055
|
+
status: finalResponse.status
|
|
2056
|
+
});
|
|
2057
|
+
}
|
|
2058
|
+
if (outcome.phase === "render") markResponseFlushed();
|
|
2059
|
+
return finalResponse;
|
|
1672
2060
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
2061
|
+
case "redirect": {
|
|
2062
|
+
const headers = ctx.responseHeaders ?? new Headers();
|
|
2063
|
+
applyCookieJar(headers);
|
|
2064
|
+
return buildRedirectResponse(outcome.signal, ctx.req, headers);
|
|
2065
|
+
}
|
|
2066
|
+
case "deny": {
|
|
2067
|
+
const headers = ctx.responseHeaders ?? new Headers();
|
|
2068
|
+
applyCookieJar(headers);
|
|
2069
|
+
if (config.renderDenyFallback) try {
|
|
2070
|
+
return cloneWithMutableHeaders(await config.renderDenyFallback(outcome.signal, ctx.req, headers, ctx.match));
|
|
2071
|
+
} catch {}
|
|
2072
|
+
return new Response(null, {
|
|
2073
|
+
status: outcome.signal.status,
|
|
2074
|
+
headers
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
case "error": {
|
|
2078
|
+
if (outcome.phase === "proxy") {
|
|
2079
|
+
logProxyError({ error: outcome.error });
|
|
2080
|
+
await fireOnRequestError(outcome.error, ctx.req, "proxy");
|
|
2081
|
+
if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "proxy");
|
|
2082
|
+
return new Response(null, { status: 500 });
|
|
2083
|
+
}
|
|
2084
|
+
if (outcome.phase === "middleware") {
|
|
2085
|
+
logMiddlewareError({
|
|
2086
|
+
method: ctx.method,
|
|
2087
|
+
path: ctx.path,
|
|
2088
|
+
error: outcome.error
|
|
2089
|
+
});
|
|
2090
|
+
await fireOnRequestError(outcome.error, ctx.req, "handler");
|
|
2091
|
+
if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "middleware");
|
|
2092
|
+
return new Response(null, { status: 500 });
|
|
2093
|
+
}
|
|
2094
|
+
const headers = ctx.responseHeaders ?? new Headers();
|
|
2095
|
+
applyCookieJar(headers);
|
|
2096
|
+
logRenderError({
|
|
2097
|
+
method: ctx.method,
|
|
2098
|
+
path: ctx.path,
|
|
2099
|
+
error: outcome.error
|
|
2100
|
+
});
|
|
2101
|
+
await fireOnRequestError(outcome.error, ctx.req, "render");
|
|
2102
|
+
if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "render");
|
|
2103
|
+
if (config.renderFallbackError) try {
|
|
2104
|
+
return cloneWithMutableHeaders(await config.renderFallbackError(outcome.error, ctx.req, headers));
|
|
2105
|
+
} catch {}
|
|
2106
|
+
return new Response(null, { status: 500 });
|
|
1680
2107
|
}
|
|
1681
2108
|
}
|
|
1682
2109
|
}
|
|
2110
|
+
//#endregion
|
|
2111
|
+
//#region src/server/pipeline.ts
|
|
1683
2112
|
/**
|
|
1684
2113
|
* Create the request handler from a pipeline configuration.
|
|
1685
2114
|
*
|
|
1686
|
-
* Returns a function that processes an incoming Request through all pipeline
|
|
1687
|
-
* and produces a Response. This is the top-level entry point for the
|
|
2115
|
+
* Returns a function that processes an incoming Request through all pipeline
|
|
2116
|
+
* stages and produces a Response. This is the top-level entry point for the
|
|
2117
|
+
* server. The body is intentionally small — phase logic lives in
|
|
2118
|
+
* `pipeline-phases.ts`. This function only owns the per-request setup that
|
|
2119
|
+
* has to wrap the entire dispatch: trace ID, request context ALS, span
|
|
2120
|
+
* scope, Server-Timing header emission, and the active-request counter.
|
|
1688
2121
|
*/
|
|
1689
2122
|
function createPipeline(config) {
|
|
1690
|
-
const
|
|
2123
|
+
const proxyResolver = makeProxyResolver(config.proxy);
|
|
2124
|
+
const slowRequestMs = config.slowRequestMs ?? 3e3;
|
|
2125
|
+
const serverTiming = config.serverTiming ?? "total";
|
|
1691
2126
|
let activeRequests = 0;
|
|
1692
2127
|
return async (req) => {
|
|
1693
2128
|
const url = new URL(req.url);
|
|
@@ -1709,18 +2144,18 @@ function createPipeline(config) {
|
|
|
1709
2144
|
const otelIds = await getOtelTraceId();
|
|
1710
2145
|
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
1711
2146
|
let result;
|
|
1712
|
-
if (
|
|
1713
|
-
|
|
2147
|
+
if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, req, method, path), {
|
|
2148
|
+
req,
|
|
2149
|
+
method,
|
|
2150
|
+
path
|
|
2151
|
+
});
|
|
2152
|
+
else result = await handleRequest(config, req, method, path);
|
|
1714
2153
|
await setSpanAttribute("http.response.status_code", result.status);
|
|
1715
2154
|
if (serverTiming === "detailed") {
|
|
1716
2155
|
const timingHeader = getServerTimingHeader();
|
|
1717
|
-
if (timingHeader)
|
|
1718
|
-
result = ensureMutableResponse(result);
|
|
1719
|
-
result.headers.set("Server-Timing", timingHeader);
|
|
1720
|
-
}
|
|
2156
|
+
if (timingHeader) result.headers.set("Server-Timing", timingHeader);
|
|
1721
2157
|
} else if (serverTiming === "total") {
|
|
1722
2158
|
const totalMs = Math.round(performance.now() - startTime);
|
|
1723
|
-
result = ensureMutableResponse(result);
|
|
1724
2159
|
result.headers.set("Server-Timing", `total;dur=${totalMs}`);
|
|
1725
2160
|
}
|
|
1726
2161
|
return result;
|
|
@@ -1749,276 +2184,6 @@ function createPipeline(config) {
|
|
|
1749
2184
|
});
|
|
1750
2185
|
});
|
|
1751
2186
|
};
|
|
1752
|
-
async function runProxyPhase(req, method, path) {
|
|
1753
|
-
try {
|
|
1754
|
-
let proxyExport;
|
|
1755
|
-
if (config.proxyLoader) proxyExport = (await config.proxyLoader()).default;
|
|
1756
|
-
else proxyExport = config.proxy;
|
|
1757
|
-
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
1758
|
-
return await withSpan("timber.proxy", {}, () => serverTiming === "detailed" ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn());
|
|
1759
|
-
} catch (error) {
|
|
1760
|
-
logProxyError({ error });
|
|
1761
|
-
await fireOnRequestError(error, req, "proxy");
|
|
1762
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
|
|
1763
|
-
return new Response(null, { status: 500 });
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
/**
|
|
1767
|
-
* Build a redirect Response from a RedirectSignal.
|
|
1768
|
-
*
|
|
1769
|
-
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
1770
|
-
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
1771
|
-
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
1772
|
-
* createFromFetch. See design/19-client-navigation.md.
|
|
1773
|
-
*/
|
|
1774
|
-
function buildRedirectResponse(signal, req, headers) {
|
|
1775
|
-
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1776
|
-
headers.set("X-Timber-Redirect", signal.location);
|
|
1777
|
-
return new Response(null, {
|
|
1778
|
-
status: 204,
|
|
1779
|
-
headers
|
|
1780
|
-
});
|
|
1781
|
-
}
|
|
1782
|
-
headers.set("Location", signal.location);
|
|
1783
|
-
return new Response(null, {
|
|
1784
|
-
status: signal.status,
|
|
1785
|
-
headers
|
|
1786
|
-
});
|
|
1787
|
-
}
|
|
1788
|
-
async function handleRequest(req, method, path) {
|
|
1789
|
-
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
1790
|
-
if (!result.ok) return new Response(null, { status: result.status });
|
|
1791
|
-
const canonicalPathname = result.pathname;
|
|
1792
|
-
if (config.matchMetadataRoute) {
|
|
1793
|
-
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
1794
|
-
if (metaMatch) try {
|
|
1795
|
-
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
1796
|
-
const mod = await loadModule(metaMatch.file);
|
|
1797
|
-
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
1798
|
-
const handlerResult = await mod.default();
|
|
1799
|
-
if (handlerResult instanceof Response) return handlerResult;
|
|
1800
|
-
const contentType = metaMatch.contentType;
|
|
1801
|
-
let body;
|
|
1802
|
-
if (typeof handlerResult === "string") body = handlerResult;
|
|
1803
|
-
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
1804
|
-
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
1805
|
-
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
1806
|
-
return new Response(body, {
|
|
1807
|
-
status: 200,
|
|
1808
|
-
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
1809
|
-
});
|
|
1810
|
-
} catch (error) {
|
|
1811
|
-
logRenderError({
|
|
1812
|
-
method,
|
|
1813
|
-
path,
|
|
1814
|
-
error
|
|
1815
|
-
});
|
|
1816
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
|
|
1817
|
-
return new Response(null, { status: 500 });
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
if (config.autoSitemapHandler) try {
|
|
1821
|
-
const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
|
|
1822
|
-
if (sitemapResponse) return sitemapResponse;
|
|
1823
|
-
} catch (error) {
|
|
1824
|
-
logRenderError({
|
|
1825
|
-
method,
|
|
1826
|
-
path,
|
|
1827
|
-
error
|
|
1828
|
-
});
|
|
1829
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "auto-sitemap");
|
|
1830
|
-
return new Response(null, { status: 500 });
|
|
1831
|
-
}
|
|
1832
|
-
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1833
|
-
if (!checkVersionSkew(req).ok) {
|
|
1834
|
-
const reloadHeaders = new Headers();
|
|
1835
|
-
applyReloadHeaders(reloadHeaders);
|
|
1836
|
-
return new Response(null, {
|
|
1837
|
-
status: 204,
|
|
1838
|
-
headers: reloadHeaders
|
|
1839
|
-
});
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
let match = matchRoute(canonicalPathname);
|
|
1843
|
-
let interception;
|
|
1844
|
-
const sourceUrl = req.headers.get("X-Timber-URL");
|
|
1845
|
-
if (sourceUrl && config.interceptionRewrites?.length) {
|
|
1846
|
-
const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
|
|
1847
|
-
if (intercepted) {
|
|
1848
|
-
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
1849
|
-
if (sourceMatch) {
|
|
1850
|
-
match = sourceMatch;
|
|
1851
|
-
interception = { targetPathname: canonicalPathname };
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
}
|
|
1855
|
-
if (!match) {
|
|
1856
|
-
if (config.renderNoMatch) {
|
|
1857
|
-
const responseHeaders = new Headers();
|
|
1858
|
-
return config.renderNoMatch(req, responseHeaders);
|
|
1859
|
-
}
|
|
1860
|
-
return new Response(null, { status: 404 });
|
|
1861
|
-
}
|
|
1862
|
-
const responseHeaders = new Headers();
|
|
1863
|
-
const requestHeaderOverlay = new Headers();
|
|
1864
|
-
responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
|
|
1865
|
-
if (earlyHints) try {
|
|
1866
|
-
await earlyHints(match, req, responseHeaders);
|
|
1867
|
-
} catch {}
|
|
1868
|
-
try {
|
|
1869
|
-
await coerceSegmentParams(match);
|
|
1870
|
-
} catch (error) {
|
|
1871
|
-
if (error instanceof ParamCoercionError) {
|
|
1872
|
-
const leafSegment = match.segments[match.segments.length - 1];
|
|
1873
|
-
if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
|
|
1874
|
-
if (config.renderNoMatch) return config.renderNoMatch(req, responseHeaders);
|
|
1875
|
-
return new Response(null, { status: 404 });
|
|
1876
|
-
}
|
|
1877
|
-
throw error;
|
|
1878
|
-
}
|
|
1879
|
-
setSegmentParams(match.segmentParams);
|
|
1880
|
-
if (match.middlewareChain.length > 0) {
|
|
1881
|
-
const ctx = {
|
|
1882
|
-
req,
|
|
1883
|
-
requestHeaders: requestHeaderOverlay,
|
|
1884
|
-
headers: responseHeaders,
|
|
1885
|
-
segmentParams: match.segmentParams,
|
|
1886
|
-
earlyHints: (hints) => {
|
|
1887
|
-
for (const hint of hints) {
|
|
1888
|
-
let value;
|
|
1889
|
-
if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
1890
|
-
else value = `<${hint.href}>; rel=${hint.rel}`;
|
|
1891
|
-
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1892
|
-
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1893
|
-
responseHeaders.append("Link", value);
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
};
|
|
1897
|
-
try {
|
|
1898
|
-
setMutableCookieContext(true);
|
|
1899
|
-
const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
|
|
1900
|
-
const middlewareResponse = await withSpan("timber.middleware", {}, () => serverTiming === "detailed" ? withTiming("mw", "middleware.ts", chainFn) : chainFn());
|
|
1901
|
-
setMutableCookieContext(false);
|
|
1902
|
-
if (middlewareResponse) {
|
|
1903
|
-
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
1904
|
-
applyCookieJar(finalResponse.headers);
|
|
1905
|
-
const existingKeys = new Set([...finalResponse.headers.keys()].map((k) => k.toLowerCase()));
|
|
1906
|
-
for (const [key, value] of responseHeaders.entries()) if (!existingKeys.has(key.toLowerCase())) finalResponse.headers.append(key, value);
|
|
1907
|
-
logMiddlewareShortCircuit({
|
|
1908
|
-
method,
|
|
1909
|
-
path,
|
|
1910
|
-
status: finalResponse.status
|
|
1911
|
-
});
|
|
1912
|
-
return finalResponse;
|
|
1913
|
-
}
|
|
1914
|
-
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
1915
|
-
} catch (error) {
|
|
1916
|
-
setMutableCookieContext(false);
|
|
1917
|
-
if (error instanceof RedirectSignal) {
|
|
1918
|
-
applyCookieJar(responseHeaders);
|
|
1919
|
-
return buildRedirectResponse(error, req, responseHeaders);
|
|
1920
|
-
}
|
|
1921
|
-
if (error instanceof DenySignal) {
|
|
1922
|
-
applyCookieJar(responseHeaders);
|
|
1923
|
-
if (config.renderDenyFallback) try {
|
|
1924
|
-
return await config.renderDenyFallback(error, req, responseHeaders, match);
|
|
1925
|
-
} catch {}
|
|
1926
|
-
return new Response(null, {
|
|
1927
|
-
status: error.status,
|
|
1928
|
-
headers: responseHeaders
|
|
1929
|
-
});
|
|
1930
|
-
}
|
|
1931
|
-
logMiddlewareError({
|
|
1932
|
-
method,
|
|
1933
|
-
path,
|
|
1934
|
-
error
|
|
1935
|
-
});
|
|
1936
|
-
await fireOnRequestError(error, req, "handler");
|
|
1937
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
|
|
1938
|
-
return new Response(null, { status: 500 });
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
applyCookieJar(responseHeaders);
|
|
1942
|
-
try {
|
|
1943
|
-
const renderFn = () => render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
1944
|
-
const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => serverTiming === "detailed" ? withTiming("render", "RSC + SSR render", renderFn) : renderFn());
|
|
1945
|
-
markResponseFlushed();
|
|
1946
|
-
return response;
|
|
1947
|
-
} catch (error) {
|
|
1948
|
-
if (error instanceof DenySignal) {
|
|
1949
|
-
if (config.renderDenyFallback) try {
|
|
1950
|
-
return await config.renderDenyFallback(error, req, responseHeaders, match);
|
|
1951
|
-
} catch {}
|
|
1952
|
-
return new Response(null, {
|
|
1953
|
-
status: error.status,
|
|
1954
|
-
headers: responseHeaders
|
|
1955
|
-
});
|
|
1956
|
-
}
|
|
1957
|
-
if (error instanceof RedirectSignal) return buildRedirectResponse(error, req, responseHeaders);
|
|
1958
|
-
logRenderError({
|
|
1959
|
-
method,
|
|
1960
|
-
path,
|
|
1961
|
-
error
|
|
1962
|
-
});
|
|
1963
|
-
await fireOnRequestError(error, req, "render");
|
|
1964
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
|
|
1965
|
-
if (config.renderFallbackError) try {
|
|
1966
|
-
return await config.renderFallbackError(error, req, responseHeaders);
|
|
1967
|
-
} catch {}
|
|
1968
|
-
return new Response(null, { status: 500 });
|
|
1969
|
-
}
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
/**
|
|
1973
|
-
* Fire the user's onRequestError hook with request context.
|
|
1974
|
-
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
1975
|
-
*/
|
|
1976
|
-
async function fireOnRequestError(error, req, phase) {
|
|
1977
|
-
const url = new URL(req.url);
|
|
1978
|
-
const headersObj = {};
|
|
1979
|
-
req.headers.forEach((v, k) => {
|
|
1980
|
-
headersObj[k] = v;
|
|
1981
|
-
});
|
|
1982
|
-
await callOnRequestError(error, {
|
|
1983
|
-
method: req.method,
|
|
1984
|
-
path: url.pathname,
|
|
1985
|
-
headers: headersObj
|
|
1986
|
-
}, {
|
|
1987
|
-
phase,
|
|
1988
|
-
routePath: url.pathname,
|
|
1989
|
-
routeType: "page",
|
|
1990
|
-
traceId: getTraceId()
|
|
1991
|
-
});
|
|
1992
|
-
}
|
|
1993
|
-
/**
|
|
1994
|
-
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
1995
|
-
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
1996
|
-
*/
|
|
1997
|
-
function applyCookieJar(headers) {
|
|
1998
|
-
for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
|
|
1999
|
-
}
|
|
2000
|
-
/**
|
|
2001
|
-
* Ensure a Response has mutable headers so the pipeline can safely append
|
|
2002
|
-
* Set-Cookie and Server-Timing entries.
|
|
2003
|
-
*
|
|
2004
|
-
* `Response.redirect()` and some platform-level responses return objects
|
|
2005
|
-
* with immutable headers. Calling `.set()` or `.append()` on them throws
|
|
2006
|
-
* `TypeError: immutable`. This helper detects the immutable case by
|
|
2007
|
-
* attempting a no-op write and, on failure, clones into a fresh Response
|
|
2008
|
-
* with mutable headers.
|
|
2009
|
-
*/
|
|
2010
|
-
function ensureMutableResponse(response) {
|
|
2011
|
-
try {
|
|
2012
|
-
response.headers.set("X-Timber-Probe", "1");
|
|
2013
|
-
response.headers.delete("X-Timber-Probe");
|
|
2014
|
-
return response;
|
|
2015
|
-
} catch {
|
|
2016
|
-
return new Response(response.body, {
|
|
2017
|
-
status: response.status,
|
|
2018
|
-
statusText: response.statusText,
|
|
2019
|
-
headers: new Headers(response.headers)
|
|
2020
|
-
});
|
|
2021
|
-
}
|
|
2022
2187
|
}
|
|
2023
2188
|
//#endregion
|
|
2024
2189
|
//#region src/server/build-manifest.ts
|
|
@@ -2263,7 +2428,11 @@ async function buildElementTree(config) {
|
|
|
2263
2428
|
const LayoutComponent = (await loadModule(segment.layout)).default;
|
|
2264
2429
|
if (LayoutComponent) {
|
|
2265
2430
|
const slotProps = {};
|
|
2266
|
-
|
|
2431
|
+
const slotNames = Object.keys(segment.slots);
|
|
2432
|
+
if (slotNames.length > 0) for (const slotName of slotNames) {
|
|
2433
|
+
const slotNode = segment.slots[slotName];
|
|
2434
|
+
slotProps[slotName] = await buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent);
|
|
2435
|
+
}
|
|
2267
2436
|
element = createElement(LayoutComponent, {
|
|
2268
2437
|
...slotProps,
|
|
2269
2438
|
children: element
|
|
@@ -2332,7 +2501,7 @@ function isMdxFile(file) {
|
|
|
2332
2501
|
*/
|
|
2333
2502
|
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
|
|
2334
2503
|
if (segment.statusFiles) {
|
|
2335
|
-
for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
|
|
2504
|
+
for (const [key, file] of Object.entries(segment.statusFiles)) if (key !== "4xx" && key !== "5xx") {
|
|
2336
2505
|
const status = parseInt(key, 10);
|
|
2337
2506
|
if (!isNaN(status)) {
|
|
2338
2507
|
const Component = (await loadModule(file)).default;
|
|
@@ -2347,7 +2516,7 @@ async function wrapWithErrorBoundaries(segment, element, loadModule, createEleme
|
|
|
2347
2516
|
});
|
|
2348
2517
|
}
|
|
2349
2518
|
}
|
|
2350
|
-
for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
|
|
2519
|
+
for (const [key, file] of Object.entries(segment.statusFiles)) if (key === "4xx" || key === "5xx") {
|
|
2351
2520
|
const Component = (await loadModule(file)).default;
|
|
2352
2521
|
if (Component) {
|
|
2353
2522
|
const categoryStatus = key === "4xx" ? 400 : 500;
|
|
@@ -2377,15 +2546,54 @@ async function wrapWithErrorBoundaries(segment, element, loadModule, createEleme
|
|
|
2377
2546
|
}
|
|
2378
2547
|
//#endregion
|
|
2379
2548
|
//#region src/server/status-code-resolver.ts
|
|
2380
|
-
/**
|
|
2381
|
-
|
|
2382
|
-
* Only used in the 4xx component fallback chain.
|
|
2383
|
-
*/
|
|
2384
|
-
var LEGACY_FILE_TO_STATUS = {
|
|
2549
|
+
/** Reverse index: status code → legacy file name. Built once at module load. */
|
|
2550
|
+
var STATUS_TO_LEGACY_FILE = Object.fromEntries(Object.entries({
|
|
2385
2551
|
"not-found": 404,
|
|
2386
2552
|
"forbidden": 403,
|
|
2387
2553
|
"unauthorized": 401
|
|
2388
|
-
};
|
|
2554
|
+
}).map(([name, status]) => [status, name]));
|
|
2555
|
+
/**
|
|
2556
|
+
* Look up `{statusStr}` then `{categoryKey}` (e.g. "4xx" / "5xx") in a
|
|
2557
|
+
* status-file group on a single segment. Shared by all three fallback
|
|
2558
|
+
* chains — the only structural difference between component 4xx,
|
|
2559
|
+
* component 5xx, and JSON resolution is *which* group is searched and
|
|
2560
|
+
* how the per-segment loop is layered around it.
|
|
2561
|
+
*/
|
|
2562
|
+
function lookupInGroup(group, statusStr, categoryKey, segmentIndex, status) {
|
|
2563
|
+
if (!group) return null;
|
|
2564
|
+
const exact = group[statusStr];
|
|
2565
|
+
if (exact) return {
|
|
2566
|
+
file: exact,
|
|
2567
|
+
status,
|
|
2568
|
+
kind: "exact",
|
|
2569
|
+
segmentIndex
|
|
2570
|
+
};
|
|
2571
|
+
const category = group[categoryKey];
|
|
2572
|
+
if (category) return {
|
|
2573
|
+
file: category,
|
|
2574
|
+
status,
|
|
2575
|
+
kind: "category",
|
|
2576
|
+
segmentIndex
|
|
2577
|
+
};
|
|
2578
|
+
return null;
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Look up the legacy convention file (`not-found.tsx` / `forbidden.tsx` /
|
|
2582
|
+
* `unauthorized.tsx`) for `status` on a single segment. Returns null if
|
|
2583
|
+
* `status` has no legacy mapping or the file isn't present.
|
|
2584
|
+
*/
|
|
2585
|
+
function lookupLegacy(group, status, segmentIndex) {
|
|
2586
|
+
if (!group) return null;
|
|
2587
|
+
const name = STATUS_TO_LEGACY_FILE[status];
|
|
2588
|
+
if (!name) return null;
|
|
2589
|
+
const file = group[name];
|
|
2590
|
+
return file ? {
|
|
2591
|
+
file,
|
|
2592
|
+
status,
|
|
2593
|
+
kind: "legacy",
|
|
2594
|
+
segmentIndex
|
|
2595
|
+
} : null;
|
|
2596
|
+
}
|
|
2389
2597
|
/**
|
|
2390
2598
|
* Resolve the status-code file to render for a given HTTP status code.
|
|
2391
2599
|
*
|
|
@@ -2398,108 +2606,58 @@ var LEGACY_FILE_TO_STATUS = {
|
|
|
2398
2606
|
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
2399
2607
|
*/
|
|
2400
2608
|
function resolveStatusFile(status, segments, format = "component") {
|
|
2401
|
-
if (status
|
|
2402
|
-
if (
|
|
2403
|
-
return
|
|
2609
|
+
if (status < 400 || status > 599) return null;
|
|
2610
|
+
if (format === "json") return resolveJson(status, segments);
|
|
2611
|
+
if (status <= 499) return resolve4xx(status, segments);
|
|
2612
|
+
return resolve5xx(status, segments);
|
|
2404
2613
|
}
|
|
2405
2614
|
/**
|
|
2406
|
-
* 4xx component fallback chain
|
|
2407
|
-
*
|
|
2408
|
-
*
|
|
2409
|
-
*
|
|
2615
|
+
* 4xx component fallback chain — three separate full passes leaf→root.
|
|
2616
|
+
*
|
|
2617
|
+
* The passes must be separate (not interleaved per-segment) so that a
|
|
2618
|
+
* root-level `404.tsx` beats a leaf-level `error.tsx`. The 5xx chain
|
|
2619
|
+
* inverts this and is per-segment: a leaf's `error.tsx` beats a root's
|
|
2620
|
+
* `5xx.tsx`. This asymmetry is the only reason these two functions exist
|
|
2621
|
+
* separately.
|
|
2622
|
+
*
|
|
2623
|
+
* Pass 1 — {status}.tsx → 4xx.tsx (statusFiles)
|
|
2624
|
+
* Pass 2 — not-found / forbidden / unauthorized (legacyStatusFiles)
|
|
2625
|
+
* Pass 3 — error.tsx (error)
|
|
2410
2626
|
*/
|
|
2411
2627
|
function resolve4xx(status, segments) {
|
|
2412
2628
|
const statusStr = String(status);
|
|
2413
2629
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2414
|
-
const
|
|
2415
|
-
if (
|
|
2416
|
-
const exact = segment.statusFiles.get(statusStr);
|
|
2417
|
-
if (exact) return {
|
|
2418
|
-
file: exact,
|
|
2419
|
-
status,
|
|
2420
|
-
kind: "exact",
|
|
2421
|
-
segmentIndex: i
|
|
2422
|
-
};
|
|
2423
|
-
const category = segment.statusFiles.get("4xx");
|
|
2424
|
-
if (category) return {
|
|
2425
|
-
file: category,
|
|
2426
|
-
status,
|
|
2427
|
-
kind: "category",
|
|
2428
|
-
segmentIndex: i
|
|
2429
|
-
};
|
|
2630
|
+
const r = lookupInGroup(segments[i].statusFiles, statusStr, "4xx", i, status);
|
|
2631
|
+
if (r) return r;
|
|
2430
2632
|
}
|
|
2431
2633
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2432
|
-
const
|
|
2433
|
-
if (
|
|
2434
|
-
for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
|
|
2435
|
-
const file = segment.legacyStatusFiles.get(name);
|
|
2436
|
-
if (file) return {
|
|
2437
|
-
file,
|
|
2438
|
-
status,
|
|
2439
|
-
kind: "legacy",
|
|
2440
|
-
segmentIndex: i
|
|
2441
|
-
};
|
|
2442
|
-
}
|
|
2634
|
+
const r = lookupLegacy(segments[i].legacyStatusFiles, status, i);
|
|
2635
|
+
if (r) return r;
|
|
2443
2636
|
}
|
|
2444
|
-
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
|
|
2445
|
-
file: segments[i].error,
|
|
2446
|
-
status,
|
|
2447
|
-
kind: "error",
|
|
2448
|
-
segmentIndex: i
|
|
2449
|
-
};
|
|
2450
|
-
return null;
|
|
2451
|
-
}
|
|
2452
|
-
/**
|
|
2453
|
-
* 4xx JSON fallback chain (single pass):
|
|
2454
|
-
* Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
|
|
2455
|
-
* No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
|
|
2456
|
-
*/
|
|
2457
|
-
function resolve4xxJson(status, segments) {
|
|
2458
|
-
const statusStr = String(status);
|
|
2459
2637
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2460
|
-
const
|
|
2461
|
-
if (
|
|
2462
|
-
|
|
2463
|
-
if (exact) return {
|
|
2464
|
-
file: exact,
|
|
2638
|
+
const errorFile = segments[i].error;
|
|
2639
|
+
if (errorFile) return {
|
|
2640
|
+
file: errorFile,
|
|
2465
2641
|
status,
|
|
2466
|
-
kind: "
|
|
2467
|
-
segmentIndex: i
|
|
2468
|
-
};
|
|
2469
|
-
const category = segment.jsonStatusFiles.get("4xx");
|
|
2470
|
-
if (category) return {
|
|
2471
|
-
file: category,
|
|
2472
|
-
status,
|
|
2473
|
-
kind: "category",
|
|
2642
|
+
kind: "error",
|
|
2474
2643
|
segmentIndex: i
|
|
2475
2644
|
};
|
|
2476
2645
|
}
|
|
2477
2646
|
return null;
|
|
2478
2647
|
}
|
|
2479
2648
|
/**
|
|
2480
|
-
* 5xx component fallback chain
|
|
2481
|
-
*
|
|
2649
|
+
* 5xx component fallback chain — single pass, per-segment leaf→root.
|
|
2650
|
+
*
|
|
2651
|
+
* At each segment: {status}.tsx → 5xx.tsx → error.tsx. A leaf's
|
|
2652
|
+
* `error.tsx` therefore beats a root's `5xx.tsx`, which is the
|
|
2653
|
+
* intentional inverse of the 4xx chain.
|
|
2482
2654
|
*/
|
|
2483
2655
|
function resolve5xx(status, segments) {
|
|
2484
2656
|
const statusStr = String(status);
|
|
2485
2657
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2486
2658
|
const segment = segments[i];
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
if (exact) return {
|
|
2490
|
-
file: exact,
|
|
2491
|
-
status,
|
|
2492
|
-
kind: "exact",
|
|
2493
|
-
segmentIndex: i
|
|
2494
|
-
};
|
|
2495
|
-
const category = segment.statusFiles.get("5xx");
|
|
2496
|
-
if (category) return {
|
|
2497
|
-
file: category,
|
|
2498
|
-
status,
|
|
2499
|
-
kind: "category",
|
|
2500
|
-
segmentIndex: i
|
|
2501
|
-
};
|
|
2502
|
-
}
|
|
2659
|
+
const r = lookupInGroup(segment.statusFiles, statusStr, "5xx", i, status);
|
|
2660
|
+
if (r) return r;
|
|
2503
2661
|
if (segment.error) return {
|
|
2504
2662
|
file: segment.error,
|
|
2505
2663
|
status,
|
|
@@ -2510,29 +2668,18 @@ function resolve5xx(status, segments) {
|
|
|
2510
2668
|
return null;
|
|
2511
2669
|
}
|
|
2512
2670
|
/**
|
|
2513
|
-
*
|
|
2514
|
-
*
|
|
2515
|
-
*
|
|
2671
|
+
* JSON fallback chain (for both 4xx and 5xx) — single pass leaf→root.
|
|
2672
|
+
*
|
|
2673
|
+
* At each segment: {status}.json → {category}.json. No legacy compat,
|
|
2674
|
+
* no error.tsx — the JSON chain terminates at the category catch-all
|
|
2675
|
+
* and the caller falls back to a bare-JSON framework default.
|
|
2516
2676
|
*/
|
|
2517
|
-
function
|
|
2677
|
+
function resolveJson(status, segments) {
|
|
2518
2678
|
const statusStr = String(status);
|
|
2679
|
+
const categoryKey = status >= 500 ? "5xx" : "4xx";
|
|
2519
2680
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2520
|
-
const
|
|
2521
|
-
if (
|
|
2522
|
-
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
2523
|
-
if (exact) return {
|
|
2524
|
-
file: exact,
|
|
2525
|
-
status,
|
|
2526
|
-
kind: "exact",
|
|
2527
|
-
segmentIndex: i
|
|
2528
|
-
};
|
|
2529
|
-
const category = segment.jsonStatusFiles.get("5xx");
|
|
2530
|
-
if (category) return {
|
|
2531
|
-
file: category,
|
|
2532
|
-
status,
|
|
2533
|
-
kind: "category",
|
|
2534
|
-
segmentIndex: i
|
|
2535
|
-
};
|
|
2681
|
+
const r = lookupInGroup(segments[i].jsonStatusFiles, statusStr, categoryKey, i, status);
|
|
2682
|
+
if (r) return r;
|
|
2536
2683
|
}
|
|
2537
2684
|
return null;
|
|
2538
2685
|
}
|