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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/_chunks/{metadata-routes-DS3eKNmf.js → metadata-routes-BU684ls2.js} +1 -1
  2. package/dist/_chunks/{metadata-routes-DS3eKNmf.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  3. package/dist/_chunks/segment-classify-BjfuctV2.js +137 -0
  4. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -0
  5. package/dist/_chunks/{interception-BbqMCVXa.js → walkers-VOXgavMF.js} +61 -85
  6. package/dist/_chunks/walkers-VOXgavMF.js.map +1 -0
  7. package/dist/adapters/nitro.d.ts.map +1 -1
  8. package/dist/adapters/nitro.js +55 -5
  9. package/dist/adapters/nitro.js.map +1 -1
  10. package/dist/client/index.js +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +189 -62
  13. package/dist/index.js.map +1 -1
  14. package/dist/plugins/build-report.d.ts +6 -4
  15. package/dist/plugins/build-report.d.ts.map +1 -1
  16. package/dist/plugins/dev-404-page.d.ts +8 -18
  17. package/dist/plugins/dev-404-page.d.ts.map +1 -1
  18. package/dist/routing/index.d.ts +5 -3
  19. package/dist/routing/index.d.ts.map +1 -1
  20. package/dist/routing/index.js +3 -3
  21. package/dist/routing/scanner.d.ts +1 -10
  22. package/dist/routing/scanner.d.ts.map +1 -1
  23. package/dist/routing/segment-classify.d.ts +37 -8
  24. package/dist/routing/segment-classify.d.ts.map +1 -1
  25. package/dist/routing/types.d.ts +63 -23
  26. package/dist/routing/types.d.ts.map +1 -1
  27. package/dist/routing/walkers.d.ts +51 -0
  28. package/dist/routing/walkers.d.ts.map +1 -0
  29. package/dist/server/action-handler.d.ts.map +1 -1
  30. package/dist/server/dev-holding-server.d.ts +4 -2
  31. package/dist/server/dev-holding-server.d.ts.map +1 -1
  32. package/dist/server/html-injector-core.d.ts +212 -0
  33. package/dist/server/html-injector-core.d.ts.map +1 -0
  34. package/dist/server/html-injectors.d.ts +59 -59
  35. package/dist/server/html-injectors.d.ts.map +1 -1
  36. package/dist/server/internal.js +710 -563
  37. package/dist/server/internal.js.map +1 -1
  38. package/dist/server/node-stream-transforms.d.ts +46 -49
  39. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  40. package/dist/server/pipeline-helpers.d.ts +88 -0
  41. package/dist/server/pipeline-helpers.d.ts.map +1 -0
  42. package/dist/server/pipeline-phases.d.ts +97 -0
  43. package/dist/server/pipeline-phases.d.ts.map +1 -0
  44. package/dist/server/pipeline.d.ts +53 -32
  45. package/dist/server/pipeline.d.ts.map +1 -1
  46. package/dist/server/port-resolution.d.ts +117 -0
  47. package/dist/server/port-resolution.d.ts.map +1 -0
  48. package/dist/server/route-matcher.d.ts +20 -47
  49. package/dist/server/route-matcher.d.ts.map +1 -1
  50. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +74 -0
  52. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -0
  53. package/dist/server/status-code-resolver.d.ts +16 -11
  54. package/dist/server/status-code-resolver.d.ts.map +1 -1
  55. package/dist/server/tree-builder.d.ts.map +1 -1
  56. package/dist/utils/directive-parser.d.ts +0 -45
  57. package/dist/utils/directive-parser.d.ts.map +1 -1
  58. package/package.json +7 -6
  59. package/src/adapters/nitro.ts +55 -5
  60. package/src/cli.ts +0 -0
  61. package/src/index.ts +84 -31
  62. package/src/plugins/build-report.ts +13 -22
  63. package/src/plugins/dev-404-page.ts +15 -41
  64. package/src/plugins/routing.ts +14 -12
  65. package/src/routing/codegen.ts +1 -1
  66. package/src/routing/convention-lint.ts +4 -4
  67. package/src/routing/index.ts +5 -3
  68. package/src/routing/interception.ts +1 -1
  69. package/src/routing/scanner.ts +17 -93
  70. package/src/routing/segment-classify.ts +107 -8
  71. package/src/routing/status-file-lint.ts +3 -3
  72. package/src/routing/types.ts +63 -23
  73. package/src/routing/walkers.ts +90 -0
  74. package/src/server/action-handler.ts +6 -0
  75. package/src/server/deny-renderer.ts +5 -5
  76. package/src/server/dev-holding-server.ts +4 -2
  77. package/src/server/fallback-error.ts +1 -1
  78. package/src/server/html-injector-core.ts +403 -0
  79. package/src/server/html-injectors.ts +158 -297
  80. package/src/server/node-stream-transforms.ts +108 -248
  81. package/src/server/pipeline-helpers.ts +180 -0
  82. package/src/server/pipeline-phases.ts +591 -0
  83. package/src/server/pipeline.ts +76 -539
  84. package/src/server/port-resolution.ts +215 -0
  85. package/src/server/route-element-builder.ts +1 -1
  86. package/src/server/route-matcher.ts +28 -60
  87. package/src/server/rsc-entry/api-handler.ts +2 -2
  88. package/src/server/rsc-entry/error-renderer.ts +1 -1
  89. package/src/server/rsc-entry/index.ts +52 -98
  90. package/src/server/rsc-entry/wrap-action-dispatch.ts +156 -0
  91. package/src/server/sitemap-generator.ts +1 -1
  92. package/src/server/slot-resolver.ts +1 -1
  93. package/src/server/status-code-resolver.ts +112 -128
  94. package/src/server/tree-builder.ts +6 -4
  95. package/src/utils/directive-parser.ts +0 -392
  96. package/LICENSE +0 -8
  97. package/dist/_chunks/interception-BbqMCVXa.js.map +0 -1
  98. package/dist/_chunks/segment-classify-BDNn6EzD.js +0 -65
  99. package/dist/_chunks/segment-classify-BDNn6EzD.js.map +0 -1
  100. package/dist/server/manifest-status-resolver.d.ts +0 -58
  101. package/dist/server/manifest-status-resolver.d.ts.map +0 -1
  102. package/src/server/manifest-status-resolver.ts +0 -215
@@ -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 { t as classifyUrlSegment } from "../_chunks/segment-classify-BDNn6EzD.js";
4
- import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-DS3eKNmf.js";
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
- * Request pipelinethe central dispatch for all timber.js requests.
1625
- *
1626
- * Pipeline stages (in order):
1627
- * proxy.ts canonicalize route match → 103 Early Hints → middleware.ts → render
1628
- *
1629
- * Each stage is a pure function or returns a Response to short-circuit.
1630
- * Each request gets a trace ID, structured logging, and OTEL spans.
1631
- *
1632
- * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
1633
- * and design/17-logging.md §"Production Logging"
1634
- */
1635
- /** Keys that must never be merged via Object.assign — they pollute Object.prototype. */
1636
- var DANGEROUS_KEYS = new Set([
1637
- "__proto__",
1638
- "constructor",
1639
- "prototype"
1640
- ]);
1641
- /**
1642
- * Shallow merge that skips prototype-polluting keys.
1643
- *
1644
- * Used instead of Object.assign when the source object comes from
1645
- * user-authored codec output (segmentParams.parse), which could
1646
- * contain __proto__, constructor, or prototype keys.
1647
- *
1648
- * See TIM-655, design/13-security.md
1649
- */
1650
- function safeMerge(target, source) {
1651
- for (const key of Object.keys(source)) if (!DANGEROUS_KEYS.has(key)) target[key] = source[key];
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
- * Run segment param coercion on the matched route's segments.
1655
- *
1656
- * Loads params.ts modules from segments that have them, extracts the
1657
- * segmentParams definition, and coerces raw string params through codecs.
1658
- * Throws ParamCoercionError if any codec fails (→ 404).
1659
- *
1660
- * This runs BEFORE middleware, so ctx.segmentParams is already typed.
1661
- * See design/07-routing.md §"Where Coercion Runs"
1662
- */
1663
- async function coerceSegmentParams(match) {
1664
- const segments = match.segments;
1665
- for (const segment of segments) {
1666
- if (!segment.params) continue;
1667
- let mod;
1668
- try {
1669
- mod = await loadModule(segment.params);
1670
- } catch (err) {
1671
- throw new ParamCoercionError(`Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`);
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
- const segmentParamsDef = mod.segmentParams;
1674
- if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
1675
- try {
1676
- const coerced = segmentParamsDef.parse(match.segmentParams);
1677
- safeMerge(match.segmentParams, coerced);
1678
- } catch (err) {
1679
- throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
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 stages
1687
- * and produces a Response. This is the top-level entry point for the server.
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 { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, serverTiming = "total", onPipelineError } = config;
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 (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
1713
- else result = await handleRequest(req, method, path);
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
- if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent);
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
- * Maps legacy file convention names to their corresponding HTTP status codes.
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 >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
2402
- if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
2403
- return null;
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 (three separate passes):
2407
- * Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
2408
- * Pass 2 legacy compat (leaf root): not-found.tsx / forbidden.tsx / unauthorized.tsx
2409
- * Pass 3 error.tsx (leaf root)
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 segment = segments[i];
2415
- if (!segment.statusFiles) continue;
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 segment = segments[i];
2433
- if (!segment.legacyStatusFiles) continue;
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 segment = segments[i];
2461
- if (!segment.jsonStatusFiles) continue;
2462
- const exact = segment.jsonStatusFiles.get(statusStr);
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: "exact",
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 (single pass, per-segment):
2481
- * At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
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
- if (segment.statusFiles) {
2488
- const exact = segment.statusFiles.get(statusStr);
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
- * 5xx JSON fallback chain (single pass):
2514
- * At each segment (leaf → root): {status}.json → 5xx.json
2515
- * No error.tsx equivalent JSON chain terminates at category catch-all.
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 resolve5xxJson(status, segments) {
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 segment = segments[i];
2521
- if (!segment.jsonStatusFiles) continue;
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
  }