@timber-js/app 0.2.0-alpha.171 → 0.2.0-alpha.172

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 (97) hide show
  1. package/dist/_chunks/{actions-O_LsyCE4.js → actions-pN8r5Vnh.js} +3 -3
  2. package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-pN8r5Vnh.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-2hT5kfsr.js} +2 -2
  4. package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-2hT5kfsr.js.map} +1 -1
  5. package/dist/_chunks/{canonicalize-Du3o_ptW.js → canonicalize-DQHyFClh.js} +2 -1
  6. package/dist/_chunks/canonicalize-DQHyFClh.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-B5FDplGI.js → cli-schema-sync-DvdvFwwE.js} +3 -3
  8. package/dist/_chunks/{cli-schema-sync-B5FDplGI.js.map → cli-schema-sync-DvdvFwwE.js.map} +1 -1
  9. package/dist/_chunks/{logger-AWfuX-KJ.js → logger-kUT0QH0K.js} +23 -1
  10. package/dist/_chunks/logger-kUT0QH0K.js.map +1 -0
  11. package/dist/_chunks/{walkers-DCoE-LJf.js → walkers-Bv63zfAC.js} +2 -2
  12. package/dist/_chunks/{walkers-DCoE-LJf.js.map → walkers-Bv63zfAC.js.map} +1 -1
  13. package/dist/cache/index.js +1 -1
  14. package/dist/cli.d.ts +3 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +9 -5
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client/internal.js +40 -6
  19. package/dist/client/internal.js.map +1 -1
  20. package/dist/client/rsc-fetch.d.ts +1 -1
  21. package/dist/client/segment-cache.d.ts +20 -3
  22. package/dist/client/segment-cache.d.ts.map +1 -1
  23. package/dist/client/segment-outlet.d.ts +10 -2
  24. package/dist/client/segment-outlet.d.ts.map +1 -1
  25. package/dist/client/slot-context.d.ts +10 -8
  26. package/dist/client/slot-context.d.ts.map +1 -1
  27. package/dist/client/slot-provider.d.ts +5 -0
  28. package/dist/client/slot-provider.d.ts.map +1 -1
  29. package/dist/index.js +5 -5
  30. package/dist/routing/index.js +2 -2
  31. package/dist/server/access-gate.d.ts.map +1 -1
  32. package/dist/server/als-registry.d.ts +26 -0
  33. package/dist/server/als-registry.d.ts.map +1 -1
  34. package/dist/server/cookie-context.d.ts.map +1 -1
  35. package/dist/server/index.js +2 -2
  36. package/dist/server/internal.js +1614 -1667
  37. package/dist/server/internal.js.map +1 -1
  38. package/dist/server/metadata-routes.d.ts +13 -0
  39. package/dist/server/metadata-routes.d.ts.map +1 -1
  40. package/dist/server/metadata.d.ts +8 -0
  41. package/dist/server/metadata.d.ts.map +1 -1
  42. package/dist/server/prebuilt/slots.d.ts +33 -8
  43. package/dist/server/prebuilt/slots.d.ts.map +1 -1
  44. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  45. package/dist/server/request-context.d.ts +15 -0
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/route-element-builder.d.ts +9 -11
  48. package/dist/server/route-element-builder.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/helpers.d.ts +18 -10
  50. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/rsc-payload.d.ts +2 -1
  53. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  54. package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
  55. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  56. package/dist/server/slot-resolver.d.ts +46 -1
  57. package/dist/server/slot-resolver.d.ts.map +1 -1
  58. package/dist/server/state-tree-diff.d.ts +36 -3
  59. package/dist/server/state-tree-diff.d.ts.map +1 -1
  60. package/dist/server/tree-builder.d.ts +7 -0
  61. package/dist/server/tree-builder.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/cli.ts +15 -5
  64. package/src/client/rsc-fetch.ts +1 -1
  65. package/src/client/segment-cache.ts +83 -10
  66. package/src/client/segment-outlet.tsx +24 -2
  67. package/src/client/slot-context.ts +10 -8
  68. package/src/client/slot-provider.tsx +9 -2
  69. package/src/server/access-gate.tsx +28 -1
  70. package/src/server/als-registry.ts +44 -0
  71. package/src/server/cookie-context.ts +7 -1
  72. package/src/server/deny-renderer.ts +1 -1
  73. package/src/server/metadata-routes.ts +95 -0
  74. package/src/server/metadata.ts +21 -0
  75. package/src/server/prebuilt/slots.ts +39 -16
  76. package/src/server/prebuilt-builder.ts +6 -5
  77. package/src/server/prebuilt-runtime.ts +11 -11
  78. package/src/server/request-context.ts +47 -1
  79. package/src/server/route-element-builder.ts +72 -144
  80. package/src/server/rsc-entry/helpers.ts +68 -14
  81. package/src/server/rsc-entry/render-route.ts +11 -3
  82. package/src/server/rsc-entry/rsc-payload.ts +16 -3
  83. package/src/server/rsc-entry/ssr-renderer.ts +3 -1
  84. package/src/server/slot-resolver.ts +299 -8
  85. package/src/server/state-tree-diff.ts +104 -8
  86. package/src/server/tree-builder.ts +10 -0
  87. package/dist/_chunks/canonicalize-Du3o_ptW.js.map +0 -1
  88. package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
  89. package/dist/client/child-segment-context.d.ts +0 -22
  90. package/dist/client/child-segment-context.d.ts.map +0 -1
  91. package/dist/client/child-segment-outlet.d.ts +0 -18
  92. package/dist/client/child-segment-outlet.d.ts.map +0 -1
  93. package/dist/client/child-segment-provider.d.ts +0 -21
  94. package/dist/client/child-segment-provider.d.ts.map +0 -1
  95. package/src/client/child-segment-context.ts +0 -40
  96. package/src/client/child-segment-outlet.tsx +0 -25
  97. package/src/client/child-segment-provider.tsx +0 -27
@@ -1,13 +1,13 @@
1
- import { a as classifyMetadataRoute, i as METADATA_ROUTE_CONVENTIONS, o as getMetadataRouteAutoLink, r as canonicalize, s as getMetadataRouteServePath } from "../_chunks/canonicalize-Du3o_ptW.js";
1
+ import { a as classifyMetadataRoute, i as METADATA_ROUTE_CONVENTIONS, o as getMetadataRouteAutoLink, r as canonicalize, s as getMetadataRouteServePath } from "../_chunks/canonicalize-DQHyFClh.js";
2
2
  import { n as classifyUrlSegment } from "../_chunks/segment-classify-Byy425ng.js";
3
- import { $ as timingAls, A as setMatchedSegmentPath, C as applyRequestHeaderOverlay, D as getSegmentParams, E as getSearchParams, F as getSetCookieHeaders, G as replaceTraceId, H as getOtelTraceId, I as getWaitUntil, J as withSpan, K as runWithTraceId, L as isDebug, M as setSegmentParams, N as getCookie, O as markResponseFlushed, V as generateTraceId, W as getTraceId, Y as earlyHintsSenderAls, Z as requestContextAls, _ as RedirectSignal, a as logProxyError, c as logRequestReceived, d as logSwrRefetchFailed, f as logWaitUntilRejected, g as DenySignal, h as swallow, i as logMiddlewareShortCircuit, j as setMutableCookieContext, k as runWithRequestContext, l as logRouteError, m as setLogger, n as logCacheMiss, o as logRenderError, p as logWaitUntilUnsupported, q as setSpanAttribute, r as logMiddlewareError, s as logRequestCompleted, t as getLogger, u as logSlowRequest, v as RenderError, w as getHeader } from "../_chunks/logger-AWfuX-KJ.js";
4
- import { i as coerce, t as executeAction } from "../_chunks/actions-O_LsyCE4.js";
5
- import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as createSingleflight, y as lastInvalidationEpochFor } from "../_chunks/cache-api-B-lhk9p4.js";
3
+ import { $ as timingAls, A as setMatchedSegmentPath, C as applyRequestHeaderOverlay, D as getSegmentParams, E as getSearchParams, F as getSetCookieHeaders, G as replaceTraceId, H as getOtelTraceId, I as getWaitUntil, J as withSpan, K as runWithTraceId, L as isDebug, M as setSegmentParams, N as getCookie, O as markResponseFlushed, V as generateTraceId, W as getTraceId, Y as earlyHintsSenderAls, Z as requestContextAls, _ as RedirectSignal, a as logProxyError, c as logRequestReceived, d as logSwrRefetchFailed, f as logWaitUntilRejected, g as DenySignal, h as swallow, i as logMiddlewareShortCircuit, j as setMutableCookieContext, k as runWithRequestContext, l as logRouteError, m as setLogger, n as logCacheMiss, o as logRenderError, p as logWaitUntilUnsupported, q as setSpanAttribute, r as logMiddlewareError, s as logRequestCompleted, t as getLogger, u as logSlowRequest, v as RenderError, w as getHeader } from "../_chunks/logger-kUT0QH0K.js";
4
+ import { i as coerce, t as executeAction } from "../_chunks/actions-pN8r5Vnh.js";
5
+ import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as createSingleflight, y as lastInvalidationEpochFor } from "../_chunks/cache-api-2hT5kfsr.js";
6
6
  import "../client/error-boundary.js";
7
7
  import { n as toBracketKey } from "../_chunks/resolve-schema-Dz3fcFUo.js";
8
8
  import "../_chunks/segment-context-ZDnXDkbz.js";
9
- import { readFile } from "node:fs/promises";
10
9
  import { createHash } from "node:crypto";
10
+ import { readFile } from "node:fs/promises";
11
11
  import React, { createElement, useContext } from "react";
12
12
  //#region src/server/server-timing.ts
13
13
  /**
@@ -1500,7 +1500,13 @@ async function accessGateFallback(accessFn, segmentName, denyPages, children) {
1500
1500
  * slot doesn't make architectural sense.
1501
1501
  */
1502
1502
  async function SlotAccessGate(props) {
1503
- const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
1503
+ const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children, verdict } = props;
1504
+ if (verdict !== void 0) {
1505
+ if (verdict === "pass") return children;
1506
+ if (verdict instanceof DenySignal) return buildDeniedFallback(DeniedComponent, slotName, verdict.data, createElement) ?? defaultFallback ?? null;
1507
+ if (isDebug()) console.error("[timber] redirect() is not allowed in slot access.ts. Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. If you need to redirect, move the logic to the parent segment's access.ts.");
1508
+ return buildDeniedFallback(DeniedComponent, slotName, void 0, createElement) ?? defaultFallback ?? null;
1509
+ }
1504
1510
  try {
1505
1511
  await accessFn();
1506
1512
  } catch (error) {
@@ -1526,1921 +1532,1862 @@ function buildDeniedFallback(DeniedComponent, slotName, data, createElement) {
1526
1532
  });
1527
1533
  }
1528
1534
  //#endregion
1529
- //#region src/server/param-coercion.ts
1530
- /** Global schema codecs, set once at startup from virtual:timber-schema. */
1531
- var _globalCodecs = null;
1535
+ //#region src/client/slot-context.ts
1532
1536
  /**
1533
- * Run segment param coercion on the matched route's segments.
1534
- *
1535
- * When `globalCodecs` is provided (from app/schema.ts), uses the global
1536
- * codec map keyed by bare param name. Otherwise falls back to loading
1537
- * per-segment params.ts modules.
1537
+ * SlotContext — delivers live slot content to SlotOutlet holes inside
1538
+ * cached component shells (TIM-1173).
1538
1539
  *
1539
- * Throws ParamCoercionError if any codec fails (→ 404).
1540
+ * A `cache.component(Fn, { slots: [...] })` shell is captured with a
1541
+ * stable <SlotOutlet slot={name} /> client reference in place of each
1542
+ * declared slot prop. At request time the revived shell is wrapped in a
1543
+ * SlotsProvider carrying the live slot values; each outlet reads its
1544
+ * value from this context by name.
1540
1545
  *
1541
- * This runs BEFORE middleware, so ctx.segmentParams is already typed.
1542
- * See design/07-routing.md §"Where Coercion Runs"
1543
- * See design/41-global-params.md §"Pipeline Integration"
1544
- */
1545
- async function coerceSegmentParams(match) {
1546
- const globalCodecs = _globalCodecs;
1547
- const mergeTarget = Object.create(null);
1548
- for (const key of Object.keys(match.segmentParams)) if (key !== "__proto__") mergeTarget[key] = match.segmentParams[key];
1549
- match.segmentParams = mergeTarget;
1550
- if (globalCodecs) {
1551
- for (const segment of match.segments) {
1552
- if (!segment.paramName) continue;
1553
- const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
1554
- const codec = globalCodecs[bracketKey];
1555
- if (!codec) continue;
1556
- const key = segment.paramName;
1557
- if (!(key in mergeTarget)) continue;
1558
- try {
1559
- mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key]));
1560
- } catch (err) {
1561
- const message = err instanceof Error ? err.message : String(err);
1562
- if (isDebug()) console.warn(`[timber] Global schema codec rejected value for param "${key}"\n Error: ${message}\n Raw value: ${JSON.stringify(mergeTarget[key])}\n Hint: check the codec in app/schema.ts for key '${bracketKey}'.`);
1563
- throw new ParamCoercionError(message);
1564
- }
1565
- }
1566
- return;
1567
- }
1568
- for (const segment of match.segments) {
1569
- if (!segment.params) continue;
1570
- let mod;
1571
- try {
1572
- mod = await loadModule(segment.params);
1573
- } catch (err) {
1574
- const message = `Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`;
1575
- if (isDebug()) console.warn(`[timber] Param coercion error: ${message}\n Segment: ${segment.segmentName}\n Params file: ${segment.params}`);
1576
- throw new ParamCoercionError(message);
1577
- }
1578
- const segmentParamsDef = mod.segmentParams;
1579
- if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
1580
- try {
1581
- const coerced = segmentParamsDef.parse(match.segmentParams);
1582
- for (const key of Object.keys(coerced)) if (key !== "__proto__") mergeTarget[key] = sanitizeParamValue(coerced[key]);
1583
- } catch (err) {
1584
- const message = err instanceof Error ? err.message : String(err);
1585
- if (isDebug()) {
1586
- const rawKeys = Object.keys(match.segmentParams).join(", ");
1587
- console.warn(`[timber] Param codec rejected values for segment "${segment.segmentName}"\n Error: ${message}\n Available raw params: { ${rawKeys} }\n Params file: ${segment.params}\n Hint: this usually means a codec threw for the raw URL value.\n Check that the regex/schema accepts the actual path segment string.`);
1588
- }
1589
- throw new ParamCoercionError(message);
1590
- }
1591
- }
1592
- }
1593
- //#endregion
1594
- //#region src/client/segment-update-context.ts
1595
- /**
1596
- * SegmentUpdateContext — React context for partial navigation updates.
1546
+ * Providers MERGE per-key with their parent: an inner SlotsProvider
1547
+ * inherits the outer's values and overrides only the keys it declares.
1548
+ * Two sibling instances each have their own provider and resolve
1549
+ * independently. Layouts use a reserved slot name (LAYOUT_CHILDREN_SLOT)
1550
+ * that cannot collide with user-declared slot names (TIM-1191).
1597
1551
  *
1598
- * During partial navigation (server skips unchanged sync layouts), the
1599
- * router builds a Map of segment path → ReactNode updates and passes it
1600
- * as the context value. Mounted SegmentOutlet components read from this
1601
- * context to decide whether to render the update or their cached content.
1552
+ * Generalizes the original layout children pattern (TIM-1181) from a
1553
+ * single implicit `children` hole on layouts to explicitly declared,
1554
+ * named slot props on any cached component. Layouts use this same
1555
+ * context with the reserved LAYOUT_CHILDREN_SLOT key (TIM-1191).
1602
1556
  *
1603
- * SINGLETON GUARANTEE: Uses globalThis + Symbol.for — same pattern as
1604
- * NavigationContext. The RSC client bundler can duplicate this module
1605
- * across chunks (browser-entry graph + client-reference graph). With
1606
- * ESM output, each chunk gets its own module scope — a bare createContext
1607
- * at module level would create separate instances per chunk. globalThis
1608
- * guarantees a single instance regardless of duplication.
1557
+ * SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
1558
+ * can duplicate this module across chunks; globalThis guarantees a single
1559
+ * context instance.
1609
1560
  *
1610
- * See design/19-client-navigation.md §"Singleton Guarantee via globalThis"
1561
+ * See design/45-cache-lifetimes.md §Slot Components.
1611
1562
  */
1612
- var EMPTY_SEGMENT_UPDATES = /* @__PURE__ */ new Map();
1613
- var CTX_KEY$2 = Symbol.for("__timber_segment_update_ctx");
1614
- function getOrCreateContext$2() {
1615
- const existing = globalThis[CTX_KEY$2];
1563
+ var CTX_KEY$1 = Symbol.for("__timber_slot_ctx");
1564
+ function getOrCreateContext$1() {
1565
+ const existing = globalThis[CTX_KEY$1];
1616
1566
  if (existing !== void 0) return existing;
1617
1567
  if (typeof React.createContext === "function") {
1618
- const ctx = React.createContext(EMPTY_SEGMENT_UPDATES);
1619
- globalThis[CTX_KEY$2] = ctx;
1568
+ const ctx = React.createContext(null);
1569
+ globalThis[CTX_KEY$1] = ctx;
1620
1570
  return ctx;
1621
1571
  }
1622
1572
  }
1623
- getOrCreateContext$2();
1573
+ var SlotContext = getOrCreateContext$1();
1624
1574
  //#endregion
1625
- //#region src/client/segment-outlet.tsx
1575
+ //#region src/client/slot-outlet.tsx
1626
1576
  /**
1627
- * SegmentOutlet — client component boundary at each layout segment.
1628
- *
1629
- * Each layout in the segment tree is wrapped with a SegmentOutlet that:
1630
- * 1. Knows its segment path (prop from the server)
1631
- * 2. Reads from SegmentUpdateContext for partial navigation updates
1632
- * 3. Caches its rendered content in a ref across navigations
1633
- *
1634
- * On full navigation: receives new children via props, caches and renders them.
1635
- * On partial navigation (this segment skipped): the context map has no entry
1636
- * for this path, so the outlet returns cached content — preserving layout state.
1637
- * On partial navigation (this segment updated): the context map has content
1638
- * for this path, so the outlet renders the update.
1577
+ * SlotOutlet — the hole in a cached component shell (TIM-1173).
1639
1578
  *
1640
- * Uses React context instead of useSyncExternalStore to stay compatible
1641
- * with concurrent rendering (transitions). All outlets re-render when the
1642
- * context value changes, but each bails out quickly if its segment has
1643
- * no update — same approach as Next.js LayoutRouter.
1579
+ * At capture time the prebuilt runtime renders the wrapped component with
1580
+ * <SlotOutlet slot={name} /> in place of each declared slot prop. Being a
1581
+ * client component, it serializes into the flight payload as a stable,
1582
+ * deterministic client reference — the same bytes regardless of what live
1583
+ * content will fill the hole. At request time the revived shell sits under
1584
+ * a SlotsProvider and each outlet reads its live value from SlotContext.
1644
1585
  *
1645
- * Security: performance optimization only. The server always runs all
1646
- * access.ts files regardless of segment skipping.
1647
- * See design/13-security.md §"State tree manipulation".
1586
+ * See design/45-cache-lifetimes.md §Slot Components.
1648
1587
  */
1588
+ function SlotOutlet({ slot }) {
1589
+ const values = useContext(SlotContext);
1590
+ return values ? values[slot] ?? null : null;
1591
+ }
1649
1592
  //#endregion
1650
- //#region src/client/child-segment-context.ts
1593
+ //#region src/client/slot-provider.tsx
1651
1594
  /**
1652
- * ChildSegmentContext — delivers child segment content to ChildSegmentOutlet.
1653
- *
1654
- * Each layout receives a stable <ChildSegmentOutlet /> client reference as
1655
- * its `children` prop instead of varying server content. The actual child
1656
- * content (page or inner layout) is provided via this context from a
1657
- * ChildSegmentProvider placed ABOVE the layout in the element tree.
1595
+ * SlotsProvider — wraps a revived cached shell to supply live slot content
1596
+ * to the SlotOutlet holes inside it (TIM-1173).
1658
1597
  *
1659
- * This structural separation is what makes layout cache.component hits
1660
- * possible: the cached layout payload contains a deterministic client
1661
- * reference as children, not the varying inner content that changes per
1662
- * route. Same pattern as Next.js LayoutRouter.
1598
+ * Values MERGE with the nearest parent provider so that nesting a
1599
+ * cache.component SlotsProvider inside a layout SlotsProvider preserves
1600
+ * the layout's `children` slot while adding the component's own slots.
1601
+ * Inner values override outer ones with the same name (spread order).
1663
1602
  *
1664
- * SINGLETON GUARANTEE: Uses globalThis + Symbol.for — same pattern as
1665
- * SegmentUpdateContext. The RSC client bundler can duplicate this module
1666
- * across chunks; globalThis guarantees a single context instance.
1603
+ * Tree structure per cached-component instance:
1604
+ * SlotsProvider(values={children: <Live />})
1605
+ * └── revived shell
1606
+ * └── SlotOutlet(slot="children") → reads context → <Live />
1667
1607
  *
1668
- * See design/45-cache-lifetimes.md §Layout Propagation.
1608
+ * See design/45-cache-lifetimes.md §Slot Components.
1669
1609
  */
1670
- var CTX_KEY$1 = Symbol.for("__timber_child_segment_ctx");
1671
- function getOrCreateContext$1() {
1672
- const existing = globalThis[CTX_KEY$1];
1673
- if (existing !== void 0) return existing;
1674
- if (typeof React.createContext === "function") {
1675
- const ctx = React.createContext(null);
1676
- globalThis[CTX_KEY$1] = ctx;
1677
- return ctx;
1678
- }
1610
+ function SlotsProvider({ values, children }) {
1611
+ const parent = useContext(SlotContext);
1612
+ const merged = parent ? {
1613
+ ...parent,
1614
+ ...values
1615
+ } : values;
1616
+ return createElement(SlotContext.Provider, { value: merged }, children);
1679
1617
  }
1680
- var ChildSegmentContext = getOrCreateContext$1();
1681
1618
  //#endregion
1682
- //#region src/client/child-segment-outlet.tsx
1619
+ //#region src/server/prebuilt/cache-key.ts
1683
1620
  /**
1684
- * ChildSegmentOutlet — renders child segment content from context.
1685
- *
1686
- * Passed as `children` to every layout component, replacing the previous
1687
- * pattern of passing rendered server content directly. The layout receives
1688
- * a stable client reference (this component), and the actual child content
1689
- * is delivered via ChildSegmentContext from a provider above the layout.
1621
+ * Component cache-key hashing for prebuilt (cache.component) entries.
1690
1622
  *
1691
- * This makes layout cache.component hits possible: the cached layout
1692
- * payload contains this deterministic client reference, not varying
1693
- * server content. On cache hit, the revived payload contains the layout
1694
- * shell + this outlet; the provider above supplies the current request's
1695
- * child content.
1623
+ * Key: `sha256(canonicalJson({ props, params })).slice(0, 16)` — see
1624
+ * design/44-render-at-build-time.md §Cache Key. Both the request-time
1625
+ * lookup (prebuilt-runtime.ts) and the post-build capture pass (TIM-1119)
1626
+ * MUST use this exact function, or build artifacts become unaddressable.
1696
1627
  *
1697
- * See design/45-cache-lifetimes.md §Layout Propagation.
1698
- */
1699
- function ChildSegmentOutlet() {
1700
- return useContext(ChildSegmentContext);
1701
- }
1702
- //#endregion
1703
- //#region src/client/child-segment-provider.tsx
1704
- /**
1705
- * ChildSegmentProvider — wraps a layout to provide child segment content.
1628
+ * THE IDENTITY CONTRACT (design/44 §Cache Key): cache identity is **JSON
1629
+ * value semantics** — two inputs are the same entry iff they are the same
1630
+ * JSON value. This is an ALLOWLIST, not a denylist: a value participates
1631
+ * in caching iff it is plain JSON data — null, boolean, finite number,
1632
+ * string, dense array, or a plain `Object.prototype` object with only
1633
+ * enumerable string-keyed data properties — plus `undefined` and `-0`,
1634
+ * which hash as distinct sentinels because flight preserves them. Every
1635
+ * value outside that domain (functions, symbols, bigints, accessors,
1636
+ * hidden or symbol-keyed properties, non-`Object.prototype` prototypes
1637
+ * including null, aliased references, sparse arrays, cycles, non-finite
1638
+ * numbers) is UNKEYABLE by definition: `computeComponentCacheKey` returns
1639
+ * null and the caller renders live — slower, never wrong.
1706
1640
  *
1707
- * Placed above the layout in the element tree so that ChildSegmentOutlet
1708
- * (inside the layout's `children`) can read the actual child content.
1641
+ * The line between rejected and declared-out-of-domain: values are
1642
+ * REJECTED when the JSON projection itself would be ambiguous or lossy
1643
+ * (hidden/symbol/non-enumerable properties, accessors, exotic
1644
+ * prototypes). Observable state that leaves the projection intact — key
1645
+ * order, reference identity, descriptor metadata (writable/configurable,
1646
+ * frozen/sealed/extensible), proxy behavior — is DECLARED outside
1647
+ * cache.component's supported input domain rather than rejected:
1648
+ * key-order insensitivity is required for determinism, and rejecting
1649
+ * frozen objects would unkey ubiquitous legitimate inputs
1650
+ * (Object.freeze'd config props) to defend against components that
1651
+ * render differently based on property attributes.
1709
1652
  *
1710
- * Tree structure:
1711
- * ChildSegmentProvider(childContent=innerElement)
1712
- * └── Layout(children=<ChildSegmentOutlet />)
1713
- * └── ChildSegmentOutlet → reads context → innerElement
1653
+ * Two hard rules for this walk itself: it must be TOTAL (never throw
1654
+ * anything but UnkeyableValueError) and SIDE-EFFECT-FREE (descriptor
1655
+ * reads only; never invoke user code such as getters).
1714
1656
  *
1715
- * See design/45-cache-lifetimes.md §Layout Propagation.
1657
+ * Object keys sort recursively so `{a, b}` and `{b, a}` hash identically
1658
+ * (design/13-security.md checklist #14 — cache key determinism; key order
1659
+ * is deliberately NOT part of identity).
1716
1660
  */
1717
- //#endregion
1718
- //#region src/server/route-element-builder.ts
1661
+ var UnkeyableValueError = class extends Error {};
1662
+ function canonicalize$1(value, seen) {
1663
+ if (value === null) return "null";
1664
+ switch (typeof value) {
1665
+ case "string": return JSON.stringify(value);
1666
+ case "boolean": return value ? "true" : "false";
1667
+ case "number":
1668
+ if (!Number.isFinite(value)) throw new UnkeyableValueError("non-finite number");
1669
+ return Object.is(value, -0) ? "-0" : String(value);
1670
+ case "object": break;
1671
+ case "undefined": return "undefined";
1672
+ default: throw new UnkeyableValueError(`unsupported type: ${typeof value}`);
1673
+ }
1674
+ const obj = value;
1675
+ if (seen.has(obj)) throw new UnkeyableValueError("repeated object reference (cycle or alias)");
1676
+ seen.add(obj);
1677
+ if (Object.getOwnPropertySymbols(obj).length > 0) throw new UnkeyableValueError("symbol-keyed property");
1678
+ {
1679
+ if (Array.isArray(obj)) {
1680
+ if (Object.getOwnPropertyNames(obj).length !== obj.length + 1) throw new UnkeyableValueError("non-index array property");
1681
+ const parts = [];
1682
+ for (let i = 0; i < obj.length; i++) {
1683
+ const desc = Object.getOwnPropertyDescriptor(obj, i);
1684
+ if (desc === void 0) throw new UnkeyableValueError("sparse array hole");
1685
+ if (desc.get !== void 0 || desc.set !== void 0) throw new UnkeyableValueError("accessor property");
1686
+ if (!desc.enumerable) throw new UnkeyableValueError("non-enumerable array element");
1687
+ parts.push(canonicalize$1(desc.value, seen));
1688
+ }
1689
+ return "[" + parts.join(",") + "]";
1690
+ }
1691
+ if (Object.getPrototypeOf(obj) !== Object.prototype) throw new UnkeyableValueError("non-plain object");
1692
+ const keys = Object.keys(obj).sort();
1693
+ if (keys.length !== Object.getOwnPropertyNames(obj).length) throw new UnkeyableValueError("non-enumerable property");
1694
+ const parts = [];
1695
+ for (const key of keys) {
1696
+ const desc = Object.getOwnPropertyDescriptor(obj, key);
1697
+ if (desc.get !== void 0 || desc.set !== void 0) throw new UnkeyableValueError("accessor property");
1698
+ parts.push(JSON.stringify(key) + ":" + canonicalize$1(desc.value, seen));
1699
+ }
1700
+ return "{" + parts.join(",") + "}";
1701
+ }
1702
+ }
1719
1703
  /**
1720
- * Thrown when a defineSegmentParams codec's parse() fails.
1721
- * The pipeline catches this and responds with 404.
1704
+ * Canonicalize a single value, or null if it is unkeyable. The ONE
1705
+ * definition of "faithfully comparable" — every prebuilt code path that
1706
+ * needs value equality (cache keys, slot-param divergence) goes through
1707
+ * this, so the unkeyable rules (accessors, hidden props, aliases,
1708
+ * bigints, …) live in exactly one place.
1722
1709
  */
1723
- var ParamCoercionError = class extends Error {
1724
- constructor(message) {
1725
- super(message);
1726
- this.name = "ParamCoercionError";
1710
+ function tryCanonicalize(value) {
1711
+ try {
1712
+ return canonicalize$1(value, /* @__PURE__ */ new Set());
1713
+ } catch (error) {
1714
+ return toUnkeyable(error);
1727
1715
  }
1728
- };
1729
- //#endregion
1730
- //#region src/server/pipeline-metadata.ts
1716
+ }
1731
1717
  /**
1732
- * Metadata route helpers for the request pipeline.
1733
- *
1734
- * Handles serving static metadata files and serializing sitemap responses.
1735
- * Extracted from pipeline.ts to keep files under 500 lines.
1736
- *
1737
- * See design/16-metadata.md §"Metadata Routes"
1738
- */
1739
- /**
1740
- * Content types that are text-based and should include charset=utf-8.
1741
- * Binary formats (images) should not include charset.
1718
+ * Totality by construction: ANY throw from the walk means the input is
1719
+ * outside the keyable domain. The walk touches only user-controlled
1720
+ * values through reflection ops — and a Proxy trap can throw arbitrary
1721
+ * errors from any of them (codex review on PR #835) — so converting only
1722
+ * UnkeyableValueError would let hostile/exotic inputs abort a render
1723
+ * that plain live rendering would have served fine. Unexpected error
1724
+ * types are logged for visibility (they would indicate either a proxy
1725
+ * trap or a canonicalizer bug) but still degrade to unkeyable.
1742
1726
  */
1743
- var TEXT_CONTENT_TYPES = /* @__PURE__ */ new Set([
1744
- "application/xml",
1745
- "text/plain",
1746
- "application/json",
1747
- "application/manifest+json",
1748
- "image/svg+xml"
1749
- ]);
1727
+ function toUnkeyable(error) {
1728
+ if (!(error instanceof UnkeyableValueError)) console.error("[timber] cache key canonicalization threw — treating input as unkeyable:", error);
1729
+ return null;
1730
+ }
1750
1731
  /**
1751
- * Serve a static metadata file by reading it from disk.
1732
+ * Split a props record into data props (participate in the cache key) and
1733
+ * slot values (React-element holes, excluded from the key) per the
1734
+ * component's declared `slots` option (TIM-1173).
1752
1735
  *
1753
- * Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
1754
- * are served as-is with the appropriate Content-Type header.
1755
- * Text files include charset=utf-8; binary files do not.
1736
+ * Every DECLARED slot lands in `slotValues` — present or not — because the
1737
+ * capture render always injects a SlotOutlet placeholder for each declared
1738
+ * slot: the cached shell is identical whether the caller passed the slot
1739
+ * or omitted it, so slot presence must not vary the key. `dataProps` is
1740
+ * everything else and is what gets hashed.
1756
1741
  *
1757
- * See design/16-metadata.md §"Metadata Routes"
1742
+ * Returns null when the props OBJECT itself is outside the identity
1743
+ * contract at the top level — accessors, symbol-keyed or non-enumerable
1744
+ * properties, or a non-`Object.prototype` prototype. Splitting such an
1745
+ * object would normalize away observable state (and reading an accessor
1746
+ * would invoke user code, which this walk must never do) BEFORE
1747
+ * `computeComponentCacheKey` gets the chance to reject it — a cached
1748
+ * shell could then be served for inputs a live render distinguishes.
1749
+ * Null means unkeyable: the caller renders live with the original props.
1750
+ * The walk is descriptor-reads only; getters are never invoked. Nested
1751
+ * data-prop values still face the full allowlist when hashed.
1758
1752
  */
1759
- async function serveStaticMetadataFile(metaMatch) {
1760
- const { contentType, file } = metaMatch;
1761
- const isText = TEXT_CONTENT_TYPES.has(contentType);
1762
- const body = await readFile(file.filePath);
1763
- const headers = {
1764
- "Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
1765
- "Content-Length": String(body.byteLength),
1766
- "Cache-Control": "public, max-age=14400, must-revalidate"
1767
- };
1768
- return new Response(body, {
1769
- status: 200,
1770
- headers
1753
+ /** Create an own enumerable data property — safe for keys like `__proto__`. */
1754
+ function defineOwn(target, key, value) {
1755
+ Object.defineProperty(target, key, {
1756
+ value,
1757
+ enumerable: true,
1758
+ writable: true,
1759
+ configurable: true
1771
1760
  });
1772
1761
  }
1773
- /**
1774
- * Serialize a sitemap array to XML.
1775
- * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
1776
- */
1777
- function serializeSitemap(entries) {
1778
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
1779
- let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
1780
- if (e.lastModified) {
1781
- const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
1782
- xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
1762
+ function splitSlotProps(props, slots) {
1763
+ try {
1764
+ if (Object.getPrototypeOf(props) !== Object.prototype) return null;
1765
+ if (Object.getOwnPropertySymbols(props).length > 0) return null;
1766
+ const slotSet = new Set(slots);
1767
+ const dataProps = {};
1768
+ const slotValues = {};
1769
+ for (const key of Object.getOwnPropertyNames(props)) {
1770
+ const desc = Object.getOwnPropertyDescriptor(props, key);
1771
+ if (desc === void 0 || desc.get !== void 0 || desc.set !== void 0 || !desc.enumerable) return null;
1772
+ defineOwn(slotSet.has(key) ? slotValues : dataProps, key, desc.value);
1783
1773
  }
1784
- if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
1785
- if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
1786
- xml += "\n </url>";
1787
- return xml;
1788
- }).join("\n")}\n</urlset>`;
1789
- }
1790
- /** Escape special XML characters. */
1791
- function escapeXml(str) {
1792
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1774
+ for (const slot of slotSet) if (!Object.hasOwn(slotValues, slot)) defineOwn(slotValues, slot, void 0);
1775
+ return {
1776
+ dataProps,
1777
+ slotValues
1778
+ };
1779
+ } catch (error) {
1780
+ return toUnkeyable(error);
1781
+ }
1793
1782
  }
1794
- //#endregion
1795
- //#region src/server/pipeline-interception.ts
1796
- /**
1797
- * Interception route matching for the request pipeline.
1798
- *
1799
- * Matches target URLs against interception rewrites to support the
1800
- * modal route pattern (soft navigation intercepts).
1801
- *
1802
- * Extracted from pipeline.ts to keep files under 500 lines.
1803
- *
1804
- * See design/07-routing.md §"Intercepting Routes"
1805
- */
1806
1783
  /**
1807
- * Check if a pathname starts with a prefix on a segment boundary.
1784
+ * Compute the cache-entry hash for a component render:
1785
+ * `sha256({props, params}).slice(0, 16)` over canonical JSON.
1808
1786
  *
1809
- * Prevents /feed from matching /feed-private — the prefix must be
1810
- * followed by '/' or be an exact match.
1787
+ * Returns null when props (or params) contain unkeyable values — the
1788
+ * caller must fall back to dynamic rendering.
1811
1789
  */
1812
- function hasSegmentPrefix(pathname, prefix) {
1813
- if (prefix === "/") return true;
1814
- if (!pathname.startsWith(prefix)) return false;
1815
- return pathname.length === prefix.length || pathname[prefix.length] === "/";
1790
+ function computeComponentCacheKey(props, params) {
1791
+ let canonical;
1792
+ try {
1793
+ const seen = /* @__PURE__ */ new Set();
1794
+ canonical = "{\"params\":" + canonicalize$1({ ...params }, seen) + ",\"props\":" + canonicalize$1(props, seen) + "}";
1795
+ } catch (error) {
1796
+ return toUnkeyable(error);
1797
+ }
1798
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
1816
1799
  }
1817
1800
  /**
1818
- * Check if an intercepting route applies for this soft navigation.
1801
+ * Is this value the framework-injected layout children slot outlet
1802
+ * (`<SlotOutlet slot={LAYOUT_CHILDREN_SLOT} />`)? Layout components
1803
+ * receive this as their `children` prop — a stable client reference
1804
+ * whose cache key is stripped so layout `cache.component` hits work
1805
+ * (design/45 §Layout Propagation).
1819
1806
  *
1820
- * Matches the target pathname against interception rewrites, constrained
1821
- * by the source URL (X-Timber-URL header — where the user navigates FROM).
1807
+ * Uses a framework-reserved slot name (LAYOUT_CHILDREN_SLOT) that
1808
+ * cannot collide with user-declared slot names, preventing context
1809
+ * shadowing when a cached component with its own SlotsProvider is
1810
+ * nested inside a layout. See TIM-1191.
1822
1811
  *
1823
- * Returns the source pathname to re-match if interception applies, or null.
1812
+ * Total: the probes run against a user-controlled value, and a Proxy
1813
+ * trap can throw — that must classify as "not the outlet" (the
1814
+ * canonicalizer then rejects the value as unkeyable → live render), not
1815
+ * abort the request (codex P2 on PR #890).
1824
1816
  */
1825
- function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
1826
- for (const rewrite of rewrites) {
1827
- if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
1828
- if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
1817
+ function isChildrenSlotOutletElement(value) {
1818
+ try {
1819
+ if (value == null || typeof value !== "object") return false;
1820
+ if (!("type" in value)) return false;
1821
+ const el = value;
1822
+ return el.type === SlotOutlet && el.props?.slot === "\0timber:children";
1823
+ } catch {
1824
+ return false;
1829
1825
  }
1830
- return null;
1831
1826
  }
1832
1827
  /**
1833
- * Check if a pathname matches a URL pattern with dynamic segments.
1834
- *
1835
- * Supports [param] (single segment) and [...param] (one or more segments).
1836
- * Static segments must match exactly.
1828
+ * Dense array of non-empty strings — no holes, no non-string entries.
1829
+ * Total: the reads run against a user-controlled value at module load,
1830
+ * and a throwing Proxy trap must classify as invalid, not crash startup.
1837
1831
  */
1838
- function pathnameMatchesPattern(pathname, pattern) {
1839
- const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
1840
- const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
1841
- let pi = 0;
1842
- for (let i = 0; i < patternParts.length; i++) {
1843
- const seg = classifyUrlSegment(patternParts[i]);
1844
- switch (seg.kind) {
1845
- case "catch-all": return pi < pathParts.length;
1846
- case "optional-catch-all": return true;
1847
- case "dynamic": {
1848
- if (pi >= pathParts.length) return false;
1849
- const prefix = seg.prefix ?? "";
1850
- const suffix = seg.suffix ?? "";
1851
- const pathPart = pathParts[pi];
1852
- if (prefix || suffix) {
1853
- if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
1854
- }
1855
- pi++;
1856
- continue;
1857
- }
1858
- case "static":
1859
- if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
1860
- pi++;
1861
- continue;
1832
+ function isDenseStringArray(value) {
1833
+ try {
1834
+ if (!Array.isArray(value)) return false;
1835
+ for (let i = 0; i < value.length; i++) {
1836
+ if (!Object.hasOwn(value, i)) return false;
1837
+ const entry = value[i];
1838
+ if (typeof entry !== "string" || entry.length === 0) return false;
1862
1839
  }
1840
+ return true;
1841
+ } catch {
1842
+ return false;
1863
1843
  }
1864
- return pi === pathParts.length;
1865
1844
  }
1866
- //#endregion
1867
- //#region src/server/pipeline-outcome.ts
1868
1845
  /**
1869
- * Pipeline outcome translator — converts a `PhaseOutcome` (the value
1870
- * each phase function returns) into a final `Response`.
1871
- *
1872
- * Lifted out of `pipeline-phases.ts` (TIM-853) so the per-phase try /
1873
- * catch logic and the terminal Response-building logic each live in
1874
- * their own file. The phases produce values; this module is the single
1875
- * source of truth for how those values become wire responses.
1876
- *
1877
- * See design/07-routing.md §"Request Lifecycle".
1846
+ * Validate the `slots` option. Both the production and dev wrappers gate
1847
+ * on this — slot substitution is active in dev exactly when it would be
1848
+ * active in production, so the tree shape a component sees never
1849
+ * diverges between the two.
1878
1850
  */
1879
- function rscErrorResponse(isRsc, status, headers) {
1880
- if (!isRsc) return new Response(null, { status });
1881
- const h = headers ?? new Headers();
1882
- h.set("X-Timber-Error", "1");
1883
- h.set("content-type", "application/json; charset=utf-8");
1884
- return new Response(JSON.stringify({
1885
- error: true,
1886
- status
1887
- }), {
1888
- status,
1889
- headers: h
1890
- });
1851
+ function resolveSlotNames(id, options) {
1852
+ const slots = options.slots;
1853
+ if (slots === void 0) return { kind: "none" };
1854
+ if (!isDenseStringArray(slots)) {
1855
+ console.warn(`[timber] cache.component "${id}": invalid slots declaration — expected an array of non-empty prop-name strings. Slot caching is disabled.`);
1856
+ return { kind: "disabled" };
1857
+ }
1858
+ if (slots.length === 0) return { kind: "none" };
1859
+ if (!(options.ttl !== void 0 || options.tags != null)) {
1860
+ console.warn(`[timber] cache.component "${id}": slots require a runtime lifetime (ttl and/or tags). Slot caching is disabled.`);
1861
+ return { kind: "disabled" };
1862
+ }
1863
+ return {
1864
+ kind: "active",
1865
+ slots
1866
+ };
1891
1867
  }
1892
1868
  /**
1893
- * Terminal outcome handler — converts a `PhaseOutcome` into a final
1894
- * `Response`, applying cookies, building redirects, rendering deny pages
1895
- * and fallback error pages, and firing instrumentation hooks.
1869
+ * Split the live props into the cacheable shell render (data props plus a
1870
+ * deterministic SlotOutlet placeholder per declared slot — identical
1871
+ * whether or not the caller passed the slot) and the per-instance live
1872
+ * slot values.
1896
1873
  *
1897
- * This is the single source of truth for how phase outputs become wire
1898
- * responses; the per-phase try/catch blocks now produce values, not
1899
- * Responses, so the conversion logic lives in exactly one place.
1874
+ * Null when the props object itself is unkeyable at the top level
1875
+ * (accessors, hidden properties, exotic prototype — see splitSlotProps):
1876
+ * the caller must render live with the original props.
1900
1877
  */
1901
- async function outcomeToResponse(config, outcome, ctx) {
1902
- switch (outcome.kind) {
1903
- case "response": {
1904
- const finalResponse = cloneWithMutableHeaders(outcome.response);
1905
- if (outcome.phase === "proxy") return finalResponse;
1906
- if (outcome.phase === "middleware" && ctx.responseHeaders) {
1907
- applyCookieJar(finalResponse.headers);
1908
- mergeMissingHeaders(finalResponse.headers, ctx.responseHeaders);
1909
- logMiddlewareShortCircuit({
1910
- method: ctx.method,
1911
- path: ctx.path,
1912
- status: finalResponse.status
1913
- });
1878
+ function prepareSlotRender(props, slots) {
1879
+ const split = splitSlotProps(props ?? {}, slots);
1880
+ if (split === null) return null;
1881
+ const { dataProps, slotValues } = split;
1882
+ const shellProps = { ...dataProps };
1883
+ for (const slot of slots) Object.defineProperty(shellProps, slot, {
1884
+ value: createElement(SlotOutlet, { slot }),
1885
+ enumerable: true,
1886
+ writable: true,
1887
+ configurable: true
1888
+ });
1889
+ return {
1890
+ shellProps,
1891
+ dataProps,
1892
+ slotValues
1893
+ };
1894
+ }
1895
+ /**
1896
+ * Wrap a rendered shell (revived payload or live element) in a
1897
+ * per-instance SlotsProvider feeding the live values to its outlets.
1898
+ */
1899
+ function wrapInSlotsProvider(shell, slotValues) {
1900
+ return createElement(SlotsProvider, {
1901
+ values: slotValues,
1902
+ children: shell
1903
+ });
1904
+ }
1905
+ //#endregion
1906
+ //#region src/server/param-coercion.ts
1907
+ /** Global schema codecs, set once at startup from virtual:timber-schema. */
1908
+ var _globalCodecs = null;
1909
+ /**
1910
+ * Run segment param coercion on the matched route's segments.
1911
+ *
1912
+ * When `globalCodecs` is provided (from app/schema.ts), uses the global
1913
+ * codec map keyed by bare param name. Otherwise falls back to loading
1914
+ * per-segment params.ts modules.
1915
+ *
1916
+ * Throws ParamCoercionError if any codec fails (→ 404).
1917
+ *
1918
+ * This runs BEFORE middleware, so ctx.segmentParams is already typed.
1919
+ * See design/07-routing.md §"Where Coercion Runs"
1920
+ * See design/41-global-params.md §"Pipeline Integration"
1921
+ */
1922
+ async function coerceSegmentParams(match) {
1923
+ const globalCodecs = _globalCodecs;
1924
+ const mergeTarget = Object.create(null);
1925
+ for (const key of Object.keys(match.segmentParams)) if (key !== "__proto__") mergeTarget[key] = match.segmentParams[key];
1926
+ match.segmentParams = mergeTarget;
1927
+ if (globalCodecs) {
1928
+ for (const segment of match.segments) {
1929
+ if (!segment.paramName) continue;
1930
+ const bracketKey = toBracketKey(segment.segmentType, segment.paramName);
1931
+ const codec = globalCodecs[bracketKey];
1932
+ if (!codec) continue;
1933
+ const key = segment.paramName;
1934
+ if (!(key in mergeTarget)) continue;
1935
+ try {
1936
+ mergeTarget[key] = sanitizeParamValue(codec.parse(mergeTarget[key]));
1937
+ } catch (err) {
1938
+ const message = err instanceof Error ? err.message : String(err);
1939
+ if (isDebug()) console.warn(`[timber] Global schema codec rejected value for param "${key}"\n Error: ${message}\n Raw value: ${JSON.stringify(mergeTarget[key])}\n Hint: check the codec in app/schema.ts for key '${bracketKey}'.`);
1940
+ throw new ParamCoercionError(message);
1914
1941
  }
1915
- if (outcome.phase === "render") markResponseFlushed();
1916
- return finalResponse;
1917
- }
1918
- case "redirect": {
1919
- const headers = ctx.responseHeaders ?? new Headers();
1920
- applyCookieJar(headers);
1921
- return buildRedirectResponse(outcome.signal, ctx.req, headers);
1922
1942
  }
1923
- case "deny": {
1924
- const headers = ctx.responseHeaders ?? new Headers();
1925
- applyCookieJar(headers);
1926
- if (config.renderDenyFallback) try {
1927
- return cloneWithMutableHeaders(await config.renderDenyFallback(outcome.signal, ctx.req, headers, ctx.match));
1928
- } catch (denyRenderError) {
1929
- logRenderError({
1930
- method: ctx.method,
1931
- path: ctx.path,
1932
- error: denyRenderError
1933
- });
1934
- await fireOnRequestError(denyRenderError, ctx.req, "render");
1935
- if (config.onPipelineError && denyRenderError instanceof Error) config.onPipelineError(denyRenderError, "render");
1936
- }
1937
- if (isDebug()) console.warn(`[timber] DenySignal(${outcome.signal.status}) from ${outcome.phase} phase — no renderDenyFallback configured, returning bare ${outcome.signal.status} response\n Request: ${ctx.method} ${ctx.path}\n Add a not-found.tsx or error.tsx to render a custom deny page.`);
1938
- return new Response(null, {
1939
- status: outcome.signal.status,
1940
- headers
1941
- });
1943
+ return;
1944
+ }
1945
+ for (const segment of match.segments) {
1946
+ if (!segment.params) continue;
1947
+ let mod;
1948
+ try {
1949
+ mod = await loadModule(segment.params);
1950
+ } catch (err) {
1951
+ const message = `Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`;
1952
+ if (isDebug()) console.warn(`[timber] Param coercion error: ${message}\n Segment: ${segment.segmentName}\n Params file: ${segment.params}`);
1953
+ throw new ParamCoercionError(message);
1942
1954
  }
1943
- case "error": {
1944
- const isRsc = (ctx.req.headers.get("Accept") ?? "").includes("text/x-component");
1945
- if (outcome.phase === "proxy") {
1946
- logProxyError({ error: outcome.error });
1947
- await fireOnRequestError(outcome.error, ctx.req, "proxy");
1948
- if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "proxy");
1949
- return rscErrorResponse(isRsc, 500);
1950
- }
1951
- if (outcome.phase === "middleware") {
1952
- logMiddlewareError({
1953
- method: ctx.method,
1954
- path: ctx.path,
1955
- error: outcome.error
1956
- });
1957
- await fireOnRequestError(outcome.error, ctx.req, "handler");
1958
- if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "middleware");
1959
- return rscErrorResponse(isRsc, 500);
1960
- }
1961
- const headers = ctx.responseHeaders ?? new Headers();
1962
- applyCookieJar(headers);
1963
- logRenderError({
1964
- method: ctx.method,
1965
- path: ctx.path,
1966
- error: outcome.error
1967
- });
1968
- await fireOnRequestError(outcome.error, ctx.req, "render");
1969
- if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "render");
1970
- if (isRsc) return rscErrorResponse(true, 500, headers);
1971
- if (config.renderFallbackError) try {
1972
- return cloneWithMutableHeaders(await config.renderFallbackError(outcome.error, ctx.req, headers));
1973
- } catch (fallbackRenderError) {
1974
- logRenderError({
1975
- method: ctx.method,
1976
- path: ctx.path,
1977
- error: fallbackRenderError
1978
- });
1979
- await fireOnRequestError(fallbackRenderError, ctx.req, "render");
1980
- if (config.onPipelineError && fallbackRenderError instanceof Error) config.onPipelineError(fallbackRenderError, "render");
1955
+ const segmentParamsDef = mod.segmentParams;
1956
+ if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
1957
+ try {
1958
+ const coerced = segmentParamsDef.parse(match.segmentParams);
1959
+ for (const key of Object.keys(coerced)) if (key !== "__proto__") mergeTarget[key] = sanitizeParamValue(coerced[key]);
1960
+ } catch (err) {
1961
+ const message = err instanceof Error ? err.message : String(err);
1962
+ if (isDebug()) {
1963
+ const rawKeys = Object.keys(match.segmentParams).join(", ");
1964
+ console.warn(`[timber] Param codec rejected values for segment "${segment.segmentName}"\n Error: ${message}\n Available raw params: { ${rawKeys} }\n Params file: ${segment.params}\n Hint: this usually means a codec threw for the raw URL value.\n Check that the regex/schema accepts the actual path segment string.`);
1981
1965
  }
1982
- return new Response(null, { status: 500 });
1966
+ throw new ParamCoercionError(message);
1983
1967
  }
1984
1968
  }
1985
1969
  }
1986
1970
  //#endregion
1987
- //#region src/server/pipeline-phases.ts
1971
+ //#region src/client/segment-update-context.ts
1988
1972
  /**
1989
- * Pipeline phase functions — module-level free functions that take their
1990
- * dependencies as explicit parameters. Each phase returns a `PhaseOutcome`
1991
- * (a discriminated union over response / redirect / deny / error) defined
1992
- * in `pipeline-outcome.ts`. The terminal `outcomeToResponse` (also in
1993
- * `pipeline-outcome.ts`) translates outcomes into Responses.
1973
+ * SegmentUpdateContext — React context for partial navigation updates.
1994
1974
  *
1995
- * Lifted out of `createPipeline` so each phase can be unit-tested in
1996
- * isolation. The lift is mechanical — these functions used to be closures
1997
- * over `config`; they now take `config` as an explicit parameter.
1975
+ * During partial navigation (server skips unchanged sync layouts), the
1976
+ * router builds a Map of segment path → ReactNode updates and passes it
1977
+ * as the context value. Mounted SegmentOutlet components read from this
1978
+ * context to decide whether to render the update or their cached content.
1998
1979
  *
1999
- * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
1980
+ * SINGLETON GUARANTEE: Uses globalThis + Symbol.for — same pattern as
1981
+ * NavigationContext. The RSC client bundler can duplicate this module
1982
+ * across chunks (browser-entry graph + client-reference graph). With
1983
+ * ESM output, each chunk gets its own module scope — a bare createContext
1984
+ * at module level would create separate instances per chunk. globalThis
1985
+ * guarantees a single instance regardless of duplication.
1986
+ *
1987
+ * See design/19-client-navigation.md §"Singleton Guarantee via globalThis"
2000
1988
  */
1989
+ var EMPTY_SEGMENT_UPDATES = /* @__PURE__ */ new Map();
1990
+ var CTX_KEY = Symbol.for("__timber_segment_update_ctx");
1991
+ function getOrCreateContext() {
1992
+ const existing = globalThis[CTX_KEY];
1993
+ if (existing !== void 0) return existing;
1994
+ if (typeof React.createContext === "function") {
1995
+ const ctx = React.createContext(EMPTY_SEGMENT_UPDATES);
1996
+ globalThis[CTX_KEY] = ctx;
1997
+ return ctx;
1998
+ }
1999
+ }
2000
+ getOrCreateContext();
2001
+ //#endregion
2002
+ //#region src/client/segment-outlet.tsx
2001
2003
  /**
2002
- * Validate and canonicalize the X-Timber-URL header value.
2004
+ * SegmentOutlet — client component boundary at each layout segment.
2003
2005
  *
2004
- * Returns the canonical pathname if valid, or null if rejected:
2005
- * - Must start with '/' (relative pathname, no scheme/authority)
2006
- * - Must not contain control characters
2007
- * - Must pass canonicalization (no encoded separators, null bytes, etc.)
2006
+ * Each layout in the segment tree is wrapped with a SegmentOutlet that:
2007
+ * 1. Knows its segment path (prop from the server)
2008
+ * 2. Reads from SegmentUpdateContext for partial navigation updates
2009
+ * 3. Caches its rendered content in a ref across navigations
2010
+ *
2011
+ * On full navigation: receives new children via props, caches and renders them.
2012
+ * On partial navigation (this segment skipped): the context map has no entry
2013
+ * for this path, so the outlet returns cached content — preserving layout state.
2014
+ * On partial navigation (this segment updated): the context map has content
2015
+ * for this path, so the outlet renders the update.
2016
+ *
2017
+ * Uses React context instead of useSyncExternalStore to stay compatible
2018
+ * with concurrent rendering (transitions). All outlets re-render when the
2019
+ * context value changes, but each bails out quickly if its segment has
2020
+ * no update — same approach as Next.js LayoutRouter.
2021
+ *
2022
+ * Security: performance optimization only. The server always runs all
2023
+ * access.ts files regardless of segment skipping.
2024
+ * See design/13-security.md §"State tree manipulation".
2008
2025
  */
2009
- function validateInterceptionHeader(raw, stripTrailingSlash) {
2010
- if (!raw.startsWith("/")) return null;
2011
- if (raw.startsWith("//")) return null;
2012
- for (let i = 0; i < raw.length; i++) {
2013
- const code = raw.charCodeAt(i);
2014
- if (code <= 31 || code === 127) return null;
2026
+ //#endregion
2027
+ //#region src/server/route-element-builder.ts
2028
+ /**
2029
+ * Thrown when a defineSegmentParams codec's parse() fails.
2030
+ * The pipeline catches this and responds with 404.
2031
+ */
2032
+ var ParamCoercionError = class extends Error {
2033
+ constructor(message) {
2034
+ super(message);
2035
+ this.name = "ParamCoercionError";
2015
2036
  }
2016
- const result = canonicalize(raw, stripTrailingSlash);
2017
- if (!result.ok) return null;
2018
- return result.pathname;
2019
- }
2037
+ };
2038
+ //#endregion
2039
+ //#region src/server/pipeline-metadata.ts
2020
2040
  /**
2021
- * Run the proxy.ts phase. Calls user proxy code with a `next()` continuation.
2041
+ * Metadata route helpers for the request pipeline.
2022
2042
  *
2023
- * When `next` is provided, it replaces the default `handleRequest` as the
2024
- * continuation — this lets the pipeline inject action dispatch between proxy
2025
- * and route matching (TIM-1213). The proxy resolver was picked at pipeline
2026
- * construction time so the hot path sees no per-request branching on the
2027
- * `ProxyConfig` discriminant.
2043
+ * Handles serving static metadata files and serializing sitemap responses.
2044
+ * Extracted from pipeline.ts to keep files under 500 lines.
2045
+ *
2046
+ * See design/16-metadata.md §"Metadata Routes"
2028
2047
  */
2029
- async function runProxyPhase(config, getProxy, req, method, path, next) {
2030
- const detailed = config.serverTiming === "detailed";
2031
- try {
2032
- const proxyExport = await getProxy();
2033
- const continuation = next ?? (() => handleRequest(config, req, method, path, true));
2034
- const proxyFn = () => runProxy(proxyExport, req, continuation);
2035
- return {
2036
- kind: "response",
2037
- phase: "proxy",
2038
- response: await withSpan("timber.proxy", {}, () => detailed ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn())
2039
- };
2040
- } catch (error) {
2041
- if (error instanceof RedirectSignal) return {
2042
- kind: "redirect",
2043
- phase: "proxy",
2044
- signal: error
2045
- };
2046
- if (error instanceof DenySignal) return {
2047
- kind: "deny",
2048
- phase: "proxy",
2049
- signal: error
2050
- };
2051
- return {
2052
- kind: "error",
2053
- phase: "proxy",
2054
- error
2055
- };
2056
- }
2057
- }
2058
2048
  /**
2059
- * Run the middleware chain phase. If the chain short-circuits with a Response,
2060
- * returns it as a 'response' outcome. Otherwise applies the request header
2061
- * overlay and falls through to the render phase.
2049
+ * Content types that are text-based and should include charset=utf-8.
2050
+ * Binary formats (images) should not include charset.
2062
2051
  */
2063
- async function runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext) {
2064
- const detailed = config.serverTiming === "detailed";
2065
- const ctx = {
2066
- req,
2067
- requestHeaders: requestHeaderOverlay,
2068
- headers: responseHeaders,
2069
- segmentParams: match.segmentParams,
2070
- earlyHints: (hints) => {
2071
- for (const hint of hints) {
2072
- let value;
2073
- if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
2074
- else value = `<${hint.href}>; rel=${hint.rel}`;
2075
- if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2076
- if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2077
- responseHeaders.append("Link", value);
2078
- }
2079
- }
2052
+ var TEXT_CONTENT_TYPES = /* @__PURE__ */ new Set([
2053
+ "application/xml",
2054
+ "text/plain",
2055
+ "application/json",
2056
+ "application/manifest+json",
2057
+ "image/svg+xml"
2058
+ ]);
2059
+ /**
2060
+ * Serve a static metadata file by reading it from disk.
2061
+ *
2062
+ * Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
2063
+ * are served as-is with the appropriate Content-Type header.
2064
+ * Text files include charset=utf-8; binary files do not.
2065
+ *
2066
+ * See design/16-metadata.md §"Metadata Routes"
2067
+ */
2068
+ async function serveStaticMetadataFile(metaMatch) {
2069
+ const { contentType, file } = metaMatch;
2070
+ const isText = TEXT_CONTENT_TYPES.has(contentType);
2071
+ const body = await readFile(file.filePath);
2072
+ const headers = {
2073
+ "Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
2074
+ "Content-Length": String(body.byteLength),
2075
+ "Cache-Control": "public, max-age=14400, must-revalidate"
2080
2076
  };
2081
- try {
2082
- const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
2083
- const middlewareResponse = await (async () => {
2084
- setMutableCookieContext(true);
2085
- try {
2086
- return await withSpan("timber.middleware", {}, () => detailed ? withTiming("mw", "middleware.ts", chainFn) : chainFn());
2087
- } finally {
2088
- setMutableCookieContext(false);
2089
- }
2090
- })();
2091
- if (middlewareResponse) return {
2092
- kind: "response",
2093
- phase: "middleware",
2094
- response: middlewareResponse
2095
- };
2096
- applyRequestHeaderOverlay(requestHeaderOverlay);
2097
- applyCookieJar(responseHeaders);
2098
- return runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext);
2099
- } catch (error) {
2100
- if (error instanceof RedirectSignal) return {
2101
- kind: "redirect",
2102
- phase: "middleware",
2103
- signal: error
2104
- };
2105
- if (error instanceof DenySignal) return {
2106
- kind: "deny",
2107
- phase: "middleware",
2108
- signal: error
2109
- };
2110
- return {
2111
- kind: "error",
2112
- phase: "middleware",
2113
- error
2114
- };
2115
- }
2077
+ return new Response(body, {
2078
+ status: 200,
2079
+ headers
2080
+ });
2116
2081
  }
2117
2082
  /**
2118
- * Run the render phase. Wraps the configured renderer in a span and a
2119
- * timing scope, and translates thrown signals into outcome variants.
2083
+ * Serialize a sitemap array to XML.
2084
+ * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
2120
2085
  */
2121
- async function runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, { canonicalPathname, interception }) {
2122
- const detailed = config.serverTiming === "detailed";
2123
- try {
2124
- const renderFn = () => config.render(req, match, responseHeaders, requestHeaderOverlay, interception);
2125
- return {
2126
- kind: "response",
2127
- phase: "render",
2128
- response: await withSpan("timber.render", { "http.route": canonicalPathname }, () => detailed ? withTiming("render", "RSC + SSR render", renderFn) : renderFn())
2129
- };
2130
- } catch (error) {
2131
- if (error instanceof DenySignal) return {
2132
- kind: "deny",
2133
- phase: "render",
2134
- signal: error
2135
- };
2136
- if (error instanceof RedirectSignal) return {
2137
- kind: "redirect",
2138
- phase: "render",
2139
- signal: error
2140
- };
2141
- return {
2142
- kind: "error",
2143
- phase: "render",
2144
- error
2145
- };
2146
- }
2086
+ function serializeSitemap(entries) {
2087
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
2088
+ let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
2089
+ if (e.lastModified) {
2090
+ const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
2091
+ xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
2092
+ }
2093
+ if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
2094
+ if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
2095
+ xml += "\n </url>";
2096
+ return xml;
2097
+ }).join("\n")}\n</urlset>`;
2098
+ }
2099
+ /** Escape special XML characters. */
2100
+ function escapeXml(str) {
2101
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2147
2102
  }
2103
+ //#endregion
2104
+ //#region src/server/pipeline-interception.ts
2148
2105
  /**
2149
- * Process a single request from canonicalization through phase dispatch.
2106
+ * Interception route matching for the request pipeline.
2150
2107
  *
2151
- * Stages: canonicalize → metadata routes → auto-sitemap → version skew →
2152
- * route match → interception → early hints → param coercion → middleware →
2153
- * render → outcome translation. Pre-routing short-circuits return Responses
2154
- * directly; post-match dispatch goes through `outcomeToResponse`.
2108
+ * Matches target URLs against interception rewrites to support the
2109
+ * modal route pattern (soft navigation intercepts).
2155
2110
  *
2156
- * Used both as the top-level entry (when no proxy.ts is configured) and as
2157
- * the `next()` continuation passed to `runProxy()`.
2111
+ * Extracted from pipeline.ts to keep files under 500 lines.
2158
2112
  *
2159
- * @param pathIsCanonical When true, `path` has already been canonicalized by
2160
- * `createPipeline` — skip re-canonicalization to prevent double-decode.
2161
- * When false (default), runs canonicalize as a safety net for direct callers.
2113
+ * See design/07-routing.md §"Intercepting Routes"
2162
2114
  */
2163
- async function handleRequest(config, req, method, path, pathIsCanonical) {
2164
- const stripTrailingSlash = config.stripTrailingSlash ?? true;
2165
- let canonicalPathname;
2166
- if (pathIsCanonical) canonicalPathname = path;
2167
- else {
2168
- const result = canonicalize(path, stripTrailingSlash);
2169
- if (!result.ok) {
2170
- if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${path} — responding with ${result.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
2171
- return new Response(null, { status: result.status });
2172
- }
2173
- canonicalPathname = result.pathname;
2115
+ /**
2116
+ * Check if a pathname starts with a prefix on a segment boundary.
2117
+ *
2118
+ * Prevents /feed from matching /feed-private — the prefix must be
2119
+ * followed by '/' or be an exact match.
2120
+ */
2121
+ function hasSegmentPrefix(pathname, prefix) {
2122
+ if (prefix === "/") return true;
2123
+ if (!pathname.startsWith(prefix)) return false;
2124
+ return pathname.length === prefix.length || pathname[prefix.length] === "/";
2125
+ }
2126
+ /**
2127
+ * Check if an intercepting route applies for this soft navigation.
2128
+ *
2129
+ * Matches the target pathname against interception rewrites, constrained
2130
+ * by the source URL (X-Timber-URL header — where the user navigates FROM).
2131
+ *
2132
+ * Returns the source pathname to re-match if interception applies, or null.
2133
+ */
2134
+ function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
2135
+ for (const rewrite of rewrites) {
2136
+ if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
2137
+ if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
2174
2138
  }
2175
- if (config.matchMetadataRoute) {
2176
- const metaMatch = config.matchMetadataRoute(canonicalPathname);
2177
- if (metaMatch) try {
2178
- if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
2179
- setSegmentParams(metaMatch.segmentParams);
2180
- const mod = await loadModule(metaMatch.file);
2181
- if (typeof mod.default !== "function") {
2182
- if (isDebug()) console.warn(`[timber] Metadata route ${metaMatch.file} does not export a default function — responding with 500\n Metadata routes must export a default function that returns the metadata content.`);
2183
- return new Response("Metadata route must export a default function", { status: 500 });
2184
- }
2185
- const handlerResult = await mod.default();
2186
- if (handlerResult instanceof Response) {
2187
- if (method === "HEAD") {
2188
- const headHeaders = new Headers(handlerResult.headers);
2189
- if (handlerResult.ok && !headHeaders.has("Cache-Control")) headHeaders.set("Cache-Control", "public, max-age=14400, must-revalidate");
2190
- return new Response(null, {
2191
- status: handlerResult.status,
2192
- statusText: handlerResult.statusText,
2193
- headers: headHeaders
2194
- });
2139
+ return null;
2140
+ }
2141
+ /**
2142
+ * Check if a pathname matches a URL pattern with dynamic segments.
2143
+ *
2144
+ * Supports [param] (single segment) and [...param] (one or more segments).
2145
+ * Static segments must match exactly.
2146
+ */
2147
+ function pathnameMatchesPattern(pathname, pattern) {
2148
+ const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
2149
+ const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
2150
+ let pi = 0;
2151
+ for (let i = 0; i < patternParts.length; i++) {
2152
+ const seg = classifyUrlSegment(patternParts[i]);
2153
+ switch (seg.kind) {
2154
+ case "catch-all": return pi < pathParts.length;
2155
+ case "optional-catch-all": return true;
2156
+ case "dynamic": {
2157
+ if (pi >= pathParts.length) return false;
2158
+ const prefix = seg.prefix ?? "";
2159
+ const suffix = seg.suffix ?? "";
2160
+ const pathPart = pathParts[pi];
2161
+ if (prefix || suffix) {
2162
+ if (pathPart.length <= prefix.length + suffix.length || prefix && !pathPart.startsWith(prefix) || suffix && !pathPart.endsWith(suffix)) return false;
2195
2163
  }
2196
- const res = cloneWithMutableHeaders(handlerResult);
2197
- if (res.ok && !res.headers.has("Cache-Control")) res.headers.set("Cache-Control", "public, max-age=14400, must-revalidate");
2198
- return res;
2164
+ pi++;
2165
+ continue;
2199
2166
  }
2200
- const contentType = metaMatch.contentType;
2201
- let body;
2202
- if (typeof handlerResult === "string") body = handlerResult;
2203
- else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
2204
- else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
2205
- else body = String(handlerResult);
2206
- return new Response(body, {
2207
- status: 200,
2208
- headers: {
2209
- "Content-Type": `${contentType}; charset=utf-8`,
2210
- "Cache-Control": "public, max-age=14400, must-revalidate"
2211
- }
2212
- });
2213
- } catch (error) {
2214
- if (error instanceof RedirectSignal) return new Response(null, {
2215
- status: error.status,
2216
- headers: { Location: error.location }
2217
- });
2218
- if (error instanceof DenySignal) return new Response(null, { status: error.status });
2219
- logRenderError({
2220
- method,
2221
- path,
2222
- error
2223
- });
2224
- if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "metadata-route");
2225
- return new Response(null, { status: 500 });
2226
- }
2227
- }
2228
- if (config.autoSitemapHandler) try {
2229
- const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
2230
- if (sitemapResponse) return cloneWithMutableHeaders(sitemapResponse);
2231
- } catch (error) {
2232
- logRenderError({
2233
- method,
2234
- path,
2235
- error
2236
- });
2237
- if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
2238
- return new Response(null, { status: 500 });
2239
- }
2240
- const isRscRequest = (req.headers.get("Accept") ?? "").includes("text/x-component");
2241
- if (isRscRequest) {
2242
- if (!checkVersionSkew(req).ok) {
2243
- const reloadHeaders = new Headers();
2244
- applyReloadHeaders(reloadHeaders);
2245
- return new Response(null, {
2246
- status: 204,
2247
- headers: reloadHeaders
2248
- });
2249
- }
2250
- }
2251
- let match = config.matchRoute(canonicalPathname);
2252
- let interception;
2253
- if (isRscRequest && config.interceptionRewrites?.length) {
2254
- const rawSourceUrl = req.headers.get("X-Timber-URL");
2255
- const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
2256
- if (validatedSourceUrl) {
2257
- const intercepted = findInterceptionMatch(canonicalPathname, validatedSourceUrl, config.interceptionRewrites);
2258
- if (intercepted) {
2259
- const sourceMatch = config.matchRoute(intercepted.sourcePathname);
2260
- if (sourceMatch) {
2261
- match = sourceMatch;
2262
- interception = { targetPathname: canonicalPathname };
2263
- }
2264
- }
2265
- }
2266
- }
2267
- if (!match) {
2268
- if (isDebug()) console.warn(`[timber] No route matched for pathname: ${canonicalPathname}\n Input path: ${path}\n Method: ${method}`);
2269
- if (config.renderNoMatch) {
2270
- const responseHeaders = new Headers();
2271
- return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
2272
- }
2273
- return new Response(null, { status: 404 });
2274
- }
2275
- const responseHeaders = new Headers();
2276
- const requestHeaderOverlay = new Headers();
2277
- responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
2278
- const leafSegment = match.segments[match.segments.length - 1];
2279
- const isApiRoute = leafSegment?.route && !leafSegment?.page;
2280
- if (config.earlyHints && !isRscRequest && !isApiRoute) try {
2281
- await config.earlyHints(match, req, responseHeaders);
2282
- } catch (err) {
2283
- swallow(err, "early hints hook threw");
2284
- }
2285
- match.rawSegmentParams = { ...match.segmentParams };
2286
- try {
2287
- await coerceSegmentParams(match);
2288
- } catch (error) {
2289
- if (error instanceof ParamCoercionError) {
2290
- if (isDebug()) {
2291
- const segmentChain = match.segments.map((s) => s.segmentName || "/").join(" → ");
2292
- console.warn(`[timber] Param coercion failed for ${method} ${canonicalPathname} — responding with 404\n Matched segments: ${segmentChain}\n Error: ${error.message}\n This usually means a params.ts codec rejected the URL params.\n Check that all fields in defineSegmentParams() are optional for params\n that don't appear at every route depth (e.g. year, month, day).`);
2293
- }
2294
- const leafSegment = match.segments[match.segments.length - 1];
2295
- if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
2296
- if (config.renderNoMatch) return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
2297
- return new Response(null, { status: 404 });
2167
+ case "static":
2168
+ if (pi >= pathParts.length || pathParts[pi] !== seg.value) return false;
2169
+ pi++;
2170
+ continue;
2298
2171
  }
2299
- throw error;
2300
2172
  }
2301
- setSegmentParams(match.segmentParams);
2302
- const segmentPath = match.segments.map((s) => s.segmentName).filter(Boolean).join("/") || "/";
2303
- setMatchedSegmentPath(segmentPath.startsWith("/") ? segmentPath : `/${segmentPath}`);
2304
- return outcomeToResponse(config, match.middlewareChain.length > 0 ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
2305
- canonicalPathname,
2306
- interception
2307
- }) : await runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, {
2308
- canonicalPathname,
2309
- interception
2310
- }), {
2311
- req,
2312
- method,
2313
- path,
2314
- responseHeaders,
2315
- match
2316
- });
2173
+ return pi === pathParts.length;
2317
2174
  }
2318
2175
  //#endregion
2319
- //#region src/server/pipeline.ts
2176
+ //#region src/server/pipeline-outcome.ts
2320
2177
  /**
2321
- * Create the request handler from a pipeline configuration.
2178
+ * Pipeline outcome translator — converts a `PhaseOutcome` (the value
2179
+ * each phase function returns) into a final `Response`.
2322
2180
  *
2323
- * Returns a function that processes an incoming Request through all pipeline
2324
- * stages and produces a Response. This is the top-level entry point for the
2325
- * server. The body is intentionally small — phase logic lives in
2326
- * `pipeline-phases.ts`. This function only owns the per-request setup that
2327
- * has to wrap the entire dispatch: trace ID, request context ALS, span
2328
- * scope, Server-Timing header emission, and the active-request counter.
2181
+ * Lifted out of `pipeline-phases.ts` (TIM-853) so the per-phase try /
2182
+ * catch logic and the terminal Response-building logic each live in
2183
+ * their own file. The phases produce values; this module is the single
2184
+ * source of truth for how those values become wire responses.
2185
+ *
2186
+ * See design/07-routing.md §"Request Lifecycle".
2329
2187
  */
2330
- function createPipeline(config) {
2331
- const proxyResolver = makeProxyResolver(config.proxy);
2332
- const slowRequestMs = config.slowRequestMs ?? 3e3;
2333
- const serverTiming = config.serverTiming ?? "total";
2334
- let activeRequests = 0;
2335
- const pipelineFn = async (req) => {
2336
- const url = new URL(req.url);
2337
- const method = req.method;
2338
- const path = url.pathname;
2339
- const startTime = performance.now();
2340
- activeRequests++;
2341
- return runWithTraceId(generateTraceId(), async () => {
2342
- return runWithRequestContext(req, async () => {
2343
- const runRequest = async () => {
2344
- logRequestReceived({
2345
- method,
2346
- path
2347
- });
2348
- const response = await withSpan("http.server.request", {
2349
- "http.request.method": method,
2350
- "url.path": path
2351
- }, async () => {
2352
- const otelIds = await getOtelTraceId();
2353
- if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
2354
- const stripTrailingSlash = config.stripTrailingSlash ?? true;
2355
- const canonResult = canonicalize(url.pathname, stripTrailingSlash);
2356
- if (!canonResult.ok) {
2357
- if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
2358
- return new Response(null, { status: canonResult.status });
2359
- }
2360
- const canonicalPath = canonResult.pathname;
2361
- let canonicalReq = req;
2362
- if (url.pathname !== canonicalPath) {
2363
- const canonicalUrl = new URL(req.url);
2364
- canonicalUrl.pathname = canonicalPath;
2365
- canonicalReq = new Request(canonicalUrl.toString(), req);
2366
- }
2367
- const innerHandler = async () => {
2368
- if (config.dispatchAction) {
2369
- const actionResult = await config.dispatchAction(canonicalReq, canonicalPath, pipelineFn);
2370
- if (actionResult) return actionResult;
2371
- }
2372
- return handleRequest(config, canonicalReq, method, canonicalPath, true);
2373
- };
2374
- let result;
2375
- if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, canonicalReq, method, canonicalPath, innerHandler), {
2376
- req: canonicalReq,
2377
- method,
2378
- path: canonicalPath
2379
- });
2380
- else result = await innerHandler();
2381
- await setSpanAttribute("http.response.status_code", result.status);
2382
- const varyTokens = ["Accept"];
2383
- if (config.interceptionRewrites?.length) varyTokens.push("X-Timber-URL");
2384
- const existingVary = result.headers.get("Vary");
2385
- const existingTokens = existingVary ? existingVary.toLowerCase().split(",").map((t) => t.trim()) : [];
2386
- const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
2387
- if (newTokens.length > 0) result.headers.set("Vary", existingVary ? `${existingVary}, ${newTokens.join(", ")}` : newTokens.join(", "));
2388
- if (serverTiming === "detailed") {
2389
- const timingHeader = getServerTimingHeader();
2390
- if (timingHeader) result.headers.set("Server-Timing", timingHeader);
2391
- } else if (serverTiming === "total") {
2392
- const totalMs = Math.round(performance.now() - startTime);
2393
- result.headers.set("Server-Timing", `total;dur=${totalMs}`);
2394
- }
2395
- if (serverTiming === "detailed" && result.body) result = new Response(wrapResponseWithStreamTiming(result.body, startTime), {
2396
- status: result.status,
2397
- statusText: result.statusText,
2398
- headers: result.headers
2399
- });
2400
- return result;
2401
- });
2402
- const durationMs = Math.round(performance.now() - startTime);
2403
- const status = response.status;
2404
- const concurrency = activeRequests;
2405
- activeRequests--;
2406
- logRequestCompleted({
2407
- method,
2408
- path,
2409
- status,
2410
- durationMs,
2411
- concurrency
2412
- });
2413
- if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
2414
- method,
2415
- path,
2416
- durationMs,
2417
- threshold: slowRequestMs,
2418
- concurrency
2419
- });
2420
- return response;
2421
- };
2422
- return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
2423
- });
2424
- });
2425
- };
2426
- return pipelineFn;
2188
+ function rscErrorResponse(isRsc, status, headers) {
2189
+ if (!isRsc) return new Response(null, { status });
2190
+ const h = headers ?? new Headers();
2191
+ h.set("X-Timber-Error", "1");
2192
+ h.set("content-type", "application/json; charset=utf-8");
2193
+ return new Response(JSON.stringify({
2194
+ error: true,
2195
+ status
2196
+ }), {
2197
+ status,
2198
+ headers: h
2199
+ });
2427
2200
  }
2428
- //#endregion
2429
- //#region src/server/build-manifest.ts
2430
2201
  /**
2431
- * Collect all CSS files needed for a matched route's segment chain.
2202
+ * Terminal outcome handler — converts a `PhaseOutcome` into a final
2203
+ * `Response`, applying cookies, building redirects, rendering deny pages
2204
+ * and fallback error pages, and firing instrumentation hooks.
2432
2205
  *
2433
- * Walks segments root → leaf, collecting CSS for each layout and page.
2434
- * Deduplicates while preserving order (root layout CSS first).
2206
+ * This is the single source of truth for how phase outputs become wire
2207
+ * responses; the per-phase try/catch blocks now produce values, not
2208
+ * Responses, so the conversion logic lives in exactly one place.
2435
2209
  */
2436
- function collectRouteCss(segments, manifest) {
2437
- const seen = /* @__PURE__ */ new Set();
2438
- const result = [];
2439
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2440
- if (!file) continue;
2441
- const cssFiles = manifest.css[file.filePath];
2442
- if (!cssFiles) continue;
2443
- for (const url of cssFiles) if (!seen.has(url)) {
2444
- seen.add(url);
2445
- result.push(url);
2210
+ async function outcomeToResponse(config, outcome, ctx) {
2211
+ switch (outcome.kind) {
2212
+ case "response": {
2213
+ const finalResponse = cloneWithMutableHeaders(outcome.response);
2214
+ if (outcome.phase === "proxy") return finalResponse;
2215
+ if (outcome.phase === "middleware" && ctx.responseHeaders) {
2216
+ applyCookieJar(finalResponse.headers);
2217
+ mergeMissingHeaders(finalResponse.headers, ctx.responseHeaders);
2218
+ logMiddlewareShortCircuit({
2219
+ method: ctx.method,
2220
+ path: ctx.path,
2221
+ status: finalResponse.status
2222
+ });
2223
+ }
2224
+ if (outcome.phase === "render") markResponseFlushed();
2225
+ return finalResponse;
2226
+ }
2227
+ case "redirect": {
2228
+ const headers = ctx.responseHeaders ?? new Headers();
2229
+ applyCookieJar(headers);
2230
+ return buildRedirectResponse(outcome.signal, ctx.req, headers);
2231
+ }
2232
+ case "deny": {
2233
+ const headers = ctx.responseHeaders ?? new Headers();
2234
+ applyCookieJar(headers);
2235
+ if (config.renderDenyFallback) try {
2236
+ return cloneWithMutableHeaders(await config.renderDenyFallback(outcome.signal, ctx.req, headers, ctx.match));
2237
+ } catch (denyRenderError) {
2238
+ logRenderError({
2239
+ method: ctx.method,
2240
+ path: ctx.path,
2241
+ error: denyRenderError
2242
+ });
2243
+ await fireOnRequestError(denyRenderError, ctx.req, "render");
2244
+ if (config.onPipelineError && denyRenderError instanceof Error) config.onPipelineError(denyRenderError, "render");
2245
+ }
2246
+ if (isDebug()) console.warn(`[timber] DenySignal(${outcome.signal.status}) from ${outcome.phase} phase — no renderDenyFallback configured, returning bare ${outcome.signal.status} response\n Request: ${ctx.method} ${ctx.path}\n Add a not-found.tsx or error.tsx to render a custom deny page.`);
2247
+ return new Response(null, {
2248
+ status: outcome.signal.status,
2249
+ headers
2250
+ });
2251
+ }
2252
+ case "error": {
2253
+ const isRsc = (ctx.req.headers.get("Accept") ?? "").includes("text/x-component");
2254
+ if (outcome.phase === "proxy") {
2255
+ logProxyError({ error: outcome.error });
2256
+ await fireOnRequestError(outcome.error, ctx.req, "proxy");
2257
+ if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "proxy");
2258
+ return rscErrorResponse(isRsc, 500);
2259
+ }
2260
+ if (outcome.phase === "middleware") {
2261
+ logMiddlewareError({
2262
+ method: ctx.method,
2263
+ path: ctx.path,
2264
+ error: outcome.error
2265
+ });
2266
+ await fireOnRequestError(outcome.error, ctx.req, "handler");
2267
+ if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "middleware");
2268
+ return rscErrorResponse(isRsc, 500);
2269
+ }
2270
+ const headers = ctx.responseHeaders ?? new Headers();
2271
+ applyCookieJar(headers);
2272
+ logRenderError({
2273
+ method: ctx.method,
2274
+ path: ctx.path,
2275
+ error: outcome.error
2276
+ });
2277
+ await fireOnRequestError(outcome.error, ctx.req, "render");
2278
+ if (config.onPipelineError && outcome.error instanceof Error) config.onPipelineError(outcome.error, "render");
2279
+ if (isRsc) return rscErrorResponse(true, 500, headers);
2280
+ if (config.renderFallbackError) try {
2281
+ return cloneWithMutableHeaders(await config.renderFallbackError(outcome.error, ctx.req, headers));
2282
+ } catch (fallbackRenderError) {
2283
+ logRenderError({
2284
+ method: ctx.method,
2285
+ path: ctx.path,
2286
+ error: fallbackRenderError
2287
+ });
2288
+ await fireOnRequestError(fallbackRenderError, ctx.req, "render");
2289
+ if (config.onPipelineError && fallbackRenderError instanceof Error) config.onPipelineError(fallbackRenderError, "render");
2290
+ }
2291
+ return new Response(null, { status: 500 });
2446
2292
  }
2447
2293
  }
2448
- return result;
2449
2294
  }
2295
+ //#endregion
2296
+ //#region src/server/pipeline-phases.ts
2450
2297
  /**
2451
- * Collect all font entries needed for a matched route's segment chain.
2298
+ * Pipeline phase functions — module-level free functions that take their
2299
+ * dependencies as explicit parameters. Each phase returns a `PhaseOutcome`
2300
+ * (a discriminated union over response / redirect / deny / error) defined
2301
+ * in `pipeline-outcome.ts`. The terminal `outcomeToResponse` (also in
2302
+ * `pipeline-outcome.ts`) translates outcomes into Responses.
2452
2303
  *
2453
- * Walks segments root → leaf, collecting fonts for each layout and page.
2454
- * Deduplicates by href while preserving order.
2304
+ * Lifted out of `createPipeline` so each phase can be unit-tested in
2305
+ * isolation. The lift is mechanical — these functions used to be closures
2306
+ * over `config`; they now take `config` as an explicit parameter.
2307
+ *
2308
+ * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
2455
2309
  */
2456
- function collectRouteFonts(segments, manifest) {
2457
- const seen = /* @__PURE__ */ new Set();
2458
- const result = [];
2459
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2460
- if (!file) continue;
2461
- const fonts = manifest.fonts[file.filePath];
2462
- if (!fonts) continue;
2463
- for (const entry of fonts) if (!seen.has(entry.href)) {
2464
- seen.add(entry.href);
2465
- result.push(entry);
2466
- }
2467
- }
2468
- return result;
2469
- }
2470
2310
  /**
2471
- * Collect modulepreload URLs for a matched route's segment chain.
2311
+ * Validate and canonicalize the X-Timber-URL header value.
2472
2312
  *
2473
- * Walks segments root → leaf, collecting transitive JS dependencies
2474
- * for each layout and page. Deduplicates across segments.
2313
+ * Returns the canonical pathname if valid, or null if rejected:
2314
+ * - Must start with '/' (relative pathname, no scheme/authority)
2315
+ * - Must not contain control characters
2316
+ * - Must pass canonicalization (no encoded separators, null bytes, etc.)
2475
2317
  */
2476
- function collectRouteModulepreloads(segments, manifest) {
2477
- const seen = /* @__PURE__ */ new Set();
2478
- const result = [];
2479
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2480
- if (!file) continue;
2481
- const preloads = manifest.modulepreload[file.filePath];
2482
- if (!preloads) continue;
2483
- for (const url of preloads) if (!seen.has(url)) {
2484
- seen.add(url);
2485
- result.push(url);
2486
- }
2318
+ function validateInterceptionHeader(raw, stripTrailingSlash) {
2319
+ if (!raw.startsWith("/")) return null;
2320
+ if (raw.startsWith("//")) return null;
2321
+ for (let i = 0; i < raw.length; i++) {
2322
+ const code = raw.charCodeAt(i);
2323
+ if (code <= 31 || code === 127) return null;
2487
2324
  }
2488
- return result;
2325
+ const result = canonicalize(raw, stripTrailingSlash);
2326
+ if (!result.ok) return null;
2327
+ return result.pathname;
2489
2328
  }
2490
- //#endregion
2491
- //#region src/server/early-hints.ts
2492
- /**
2493
- * 103 Early Hints utilities.
2494
- *
2495
- * Early Hints are sent before the final response to let the browser
2496
- * start fetching critical resources (CSS, fonts, JS) while the server
2497
- * is still rendering.
2498
- *
2499
- * The framework collects hints from two sources:
2500
- * 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
2501
- * 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
2502
- *
2503
- * Both are emitted as Link headers. Cloudflare CDN automatically converts
2504
- * Link headers into 103 Early Hints responses.
2505
- *
2506
- * Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
2507
- */
2508
2329
  /**
2509
- * Format a single EarlyHint as a Link header value.
2510
- *
2511
- * Attribute order: `as` before `rel` to match Cloudflare CDN's cached
2512
- * Early Hints format. Cloudflare caches Link headers from 200 responses
2513
- * and re-emits them as 103 Early Hints on subsequent requests. If our
2514
- * attribute order differs from Cloudflare's cached copy, the browser
2515
- * sees two preload headers for the same URL (different attribute order)
2516
- * and warns "Preload was ignored." Matching the order ensures the
2517
- * browser deduplicates them correctly.
2330
+ * Run the proxy.ts phase. Calls user proxy code with a `next()` continuation.
2518
2331
  *
2519
- * Examples:
2520
- * `</styles/root.css>; as=style; rel=preload`
2521
- * `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
2522
- * `</_timber/client.js>; rel=modulepreload`
2523
- * `<https://fonts.googleapis.com>; rel=preconnect`
2332
+ * When `next` is provided, it replaces the default `handleRequest` as the
2333
+ * continuation — this lets the pipeline inject action dispatch between proxy
2334
+ * and route matching (TIM-1213). The proxy resolver was picked at pipeline
2335
+ * construction time so the hot path sees no per-request branching on the
2336
+ * `ProxyConfig` discriminant.
2524
2337
  */
2525
- function formatLinkHeader(hint) {
2526
- if (hint.as !== void 0) {
2527
- let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
2528
- if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2529
- if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2530
- return value;
2338
+ async function runProxyPhase(config, getProxy, req, method, path, next) {
2339
+ const detailed = config.serverTiming === "detailed";
2340
+ try {
2341
+ const proxyExport = await getProxy();
2342
+ const continuation = next ?? (() => handleRequest(config, req, method, path, true));
2343
+ const proxyFn = () => runProxy(proxyExport, req, continuation);
2344
+ return {
2345
+ kind: "response",
2346
+ phase: "proxy",
2347
+ response: await withSpan("timber.proxy", {}, () => detailed ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn())
2348
+ };
2349
+ } catch (error) {
2350
+ if (error instanceof RedirectSignal) return {
2351
+ kind: "redirect",
2352
+ phase: "proxy",
2353
+ signal: error
2354
+ };
2355
+ if (error instanceof DenySignal) return {
2356
+ kind: "deny",
2357
+ phase: "proxy",
2358
+ signal: error
2359
+ };
2360
+ return {
2361
+ kind: "error",
2362
+ phase: "proxy",
2363
+ error
2364
+ };
2531
2365
  }
2532
- let value = `<${hint.href}>; rel=${hint.rel}`;
2533
- if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2534
- if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2535
- return value;
2536
2366
  }
2537
2367
  /**
2538
- * Collect all Link header strings for a matched route's segment chain.
2539
- *
2540
- * Walks the build manifest to emit hints for:
2541
- * - CSS stylesheets (as=style; rel=preload)
2542
- * - Font assets (as=font; rel=preload; crossorigin)
2543
- * - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
2544
- *
2545
- * Returns formatted Link header strings, deduplicated by URL, root → leaf order.
2546
- * Returns an empty array in dev mode (manifest is empty).
2368
+ * Run the middleware chain phase. If the chain short-circuits with a Response,
2369
+ * returns it as a 'response' outcome. Otherwise applies the request header
2370
+ * overlay and falls through to the render phase.
2547
2371
  */
2548
- function collectEarlyHintHeaders(segments, manifest, options) {
2549
- const result = [];
2550
- const seenUrls = /* @__PURE__ */ new Set();
2551
- const add = (url, header) => {
2552
- if (!seenUrls.has(url)) {
2553
- seenUrls.add(url);
2554
- result.push(header);
2372
+ async function runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext) {
2373
+ const detailed = config.serverTiming === "detailed";
2374
+ const ctx = {
2375
+ req,
2376
+ requestHeaders: requestHeaderOverlay,
2377
+ headers: responseHeaders,
2378
+ segmentParams: match.segmentParams,
2379
+ earlyHints: (hints) => {
2380
+ for (const hint of hints) {
2381
+ let value;
2382
+ if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
2383
+ else value = `<${hint.href}>; rel=${hint.rel}`;
2384
+ if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2385
+ if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2386
+ responseHeaders.append("Link", value);
2387
+ }
2555
2388
  }
2556
2389
  };
2557
- for (const url of collectRouteCss(segments, manifest)) add(url, formatLinkHeader({
2558
- href: url,
2559
- rel: "preload",
2560
- as: "style"
2561
- }));
2562
- for (const font of collectRouteFonts(segments, manifest)) add(font.href, formatLinkHeader({
2563
- href: font.href,
2564
- rel: "preload",
2565
- as: "font",
2566
- crossOrigin: "anonymous"
2567
- }));
2568
- if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(url, formatLinkHeader({
2569
- href: url,
2570
- rel: "modulepreload"
2571
- }));
2572
- return result;
2573
- }
2574
- //#endregion
2575
- //#region src/server/early-hints-sender.ts
2576
- /**
2577
- * Per-request 103 Early Hints sender — ALS bridge for platform adapters.
2578
- *
2579
- * The pipeline collects Link headers for CSS, fonts, and JS chunks at
2580
- * route-match time. On platforms that support it (Node.js v18.11+, Bun),
2581
- * the adapter can send these as a 103 Early Hints interim response before
2582
- * the final response is ready.
2583
- *
2584
- * This module provides an ALS-based bridge: the generated entry point
2585
- * (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
2586
- * binding a per-request sender function. The pipeline calls
2587
- * `sendEarlyHints103()` to fire the 103 if a sender is available.
2588
- *
2589
- * On platforms where 103 is handled at the CDN level (e.g., Cloudflare
2590
- * converts Link headers into 103 automatically), no sender is installed
2591
- * and `sendEarlyHints103()` is a no-op.
2592
- *
2593
- * Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
2594
- */
2595
- /**
2596
- * Run a function with a per-request early hints sender installed.
2597
- *
2598
- * Called by generated entry points (e.g., Nitro node-server/bun) to
2599
- * bind the platform's writeEarlyHints capability for the request duration.
2600
- */
2601
- function runWithEarlyHintsSender(sender, fn) {
2602
- return earlyHintsSenderAls.run(sender, fn);
2390
+ try {
2391
+ const chainFn = () => runMiddlewareChain(match.middlewareChain, ctx);
2392
+ const middlewareResponse = await (async () => {
2393
+ setMutableCookieContext(true);
2394
+ try {
2395
+ return await withSpan("timber.middleware", {}, () => detailed ? withTiming("mw", "middleware.ts", chainFn) : chainFn());
2396
+ } finally {
2397
+ setMutableCookieContext(false);
2398
+ }
2399
+ })();
2400
+ if (middlewareResponse) return {
2401
+ kind: "response",
2402
+ phase: "middleware",
2403
+ response: middlewareResponse
2404
+ };
2405
+ applyRequestHeaderOverlay(requestHeaderOverlay);
2406
+ applyCookieJar(responseHeaders);
2407
+ return runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext);
2408
+ } catch (error) {
2409
+ if (error instanceof RedirectSignal) return {
2410
+ kind: "redirect",
2411
+ phase: "middleware",
2412
+ signal: error
2413
+ };
2414
+ if (error instanceof DenySignal) return {
2415
+ kind: "deny",
2416
+ phase: "middleware",
2417
+ signal: error
2418
+ };
2419
+ return {
2420
+ kind: "error",
2421
+ phase: "middleware",
2422
+ error
2423
+ };
2424
+ }
2603
2425
  }
2604
2426
  /**
2605
- * Send collected Link headers as a 103 Early Hints response.
2606
- *
2607
- * No-op if no sender is installed for the current request (e.g., on
2608
- * Cloudflare where the CDN handles 103 automatically, or in dev mode).
2609
- *
2610
- * Non-fatal: errors from the sender are caught and silently ignored.
2427
+ * Run the render phase. Wraps the configured renderer in a span and a
2428
+ * timing scope, and translates thrown signals into outcome variants.
2611
2429
  */
2612
- function sendEarlyHints103(links) {
2613
- if (!links.length) return;
2614
- const sender = earlyHintsSenderAls.getStore();
2615
- if (!sender) return;
2430
+ async function runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, { canonicalPathname, interception }) {
2431
+ const detailed = config.serverTiming === "detailed";
2616
2432
  try {
2617
- sender(links);
2618
- } catch (err) {
2619
- swallow(err, "early hints 103 send failed");
2433
+ const renderFn = () => config.render(req, match, responseHeaders, requestHeaderOverlay, interception);
2434
+ return {
2435
+ kind: "response",
2436
+ phase: "render",
2437
+ response: await withSpan("timber.render", { "http.route": canonicalPathname }, () => detailed ? withTiming("render", "RSC + SSR render", renderFn) : renderFn())
2438
+ };
2439
+ } catch (error) {
2440
+ if (error instanceof DenySignal) return {
2441
+ kind: "deny",
2442
+ phase: "render",
2443
+ signal: error
2444
+ };
2445
+ if (error instanceof RedirectSignal) return {
2446
+ kind: "redirect",
2447
+ phase: "render",
2448
+ signal: error
2449
+ };
2450
+ return {
2451
+ kind: "error",
2452
+ phase: "render",
2453
+ error
2454
+ };
2620
2455
  }
2621
2456
  }
2622
- //#endregion
2623
- //#region src/server/tree-builder.ts
2624
- var REACT_COMPONENT_TYPE_MARKERS = /* @__PURE__ */ new Set([
2625
- Symbol.for("react.forward_ref"),
2626
- Symbol.for("react.memo"),
2627
- Symbol.for("react.lazy"),
2628
- Symbol.for("react.provider"),
2629
- Symbol.for("react.context"),
2630
- Symbol.for("react.suspense"),
2631
- Symbol.for("react.suspense_list"),
2632
- Symbol.for("react.client.reference")
2633
- ]);
2634
2457
  /**
2635
- * Validate that a loaded module's `default` export is something React
2636
- * accepts as the first argument to `createElement` — i.e. a valid component
2637
- * type. React doesn't export `isValidElementType` (only `isValidElement`,
2638
- * which checks for *elements*, not *component types*), so this mirrors
2639
- * React's internal check:
2640
- *
2641
- * - functions → function or class components
2642
- * - objects with a `$$typeof` matching one of React's known component
2643
- * markers → exotic components (`memo`, `forwardRef`, `lazy`, context,
2644
- * suspense, client references via `@vitejs/plugin-rsc`)
2645
- *
2646
- * Strings (HTML tag names) are valid for `createElement` but never appear
2647
- * as a route module's default export, so they're not recognized here.
2458
+ * Process a single request from canonicalization through phase dispatch.
2648
2459
  *
2649
- * Anything else (numbers, plain config objects, JSON, etc.) is rejected so
2650
- * the boundary wrapper is skipped rather than crashing inside React.
2651
- */
2652
- function isValidElementType(value) {
2653
- if (typeof value === "function") return true;
2654
- if (typeof value !== "object" || value === null) return false;
2655
- const marker = value.$$typeof;
2656
- return typeof marker === "symbol" && REACT_COMPONENT_TYPE_MARKERS.has(marker);
2657
- }
2658
- /**
2659
- * Build the unified element tree from a matched segment chain.
2460
+ * Stages: canonicalize → metadata routes → auto-sitemap → version skew →
2461
+ * route match → interception → early hints → param coercion → middleware →
2462
+ * render → outcome translation. Pre-routing short-circuits return Responses
2463
+ * directly; post-match dispatch goes through `outcomeToResponse`.
2660
2464
  *
2661
- * Construction is bottom-up:
2662
- * 1. Start with the page component (leaf segment)
2663
- * 2. Wrap in status-code error boundaries (fallback chain)
2664
- * 3. Wrap in AccessGate (if segment has access.ts)
2665
- * 4. Pass as children to the segment's layout
2666
- * 5. Repeat up the segment chain to root
2465
+ * Used both as the top-level entry (when no proxy.ts is configured) and as
2466
+ * the `next()` continuation passed to `runProxy()`.
2667
2467
  *
2668
- * Parallel slots are resolved at each layout level and composed as named props.
2468
+ * @param pathIsCanonical When true, `path` has already been canonicalized by
2469
+ * `createPipeline` — skip re-canonicalization to prevent double-decode.
2470
+ * When false (default), runs canonicalize as a safety net for direct callers.
2669
2471
  */
2670
- async function buildElementTree(config) {
2671
- const { segments, loadModule, createElement, errorBoundaryComponent } = config;
2672
- if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
2673
- const leaf = segments[segments.length - 1];
2674
- if (leaf.route && !leaf.page) return {
2675
- tree: null,
2676
- isApiRoute: true
2677
- };
2678
- const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
2679
- if (!PageComponent) throw new Error(`[timber] No page component found for route at ${leaf.urlPath}. Each route must have a page.tsx or route.ts.`);
2680
- let element = createElement(PageComponent, {});
2681
- for (let i = segments.length - 1; i >= 0; i--) {
2682
- const segment = segments[i];
2683
- element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
2684
- if (segment.access) {
2685
- const accessFn = (await loadModule(segment.access)).default;
2686
- element = createElement("timber:access-gate", {
2687
- accessFn,
2688
- segmentName: segment.segmentName,
2689
- children: element
2472
+ async function handleRequest(config, req, method, path, pathIsCanonical) {
2473
+ const stripTrailingSlash = config.stripTrailingSlash ?? true;
2474
+ let canonicalPathname;
2475
+ if (pathIsCanonical) canonicalPathname = path;
2476
+ else {
2477
+ const result = canonicalize(path, stripTrailingSlash);
2478
+ if (!result.ok) {
2479
+ if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${path} — responding with ${result.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
2480
+ return new Response(null, { status: result.status });
2481
+ }
2482
+ canonicalPathname = result.pathname;
2483
+ }
2484
+ if (config.matchMetadataRoute) {
2485
+ const metaMatch = config.matchMetadataRoute(canonicalPathname);
2486
+ if (metaMatch) try {
2487
+ if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
2488
+ setSegmentParams(metaMatch.segmentParams);
2489
+ const mod = await loadModule(metaMatch.file);
2490
+ if (typeof mod.default !== "function") {
2491
+ if (isDebug()) console.warn(`[timber] Metadata route ${metaMatch.file} does not export a default function — responding with 500\n Metadata routes must export a default function that returns the metadata content.`);
2492
+ return new Response("Metadata route must export a default function", { status: 500 });
2493
+ }
2494
+ const handlerResult = await mod.default();
2495
+ if (handlerResult instanceof Response) {
2496
+ if (method === "HEAD") {
2497
+ const headHeaders = new Headers(handlerResult.headers);
2498
+ if (handlerResult.ok && !headHeaders.has("Cache-Control")) headHeaders.set("Cache-Control", "public, max-age=14400, must-revalidate");
2499
+ return new Response(null, {
2500
+ status: handlerResult.status,
2501
+ statusText: handlerResult.statusText,
2502
+ headers: headHeaders
2503
+ });
2504
+ }
2505
+ const res = cloneWithMutableHeaders(handlerResult);
2506
+ if (res.ok && !res.headers.has("Cache-Control")) res.headers.set("Cache-Control", "public, max-age=14400, must-revalidate");
2507
+ return res;
2508
+ }
2509
+ const contentType = metaMatch.contentType;
2510
+ let body;
2511
+ if (typeof handlerResult === "string") body = handlerResult;
2512
+ else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
2513
+ else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
2514
+ else body = String(handlerResult);
2515
+ return new Response(body, {
2516
+ status: 200,
2517
+ headers: {
2518
+ "Content-Type": `${contentType}; charset=utf-8`,
2519
+ "Cache-Control": "public, max-age=14400, must-revalidate"
2520
+ }
2521
+ });
2522
+ } catch (error) {
2523
+ if (error instanceof RedirectSignal) return new Response(null, {
2524
+ status: error.status,
2525
+ headers: { Location: error.location }
2690
2526
  });
2527
+ if (error instanceof DenySignal) return new Response(null, { status: error.status });
2528
+ logRenderError({
2529
+ method,
2530
+ path,
2531
+ error
2532
+ });
2533
+ if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "metadata-route");
2534
+ return new Response(null, { status: 500 });
2691
2535
  }
2692
- if (segment.layout) {
2693
- const LayoutComponent = (await loadModule(segment.layout)).default;
2694
- if (LayoutComponent) {
2695
- const slotProps = {};
2696
- const slotNames = Object.keys(segment.slots);
2697
- if (slotNames.length > 0) for (const slotName of slotNames) {
2698
- const slotNode = segment.slots[slotName];
2699
- slotProps[slotName] = await buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent);
2536
+ }
2537
+ if (config.autoSitemapHandler) try {
2538
+ const sitemapResponse = await config.autoSitemapHandler(canonicalPathname);
2539
+ if (sitemapResponse) return cloneWithMutableHeaders(sitemapResponse);
2540
+ } catch (error) {
2541
+ logRenderError({
2542
+ method,
2543
+ path,
2544
+ error
2545
+ });
2546
+ if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
2547
+ return new Response(null, { status: 500 });
2548
+ }
2549
+ const isRscRequest = (req.headers.get("Accept") ?? "").includes("text/x-component");
2550
+ if (isRscRequest) {
2551
+ if (!checkVersionSkew(req).ok) {
2552
+ const reloadHeaders = new Headers();
2553
+ applyReloadHeaders(reloadHeaders);
2554
+ return new Response(null, {
2555
+ status: 204,
2556
+ headers: reloadHeaders
2557
+ });
2558
+ }
2559
+ }
2560
+ let match = config.matchRoute(canonicalPathname);
2561
+ let interception;
2562
+ if (isRscRequest && config.interceptionRewrites?.length) {
2563
+ const rawSourceUrl = req.headers.get("X-Timber-URL");
2564
+ const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
2565
+ if (validatedSourceUrl) {
2566
+ const intercepted = findInterceptionMatch(canonicalPathname, validatedSourceUrl, config.interceptionRewrites);
2567
+ if (intercepted) {
2568
+ const sourceMatch = config.matchRoute(intercepted.sourcePathname);
2569
+ if (sourceMatch) {
2570
+ match = sourceMatch;
2571
+ interception = { targetPathname: canonicalPathname };
2700
2572
  }
2701
- element = createElement(LayoutComponent, {
2702
- ...slotProps,
2703
- children: element
2704
- });
2705
2573
  }
2706
2574
  }
2707
2575
  }
2708
- return {
2709
- tree: element,
2710
- isApiRoute: false
2711
- };
2576
+ if (!match) {
2577
+ if (isDebug()) console.warn(`[timber] No route matched for pathname: ${canonicalPathname}\n Input path: ${path}\n Method: ${method}`);
2578
+ if (config.renderNoMatch) {
2579
+ const responseHeaders = new Headers();
2580
+ return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
2581
+ }
2582
+ return new Response(null, { status: 404 });
2583
+ }
2584
+ const responseHeaders = new Headers();
2585
+ const requestHeaderOverlay = new Headers();
2586
+ responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
2587
+ const leafSegment = match.segments[match.segments.length - 1];
2588
+ const isApiRoute = leafSegment?.route && !leafSegment?.page;
2589
+ if (config.earlyHints && !isRscRequest && !isApiRoute) try {
2590
+ await config.earlyHints(match, req, responseHeaders);
2591
+ } catch (err) {
2592
+ swallow(err, "early hints hook threw");
2593
+ }
2594
+ match.rawSegmentParams = { ...match.segmentParams };
2595
+ try {
2596
+ await coerceSegmentParams(match);
2597
+ } catch (error) {
2598
+ if (error instanceof ParamCoercionError) {
2599
+ if (isDebug()) {
2600
+ const segmentChain = match.segments.map((s) => s.segmentName || "/").join(" → ");
2601
+ console.warn(`[timber] Param coercion failed for ${method} ${canonicalPathname} — responding with 404\n Matched segments: ${segmentChain}\n Error: ${error.message}\n This usually means a params.ts codec rejected the URL params.\n Check that all fields in defineSegmentParams() are optional for params\n that don't appear at every route depth (e.g. year, month, day).`);
2602
+ }
2603
+ const leafSegment = match.segments[match.segments.length - 1];
2604
+ if (leafSegment.route && !leafSegment.page) return new Response(null, { status: 404 });
2605
+ if (config.renderNoMatch) return cloneWithMutableHeaders(await config.renderNoMatch(req, responseHeaders));
2606
+ return new Response(null, { status: 404 });
2607
+ }
2608
+ throw error;
2609
+ }
2610
+ setSegmentParams(match.segmentParams);
2611
+ const segmentPath = match.segments.map((s) => s.segmentName).filter(Boolean).join("/") || "/";
2612
+ setMatchedSegmentPath(segmentPath.startsWith("/") ? segmentPath : `/${segmentPath}`);
2613
+ return outcomeToResponse(config, match.middlewareChain.length > 0 ? await runMiddlewarePhase(config, req, match, responseHeaders, requestHeaderOverlay, {
2614
+ canonicalPathname,
2615
+ interception
2616
+ }) : await runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, {
2617
+ canonicalPathname,
2618
+ interception
2619
+ }), {
2620
+ req,
2621
+ method,
2622
+ path,
2623
+ responseHeaders,
2624
+ match
2625
+ });
2712
2626
  }
2627
+ //#endregion
2628
+ //#region src/server/pipeline.ts
2713
2629
  /**
2714
- * Build the element tree for a parallel slot.
2630
+ * Create the request handler from a pipeline configuration.
2715
2631
  *
2716
- * Slots have their own access.ts (SlotAccessGate) and error boundaries.
2717
- * On access denial: denied.tsx → default.tsx → null (graceful degradation).
2632
+ * Returns a function that processes an incoming Request through all pipeline
2633
+ * stages and produces a Response. This is the top-level entry point for the
2634
+ * server. The body is intentionally small — phase logic lives in
2635
+ * `pipeline-phases.ts`. This function only owns the per-request setup that
2636
+ * has to wrap the entire dispatch: trace ID, request context ALS, span
2637
+ * scope, Server-Timing header emission, and the active-request counter.
2718
2638
  */
2719
- async function buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent) {
2720
- const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
2721
- const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
2722
- if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {}) : null;
2723
- let element = createElement(PageComponent, {});
2724
- element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
2725
- if (slotNode.access) {
2726
- const accessFn = (await loadModule(slotNode.access)).default;
2727
- const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default ?? null;
2728
- const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
2729
- element = createElement("timber:slot-access-gate", {
2730
- accessFn,
2731
- DeniedComponent,
2732
- slotName: slotNode.segmentName.replace(/^@/, ""),
2733
- createElement,
2734
- defaultFallback,
2735
- children: element
2639
+ function createPipeline(config) {
2640
+ const proxyResolver = makeProxyResolver(config.proxy);
2641
+ const slowRequestMs = config.slowRequestMs ?? 3e3;
2642
+ const serverTiming = config.serverTiming ?? "total";
2643
+ let activeRequests = 0;
2644
+ const pipelineFn = async (req) => {
2645
+ const url = new URL(req.url);
2646
+ const method = req.method;
2647
+ const path = url.pathname;
2648
+ const startTime = performance.now();
2649
+ activeRequests++;
2650
+ return runWithTraceId(generateTraceId(), async () => {
2651
+ return runWithRequestContext(req, async () => {
2652
+ const runRequest = async () => {
2653
+ logRequestReceived({
2654
+ method,
2655
+ path
2656
+ });
2657
+ const response = await withSpan("http.server.request", {
2658
+ "http.request.method": method,
2659
+ "url.path": path
2660
+ }, async () => {
2661
+ const otelIds = await getOtelTraceId();
2662
+ if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
2663
+ const stripTrailingSlash = config.stripTrailingSlash ?? true;
2664
+ const canonResult = canonicalize(url.pathname, stripTrailingSlash);
2665
+ if (!canonResult.ok) {
2666
+ if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
2667
+ return new Response(null, { status: canonResult.status });
2668
+ }
2669
+ const canonicalPath = canonResult.pathname;
2670
+ let canonicalReq = req;
2671
+ if (url.pathname !== canonicalPath) {
2672
+ const canonicalUrl = new URL(req.url);
2673
+ canonicalUrl.pathname = canonicalPath;
2674
+ canonicalReq = new Request(canonicalUrl.toString(), req);
2675
+ }
2676
+ const innerHandler = async () => {
2677
+ if (config.dispatchAction) {
2678
+ const actionResult = await config.dispatchAction(canonicalReq, canonicalPath, pipelineFn);
2679
+ if (actionResult) return actionResult;
2680
+ }
2681
+ return handleRequest(config, canonicalReq, method, canonicalPath, true);
2682
+ };
2683
+ let result;
2684
+ if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, canonicalReq, method, canonicalPath, innerHandler), {
2685
+ req: canonicalReq,
2686
+ method,
2687
+ path: canonicalPath
2688
+ });
2689
+ else result = await innerHandler();
2690
+ await setSpanAttribute("http.response.status_code", result.status);
2691
+ const varyTokens = ["Accept"];
2692
+ if (config.interceptionRewrites?.length) varyTokens.push("X-Timber-URL");
2693
+ const existingVary = result.headers.get("Vary");
2694
+ const existingTokens = existingVary ? existingVary.toLowerCase().split(",").map((t) => t.trim()) : [];
2695
+ const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
2696
+ if (newTokens.length > 0) result.headers.set("Vary", existingVary ? `${existingVary}, ${newTokens.join(", ")}` : newTokens.join(", "));
2697
+ if (serverTiming === "detailed") {
2698
+ const timingHeader = getServerTimingHeader();
2699
+ if (timingHeader) result.headers.set("Server-Timing", timingHeader);
2700
+ } else if (serverTiming === "total") {
2701
+ const totalMs = Math.round(performance.now() - startTime);
2702
+ result.headers.set("Server-Timing", `total;dur=${totalMs}`);
2703
+ }
2704
+ if (serverTiming === "detailed" && result.body) result = new Response(wrapResponseWithStreamTiming(result.body, startTime), {
2705
+ status: result.status,
2706
+ statusText: result.statusText,
2707
+ headers: result.headers
2708
+ });
2709
+ return result;
2710
+ });
2711
+ const durationMs = Math.round(performance.now() - startTime);
2712
+ const status = response.status;
2713
+ const concurrency = activeRequests;
2714
+ activeRequests--;
2715
+ logRequestCompleted({
2716
+ method,
2717
+ path,
2718
+ status,
2719
+ durationMs,
2720
+ concurrency
2721
+ });
2722
+ if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
2723
+ method,
2724
+ path,
2725
+ durationMs,
2726
+ threshold: slowRequestMs,
2727
+ concurrency
2728
+ });
2729
+ return response;
2730
+ };
2731
+ return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
2732
+ });
2736
2733
  });
2737
- }
2738
- return element;
2739
- }
2740
- /** MDX/markdown extensions — these are server components that cannot be passed as function props. */
2741
- var MDX_EXTENSIONS = /* @__PURE__ */ new Set(["mdx", "md"]);
2742
- /**
2743
- * Check if a route file is an MDX/markdown file based on its extension.
2744
- * MDX components are server components by default and cannot cross the
2745
- * RSC→client boundary as function props. They must be pre-rendered as
2746
- * elements and passed as fallbackElement instead of fallbackComponent.
2747
- */
2748
- function isMdxFile(file) {
2749
- return MDX_EXTENSIONS.has(file.extension);
2734
+ };
2735
+ return pipelineFn;
2750
2736
  }
2737
+ //#endregion
2738
+ //#region src/server/build-manifest.ts
2751
2739
  /**
2752
- * Wrap an element with error boundaries from a segment's status-code files.
2753
- *
2754
- * Wrapping order (innermost to outermost):
2755
- * 1. Specific status files (503.tsx, 429.tsx, etc.)
2756
- * 2. Category catch-alls (4xx.tsx, 5xx.tsx)
2757
- * 3. error.tsx (general error boundary)
2758
- *
2759
- * This creates the fallback chain described in design/10-error-handling.md.
2740
+ * Collect all CSS files needed for a matched route's segment chain.
2760
2741
  *
2761
- * MDX status files are server components and cannot be passed as function
2762
- * props to TimberErrorBoundary (a 'use client' component). Instead, they
2763
- * are pre-rendered as elements and passed as fallbackElement. The error
2764
- * boundary renders the element directly when an error is caught.
2765
- * See TIM-503.
2742
+ * Walks segments root → leaf, collecting CSS for each layout and page.
2743
+ * Deduplicates while preserving order (root layout CSS first).
2766
2744
  */
2767
- async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
2768
- if (segment.statusFiles) {
2769
- for (const [key, file] of Object.entries(segment.statusFiles)) if (key !== "4xx" && key !== "5xx") {
2770
- const status = parseInt(key, 10);
2771
- if (!isNaN(status)) {
2772
- const mod = await loadModule(file);
2773
- const Component = isValidElementType(mod.default) ? mod.default : null;
2774
- if (Component) element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
2775
- fallbackElement: createElement(Component, { status }),
2776
- status,
2777
- children: element
2778
- } : {
2779
- fallbackComponent: Component,
2780
- status,
2781
- children: element
2782
- });
2783
- }
2784
- }
2785
- for (const [key, file] of Object.entries(segment.statusFiles)) if (key === "4xx" || key === "5xx") {
2786
- const mod = await loadModule(file);
2787
- const Component = isValidElementType(mod.default) ? mod.default : null;
2788
- if (Component) {
2789
- const categoryStatus = key === "4xx" ? 400 : 500;
2790
- element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
2791
- fallbackElement: createElement(Component, {}),
2792
- status: categoryStatus,
2793
- children: element
2794
- } : {
2795
- fallbackComponent: Component,
2796
- status: categoryStatus,
2797
- children: element
2798
- });
2799
- }
2745
+ function collectRouteCss(segments, manifest) {
2746
+ const seen = /* @__PURE__ */ new Set();
2747
+ const result = [];
2748
+ for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2749
+ if (!file) continue;
2750
+ const cssFiles = manifest.css[file.filePath];
2751
+ if (!cssFiles) continue;
2752
+ for (const url of cssFiles) if (!seen.has(url)) {
2753
+ seen.add(url);
2754
+ result.push(url);
2800
2755
  }
2801
2756
  }
2802
- if (segment.error) {
2803
- const errorModule = await loadModule(segment.error);
2804
- const ErrorComponent = isValidElementType(errorModule.default) ? errorModule.default : null;
2805
- if (ErrorComponent) element = createElement(errorBoundaryComponent, isMdxFile(segment.error) ? {
2806
- fallbackElement: createElement(ErrorComponent, {}),
2807
- children: element
2808
- } : {
2809
- fallbackComponent: ErrorComponent,
2810
- children: element
2811
- });
2812
- }
2813
- return element;
2757
+ return result;
2814
2758
  }
2815
- //#endregion
2816
- //#region src/server/csrf.ts
2817
- /** HTTP methods that are considered safe (no mutation). */
2818
- var SAFE_METHODS = /* @__PURE__ */ new Set([
2819
- "GET",
2820
- "HEAD",
2821
- "OPTIONS"
2822
- ]);
2823
- /**
2824
- * Derive the request's scheme from trusted sources.
2825
- *
2826
- * Priority:
2827
- * 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
2828
- * 2. Request URL protocol
2759
+ /**
2760
+ * Collect all font entries needed for a matched route's segment chain.
2829
2761
  *
2830
- * Falls back to 'https' (fail closed) if neither is available.
2762
+ * Walks segments root → leaf, collecting fonts for each layout and page.
2763
+ * Deduplicates by href while preserving order.
2831
2764
  */
2832
- function deriveRequestScheme(req) {
2833
- const forwarded = req.headers.get("X-Forwarded-Proto");
2834
- if (forwarded) {
2835
- const proto = forwarded.split(",")[0].trim().toLowerCase();
2836
- if (proto === "http" || proto === "https") return proto;
2837
- }
2838
- try {
2839
- return new URL(req.url).protocol.replace(":", "");
2840
- } catch {
2841
- return "https";
2765
+ function collectRouteFonts(segments, manifest) {
2766
+ const seen = /* @__PURE__ */ new Set();
2767
+ const result = [];
2768
+ for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2769
+ if (!file) continue;
2770
+ const fonts = manifest.fonts[file.filePath];
2771
+ if (!fonts) continue;
2772
+ for (const entry of fonts) if (!seen.has(entry.href)) {
2773
+ seen.add(entry.href);
2774
+ result.push(entry);
2775
+ }
2842
2776
  }
2777
+ return result;
2843
2778
  }
2844
2779
  /**
2845
- * Validate the Origin header against the request's full origin
2846
- * (scheme + host + port).
2847
- *
2848
- * For mutation methods (POST, PUT, PATCH, DELETE):
2849
- * - If `csrf: false`, skip validation.
2850
- * - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
2851
- * - Otherwise, Origin must match the derived request origin.
2780
+ * Collect modulepreload URLs for a matched route's segment chain.
2852
2781
  *
2853
- * Safe methods (GET, HEAD, OPTIONS) always pass.
2782
+ * Walks segments root → leaf, collecting transitive JS dependencies
2783
+ * for each layout and page. Deduplicates across segments.
2854
2784
  */
2855
- function validateCsrf(req, config) {
2856
- if (SAFE_METHODS.has(req.method)) return { ok: true };
2857
- if (config.csrf === false) return { ok: true };
2858
- const origin = req.headers.get("Origin");
2859
- if (!origin) return {
2860
- ok: false,
2861
- status: 403
2862
- };
2863
- if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
2864
- ok: false,
2865
- status: 403
2866
- };
2867
- const host = req.headers.get("Host");
2868
- if (!host) return {
2869
- ok: false,
2870
- status: 403
2871
- };
2872
- let originOrigin;
2873
- try {
2874
- originOrigin = new URL(origin).origin;
2875
- } catch {
2876
- return {
2877
- ok: false,
2878
- status: 403
2879
- };
2880
- }
2881
- const scheme = deriveRequestScheme(req);
2882
- let expectedOrigin;
2883
- try {
2884
- expectedOrigin = new URL(`${scheme}://${host}`).origin;
2885
- } catch {
2886
- return {
2887
- ok: false,
2888
- status: 403
2889
- };
2785
+ function collectRouteModulepreloads(segments, manifest) {
2786
+ const seen = /* @__PURE__ */ new Set();
2787
+ const result = [];
2788
+ for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2789
+ if (!file) continue;
2790
+ const preloads = manifest.modulepreload[file.filePath];
2791
+ if (!preloads) continue;
2792
+ for (const url of preloads) if (!seen.has(url)) {
2793
+ seen.add(url);
2794
+ result.push(url);
2795
+ }
2890
2796
  }
2891
- return originOrigin === expectedOrigin ? { ok: true } : {
2892
- ok: false,
2893
- status: 403
2894
- };
2797
+ return result;
2895
2798
  }
2896
2799
  //#endregion
2897
- //#region src/server/body-limits.ts
2898
- var KB = 1024;
2899
- var MB = 1024 * KB;
2900
- var GB = 1024 * MB;
2901
- var DEFAULT_LIMITS = {
2902
- actionBodySize: 1 * MB,
2903
- uploadBodySize: 10 * MB,
2904
- maxFields: 100
2905
- };
2906
- var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
2907
- /** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
2908
- function parseBodySize(size) {
2909
- const match = SIZE_PATTERN.exec(size.trim());
2910
- if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
2911
- const value = Number.parseFloat(match[1]);
2912
- const unit = (match[2] ?? "").toLowerCase();
2913
- switch (unit) {
2914
- case "kb": return Math.floor(value * KB);
2915
- case "mb": return Math.floor(value * MB);
2916
- case "gb": return Math.floor(value * GB);
2917
- case "": return Math.floor(value);
2918
- default: throw new Error(`Unknown size unit: "${unit}"`);
2919
- }
2920
- }
2921
- /** Strict integer pattern: one or more digits, nothing else. */
2922
- var STRICT_INTEGER_RE = /^\d+$/;
2923
- /** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
2924
- function enforceBodyLimits(req, kind, config) {
2925
- const contentLength = req.headers.get("Content-Length");
2926
- if (!contentLength) return {
2927
- ok: false,
2928
- status: 411
2929
- };
2930
- const trimmed = contentLength.trim();
2931
- if (!STRICT_INTEGER_RE.test(trimmed)) return {
2932
- ok: false,
2933
- status: 411
2934
- };
2935
- return Number.parseInt(trimmed, 10) <= resolveLimit(kind, config) ? { ok: true } : {
2936
- ok: false,
2937
- status: 413
2938
- };
2939
- }
2800
+ //#region src/server/early-hints.ts
2940
2801
  /**
2941
- * Resolve the byte limit for a given body kind, using config overrides or defaults.
2802
+ * 103 Early Hints utilities.
2803
+ *
2804
+ * Early Hints are sent before the final response to let the browser
2805
+ * start fetching critical resources (CSS, fonts, JS) while the server
2806
+ * is still rendering.
2807
+ *
2808
+ * The framework collects hints from two sources:
2809
+ * 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
2810
+ * 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
2811
+ *
2812
+ * Both are emitted as Link headers. Cloudflare CDN automatically converts
2813
+ * Link headers into 103 Early Hints responses.
2814
+ *
2815
+ * Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
2942
2816
  */
2943
- function resolveLimit(kind, config) {
2944
- const userLimits = config.limits;
2945
- if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
2946
- return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
2947
- }
2948
- //#endregion
2949
- //#region src/server/route-handler.ts
2950
- /** All recognized HTTP method export names. */
2951
- var HTTP_METHODS = [
2952
- "GET",
2953
- "POST",
2954
- "PUT",
2955
- "PATCH",
2956
- "DELETE",
2957
- "HEAD",
2958
- "OPTIONS"
2959
- ];
2960
2817
  /**
2961
- * Resolve the full list of allowed methods for a route module.
2818
+ * Format a single EarlyHint as a Link header value.
2962
2819
  *
2963
- * Includes:
2964
- * - All explicitly exported methods
2965
- * - HEAD (implicit when GET is exported)
2966
- * - OPTIONS (always implicit)
2820
+ * Attribute order: `as` before `rel` to match Cloudflare CDN's cached
2821
+ * Early Hints format. Cloudflare caches Link headers from 200 responses
2822
+ * and re-emits them as 103 Early Hints on subsequent requests. If our
2823
+ * attribute order differs from Cloudflare's cached copy, the browser
2824
+ * sees two preload headers for the same URL (different attribute order)
2825
+ * and warns "Preload was ignored." Matching the order ensures the
2826
+ * browser deduplicates them correctly.
2827
+ *
2828
+ * Examples:
2829
+ * `</styles/root.css>; as=style; rel=preload`
2830
+ * `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
2831
+ * `</_timber/client.js>; rel=modulepreload`
2832
+ * `<https://fonts.googleapis.com>; rel=preconnect`
2967
2833
  */
2968
- function resolveAllowedMethods(mod) {
2969
- const methods = [];
2970
- for (const method of HTTP_METHODS) {
2971
- if (method === "HEAD" || method === "OPTIONS") continue;
2972
- if (mod[method]) methods.push(method);
2834
+ function formatLinkHeader(hint) {
2835
+ if (hint.as !== void 0) {
2836
+ let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
2837
+ if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2838
+ if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2839
+ return value;
2973
2840
  }
2974
- if (mod.GET && !mod.HEAD) methods.push("HEAD");
2975
- else if (mod.HEAD) methods.push("HEAD");
2976
- if (!mod.OPTIONS) methods.push("OPTIONS");
2977
- else methods.push("OPTIONS");
2978
- return methods;
2841
+ let value = `<${hint.href}>; rel=${hint.rel}`;
2842
+ if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2843
+ if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2844
+ return value;
2979
2845
  }
2980
2846
  /**
2981
- * Handle an incoming request against a route.ts module.
2847
+ * Collect all Link header strings for a matched route's segment chain.
2982
2848
  *
2983
- * Dispatches to the named method handler, auto-generates 405/OPTIONS,
2984
- * and merges response headers from ctx.headers.
2849
+ * Walks the build manifest to emit hints for:
2850
+ * - CSS stylesheets (as=style; rel=preload)
2851
+ * - Font assets (as=font; rel=preload; crossorigin)
2852
+ * - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
2853
+ *
2854
+ * Returns formatted Link header strings, deduplicated by URL, root → leaf order.
2855
+ * Returns an empty array in dev mode (manifest is empty).
2985
2856
  */
2986
- async function handleRouteRequest(mod, ctx) {
2987
- const method = ctx.req.method.toUpperCase();
2988
- const allowHeader = resolveAllowedMethods(mod).join(", ");
2989
- if (method === "OPTIONS") {
2990
- if (mod.OPTIONS) return runHandler(mod.OPTIONS, ctx);
2991
- return new Response(null, {
2992
- status: 204,
2993
- headers: { Allow: allowHeader }
2994
- });
2995
- }
2996
- if (method === "HEAD") {
2997
- if (mod.HEAD) return runHandler(mod.HEAD, ctx);
2998
- if (mod.GET) {
2999
- const res = await runHandler(mod.GET, ctx);
3000
- return new Response(null, {
3001
- status: res.status,
3002
- headers: res.headers
3003
- });
2857
+ function collectEarlyHintHeaders(segments, manifest, options) {
2858
+ const result = [];
2859
+ const seenUrls = /* @__PURE__ */ new Set();
2860
+ const add = (url, header) => {
2861
+ if (!seenUrls.has(url)) {
2862
+ seenUrls.add(url);
2863
+ result.push(header);
3004
2864
  }
3005
- }
3006
- const handler = mod[method];
3007
- if (!handler) return new Response(null, {
3008
- status: 405,
3009
- headers: { Allow: allowHeader }
3010
- });
3011
- return runHandler(handler, ctx);
2865
+ };
2866
+ for (const url of collectRouteCss(segments, manifest)) add(url, formatLinkHeader({
2867
+ href: url,
2868
+ rel: "preload",
2869
+ as: "style"
2870
+ }));
2871
+ for (const font of collectRouteFonts(segments, manifest)) add(font.href, formatLinkHeader({
2872
+ href: font.href,
2873
+ rel: "preload",
2874
+ as: "font",
2875
+ crossOrigin: "anonymous"
2876
+ }));
2877
+ if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(url, formatLinkHeader({
2878
+ href: url,
2879
+ rel: "modulepreload"
2880
+ }));
2881
+ return result;
3012
2882
  }
2883
+ //#endregion
2884
+ //#region src/server/early-hints-sender.ts
3013
2885
  /**
3014
- * Run a handler, merge ctx.headers into the response, and catch errors.
2886
+ * Per-request 103 Early Hints sender — ALS bridge for platform adapters.
2887
+ *
2888
+ * The pipeline collects Link headers for CSS, fonts, and JS chunks at
2889
+ * route-match time. On platforms that support it (Node.js v18.11+, Bun),
2890
+ * the adapter can send these as a 103 Early Hints interim response before
2891
+ * the final response is ready.
2892
+ *
2893
+ * This module provides an ALS-based bridge: the generated entry point
2894
+ * (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
2895
+ * binding a per-request sender function. The pipeline calls
2896
+ * `sendEarlyHints103()` to fire the 103 if a sender is available.
2897
+ *
2898
+ * On platforms where 103 is handled at the CDN level (e.g., Cloudflare
2899
+ * converts Link headers into 103 automatically), no sender is installed
2900
+ * and `sendEarlyHints103()` is a no-op.
2901
+ *
2902
+ * Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
3015
2903
  */
3016
- async function runHandler(handler, ctx) {
3017
- try {
3018
- return mergeResponseHeaders(await handler(ctx), ctx.headers);
3019
- } catch (error) {
3020
- if (error instanceof DenySignal || error instanceof RedirectSignal) throw error;
3021
- logRouteError({
3022
- method: ctx.req.method,
3023
- path: new URL(ctx.req.url).pathname,
3024
- error
3025
- });
3026
- return new Response(null, { status: 500 });
3027
- }
3028
- }
3029
2904
  /**
3030
- * Merge response headers from ctx.headers into the handler's response.
3031
- * ctx.headers (set by middleware or the handler) are applied to the final response.
3032
- * Handler-set headers take precedence over ctx.headers.
2905
+ * Run a function with a per-request early hints sender installed.
2906
+ *
2907
+ * Called by generated entry points (e.g., Nitro node-server/bun) to
2908
+ * bind the platform's writeEarlyHints capability for the request duration.
3033
2909
  */
3034
- function mergeResponseHeaders(res, ctxHeaders) {
3035
- let hasCtxHeaders = false;
3036
- ctxHeaders.forEach(() => {
3037
- hasCtxHeaders = true;
3038
- });
3039
- if (!hasCtxHeaders) return res;
3040
- const merged = new Headers();
3041
- ctxHeaders.forEach((value, key) => {
3042
- if (key.toLowerCase() === "set-cookie") merged.append(key, value);
3043
- else merged.set(key, value);
3044
- });
3045
- const resCookies = res.headers.getSetCookie();
3046
- for (const cookie of resCookies) merged.append("Set-Cookie", cookie);
3047
- res.headers.forEach((value, key) => {
3048
- if (key.toLowerCase() !== "set-cookie") merged.set(key, value);
3049
- });
3050
- return new Response(res.body, {
3051
- status: res.status,
3052
- statusText: res.statusText,
3053
- headers: merged
3054
- });
2910
+ function runWithEarlyHintsSender(sender, fn) {
2911
+ return earlyHintsSenderAls.run(sender, fn);
3055
2912
  }
3056
- //#endregion
3057
- //#region src/server/render-timeout.ts
3058
2913
  /**
3059
- * Render timeout utilities for SSR streaming pipeline.
2914
+ * Send collected Link headers as a 103 Early Hints response.
3060
2915
  *
3061
- * Provides a RenderTimeoutError class and a helper to create
3062
- * timeout-guarded AbortSignals. Used to defend against hung RSC
3063
- * streams and infinite SSR renders.
2916
+ * No-op if no sender is installed for the current request (e.g., on
2917
+ * Cloudflare where the CDN handles 103 automatically, or in dev mode).
3064
2918
  *
3065
- * Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
3066
- */
3067
- /**
3068
- * Error thrown when an SSR render or RSC stream read exceeds the
3069
- * configured timeout. Callers can check `instanceof RenderTimeoutError`
3070
- * to distinguish timeout from other errors and return a 504 or close
3071
- * the connection cleanly.
2919
+ * Non-fatal: errors from the sender are caught and silently ignored.
3072
2920
  */
3073
- var RenderTimeoutError = class extends Error {
3074
- timeoutMs;
3075
- constructor(timeoutMs, context) {
3076
- const message = context ? `Render timeout after ${timeoutMs}ms: ${context}` : `Render timeout after ${timeoutMs}ms`;
3077
- super(message);
3078
- this.name = "RenderTimeoutError";
3079
- this.timeoutMs = timeoutMs;
2921
+ function sendEarlyHints103(links) {
2922
+ if (!links.length) return;
2923
+ const sender = earlyHintsSenderAls.getStore();
2924
+ if (!sender) return;
2925
+ try {
2926
+ sender(links);
2927
+ } catch (err) {
2928
+ swallow(err, "early hints 103 send failed");
3080
2929
  }
3081
- };
2930
+ }
3082
2931
  //#endregion
3083
- //#region src/server/prebuilt/cache-key.ts
2932
+ //#region src/server/tree-builder.ts
2933
+ var REACT_COMPONENT_TYPE_MARKERS = /* @__PURE__ */ new Set([
2934
+ Symbol.for("react.forward_ref"),
2935
+ Symbol.for("react.memo"),
2936
+ Symbol.for("react.lazy"),
2937
+ Symbol.for("react.provider"),
2938
+ Symbol.for("react.context"),
2939
+ Symbol.for("react.suspense"),
2940
+ Symbol.for("react.suspense_list"),
2941
+ Symbol.for("react.client.reference")
2942
+ ]);
3084
2943
  /**
3085
- * Component cache-key hashing for prebuilt (cache.component) entries.
2944
+ * Validate that a loaded module's `default` export is something React
2945
+ * accepts as the first argument to `createElement` — i.e. a valid component
2946
+ * type. React doesn't export `isValidElementType` (only `isValidElement`,
2947
+ * which checks for *elements*, not *component types*), so this mirrors
2948
+ * React's internal check:
3086
2949
  *
3087
- * Key: `sha256(canonicalJson({ props, params })).slice(0, 16)` — see
3088
- * design/44-render-at-build-time.md §Cache Key. Both the request-time
3089
- * lookup (prebuilt-runtime.ts) and the post-build capture pass (TIM-1119)
3090
- * MUST use this exact function, or build artifacts become unaddressable.
2950
+ * - functions → function or class components
2951
+ * - objects with a `$$typeof` matching one of React's known component
2952
+ * markers → exotic components (`memo`, `forwardRef`, `lazy`, context,
2953
+ * suspense, client references via `@vitejs/plugin-rsc`)
3091
2954
  *
3092
- * THE IDENTITY CONTRACT (design/44 §Cache Key): cache identity is **JSON
3093
- * value semantics** — two inputs are the same entry iff they are the same
3094
- * JSON value. This is an ALLOWLIST, not a denylist: a value participates
3095
- * in caching iff it is plain JSON data — null, boolean, finite number,
3096
- * string, dense array, or a plain `Object.prototype` object with only
3097
- * enumerable string-keyed data properties — plus `undefined` and `-0`,
3098
- * which hash as distinct sentinels because flight preserves them. Every
3099
- * value outside that domain (functions, symbols, bigints, accessors,
3100
- * hidden or symbol-keyed properties, non-`Object.prototype` prototypes
3101
- * including null, aliased references, sparse arrays, cycles, non-finite
3102
- * numbers) is UNKEYABLE by definition: `computeComponentCacheKey` returns
3103
- * null and the caller renders live — slower, never wrong.
2955
+ * Strings (HTML tag names) are valid for `createElement` but never appear
2956
+ * as a route module's default export, so they're not recognized here.
3104
2957
  *
3105
- * The line between rejected and declared-out-of-domain: values are
3106
- * REJECTED when the JSON projection itself would be ambiguous or lossy
3107
- * (hidden/symbol/non-enumerable properties, accessors, exotic
3108
- * prototypes). Observable state that leaves the projection intact — key
3109
- * order, reference identity, descriptor metadata (writable/configurable,
3110
- * frozen/sealed/extensible), proxy behavior — is DECLARED outside
3111
- * cache.component's supported input domain rather than rejected:
3112
- * key-order insensitivity is required for determinism, and rejecting
3113
- * frozen objects would unkey ubiquitous legitimate inputs
3114
- * (Object.freeze'd config props) to defend against components that
3115
- * render differently based on property attributes.
2958
+ * Anything else (numbers, plain config objects, JSON, etc.) is rejected so
2959
+ * the boundary wrapper is skipped rather than crashing inside React.
2960
+ */
2961
+ function isValidElementType(value) {
2962
+ if (typeof value === "function") return true;
2963
+ if (typeof value !== "object" || value === null) return false;
2964
+ const marker = value.$$typeof;
2965
+ return typeof marker === "symbol" && REACT_COMPONENT_TYPE_MARKERS.has(marker);
2966
+ }
2967
+ /**
2968
+ * Build the unified element tree from a matched segment chain.
3116
2969
  *
3117
- * Two hard rules for this walk itself: it must be TOTAL (never throw
3118
- * anything but UnkeyableValueError) and SIDE-EFFECT-FREE (descriptor
3119
- * reads only; never invoke user code such as getters).
2970
+ * Construction is bottom-up:
2971
+ * 1. Start with the page component (leaf segment)
2972
+ * 2. Wrap in status-code error boundaries (fallback chain)
2973
+ * 3. Wrap in AccessGate (if segment has access.ts)
2974
+ * 4. Pass as children to the segment's layout
2975
+ * 5. Repeat up the segment chain to root
3120
2976
  *
3121
- * Object keys sort recursively so `{a, b}` and `{b, a}` hash identically
3122
- * (design/13-security.md checklist #14 — cache key determinism; key order
3123
- * is deliberately NOT part of identity).
2977
+ * Parallel slots are resolved at each layout level and composed as named props.
3124
2978
  */
3125
- var UnkeyableValueError = class extends Error {};
3126
- function canonicalize$1(value, seen) {
3127
- if (value === null) return "null";
3128
- switch (typeof value) {
3129
- case "string": return JSON.stringify(value);
3130
- case "boolean": return value ? "true" : "false";
3131
- case "number":
3132
- if (!Number.isFinite(value)) throw new UnkeyableValueError("non-finite number");
3133
- return Object.is(value, -0) ? "-0" : String(value);
3134
- case "object": break;
3135
- case "undefined": return "undefined";
3136
- default: throw new UnkeyableValueError(`unsupported type: ${typeof value}`);
3137
- }
3138
- const obj = value;
3139
- if (seen.has(obj)) throw new UnkeyableValueError("repeated object reference (cycle or alias)");
3140
- seen.add(obj);
3141
- if (Object.getOwnPropertySymbols(obj).length > 0) throw new UnkeyableValueError("symbol-keyed property");
3142
- {
3143
- if (Array.isArray(obj)) {
3144
- if (Object.getOwnPropertyNames(obj).length !== obj.length + 1) throw new UnkeyableValueError("non-index array property");
3145
- const parts = [];
3146
- for (let i = 0; i < obj.length; i++) {
3147
- const desc = Object.getOwnPropertyDescriptor(obj, i);
3148
- if (desc === void 0) throw new UnkeyableValueError("sparse array hole");
3149
- if (desc.get !== void 0 || desc.set !== void 0) throw new UnkeyableValueError("accessor property");
3150
- if (!desc.enumerable) throw new UnkeyableValueError("non-enumerable array element");
3151
- parts.push(canonicalize$1(desc.value, seen));
3152
- }
3153
- return "[" + parts.join(",") + "]";
2979
+ async function buildElementTree(config) {
2980
+ const { segments, loadModule, createElement, errorBoundaryComponent } = config;
2981
+ if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
2982
+ const leaf = segments[segments.length - 1];
2983
+ if (leaf.route && !leaf.page) return {
2984
+ tree: null,
2985
+ isApiRoute: true
2986
+ };
2987
+ const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
2988
+ if (!PageComponent) throw new Error(`[timber] No page component found for route at ${leaf.urlPath}. Each route must have a page.tsx or route.ts.`);
2989
+ let element = createElement(PageComponent, {});
2990
+ for (let i = segments.length - 1; i >= 0; i--) {
2991
+ const segment = segments[i];
2992
+ element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
2993
+ if (segment.access) {
2994
+ const accessFn = (await loadModule(segment.access)).default;
2995
+ element = createElement("timber:access-gate", {
2996
+ accessFn,
2997
+ segmentName: segment.segmentName,
2998
+ children: element
2999
+ });
3154
3000
  }
3155
- if (Object.getPrototypeOf(obj) !== Object.prototype) throw new UnkeyableValueError("non-plain object");
3156
- const keys = Object.keys(obj).sort();
3157
- if (keys.length !== Object.getOwnPropertyNames(obj).length) throw new UnkeyableValueError("non-enumerable property");
3158
- const parts = [];
3159
- for (const key of keys) {
3160
- const desc = Object.getOwnPropertyDescriptor(obj, key);
3161
- if (desc.get !== void 0 || desc.set !== void 0) throw new UnkeyableValueError("accessor property");
3162
- parts.push(JSON.stringify(key) + ":" + canonicalize$1(desc.value, seen));
3001
+ if (segment.layout) {
3002
+ const LayoutComponent = (await loadModule(segment.layout)).default;
3003
+ if (LayoutComponent) {
3004
+ const slotProps = {};
3005
+ const slotNames = Object.keys(segment.slots);
3006
+ if (slotNames.length > 0) for (const slotName of slotNames) {
3007
+ const slotNode = segment.slots[slotName];
3008
+ slotProps[slotName] = await buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent);
3009
+ }
3010
+ element = createElement(LayoutComponent, {
3011
+ ...slotProps,
3012
+ children: element
3013
+ });
3014
+ }
3163
3015
  }
3164
- return "{" + parts.join(",") + "}";
3165
3016
  }
3017
+ return {
3018
+ tree: element,
3019
+ isApiRoute: false
3020
+ };
3166
3021
  }
3167
3022
  /**
3168
- * Canonicalize a single value, or null if it is unkeyable. The ONE
3169
- * definition of "faithfully comparable" — every prebuilt code path that
3170
- * needs value equality (cache keys, slot-param divergence) goes through
3171
- * this, so the unkeyable rules (accessors, hidden props, aliases,
3172
- * bigints, …) live in exactly one place.
3023
+ * Build the element tree for a parallel slot.
3024
+ *
3025
+ * Slots have their own access.ts (SlotAccessGate) and error boundaries.
3026
+ * On access denial: denied.tsx → default.tsx → null (graceful degradation).
3173
3027
  */
3174
- function tryCanonicalize(value) {
3175
- try {
3176
- return canonicalize$1(value, /* @__PURE__ */ new Set());
3177
- } catch (error) {
3178
- return toUnkeyable(error);
3028
+ async function buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent) {
3029
+ const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
3030
+ const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
3031
+ if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {}) : null;
3032
+ let element = createElement(PageComponent, {});
3033
+ element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
3034
+ if (slotNode.access) {
3035
+ const accessFn = (await loadModule(slotNode.access)).default;
3036
+ const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default ?? null;
3037
+ const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
3038
+ element = createElement("timber:slot-access-gate", {
3039
+ accessFn,
3040
+ DeniedComponent,
3041
+ slotName: slotNode.segmentName.replace(/^@/, ""),
3042
+ createElement,
3043
+ defaultFallback,
3044
+ children: element
3045
+ });
3179
3046
  }
3047
+ return element;
3180
3048
  }
3049
+ /** MDX/markdown extensions — these are server components that cannot be passed as function props. */
3050
+ var MDX_EXTENSIONS = /* @__PURE__ */ new Set(["mdx", "md"]);
3181
3051
  /**
3182
- * Totality by construction: ANY throw from the walk means the input is
3183
- * outside the keyable domain. The walk touches only user-controlled
3184
- * values through reflection ops — and a Proxy trap can throw arbitrary
3185
- * errors from any of them (codex review on PR #835) — so converting only
3186
- * UnkeyableValueError would let hostile/exotic inputs abort a render
3187
- * that plain live rendering would have served fine. Unexpected error
3188
- * types are logged for visibility (they would indicate either a proxy
3189
- * trap or a canonicalizer bug) but still degrade to unkeyable.
3052
+ * Check if a route file is an MDX/markdown file based on its extension.
3053
+ * MDX components are server components by default and cannot cross the
3054
+ * RSC→client boundary as function props. They must be pre-rendered as
3055
+ * elements and passed as fallbackElement instead of fallbackComponent.
3190
3056
  */
3191
- function toUnkeyable(error) {
3192
- if (!(error instanceof UnkeyableValueError)) console.error("[timber] cache key canonicalization threw — treating input as unkeyable:", error);
3193
- return null;
3057
+ function isMdxFile(file) {
3058
+ return MDX_EXTENSIONS.has(file.extension);
3194
3059
  }
3195
3060
  /**
3196
- * Split a props record into data props (participate in the cache key) and
3197
- * slot values (React-element holes, excluded from the key) per the
3198
- * component's declared `slots` option (TIM-1173).
3061
+ * Wrap an element with error boundaries from a segment's status-code files.
3199
3062
  *
3200
- * Every DECLARED slot lands in `slotValues` — present or not — because the
3201
- * capture render always injects a SlotOutlet placeholder for each declared
3202
- * slot: the cached shell is identical whether the caller passed the slot
3203
- * or omitted it, so slot presence must not vary the key. `dataProps` is
3204
- * everything else and is what gets hashed.
3063
+ * Wrapping order (innermost to outermost):
3064
+ * 1. Specific status files (503.tsx, 429.tsx, etc.)
3065
+ * 2. Category catch-alls (4xx.tsx, 5xx.tsx)
3066
+ * 3. error.tsx (general error boundary)
3205
3067
  *
3206
- * Returns null when the props OBJECT itself is outside the identity
3207
- * contract at the top level — accessors, symbol-keyed or non-enumerable
3208
- * properties, or a non-`Object.prototype` prototype. Splitting such an
3209
- * object would normalize away observable state (and reading an accessor
3210
- * would invoke user code, which this walk must never do) BEFORE
3211
- * `computeComponentCacheKey` gets the chance to reject it — a cached
3212
- * shell could then be served for inputs a live render distinguishes.
3213
- * Null means unkeyable: the caller renders live with the original props.
3214
- * The walk is descriptor-reads only; getters are never invoked. Nested
3215
- * data-prop values still face the full allowlist when hashed.
3068
+ * This creates the fallback chain described in design/10-error-handling.md.
3069
+ *
3070
+ * MDX status files are server components and cannot be passed as function
3071
+ * props to TimberErrorBoundary (a 'use client' component). Instead, they
3072
+ * are pre-rendered as elements and passed as fallbackElement. The error
3073
+ * boundary renders the element directly when an error is caught.
3074
+ * See TIM-503.
3216
3075
  */
3217
- /** Create an own enumerable data property — safe for keys like `__proto__`. */
3218
- function defineOwn(target, key, value) {
3219
- Object.defineProperty(target, key, {
3220
- value,
3221
- enumerable: true,
3222
- writable: true,
3223
- configurable: true
3224
- });
3225
- }
3226
- function splitSlotProps(props, slots) {
3227
- try {
3228
- if (Object.getPrototypeOf(props) !== Object.prototype) return null;
3229
- if (Object.getOwnPropertySymbols(props).length > 0) return null;
3230
- const slotSet = new Set(slots);
3231
- const dataProps = {};
3232
- const slotValues = {};
3233
- for (const key of Object.getOwnPropertyNames(props)) {
3234
- const desc = Object.getOwnPropertyDescriptor(props, key);
3235
- if (desc === void 0 || desc.get !== void 0 || desc.set !== void 0 || !desc.enumerable) return null;
3236
- defineOwn(slotSet.has(key) ? slotValues : dataProps, key, desc.value);
3076
+ async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
3077
+ if (segment.statusFiles) {
3078
+ for (const [key, file] of Object.entries(segment.statusFiles)) if (key !== "4xx" && key !== "5xx") {
3079
+ const status = parseInt(key, 10);
3080
+ if (!isNaN(status)) {
3081
+ const mod = await loadModule(file);
3082
+ const Component = isValidElementType(mod.default) ? mod.default : null;
3083
+ if (Component) element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
3084
+ fallbackElement: createElement(Component, { status }),
3085
+ status,
3086
+ children: element
3087
+ } : {
3088
+ fallbackComponent: Component,
3089
+ status,
3090
+ children: element
3091
+ });
3092
+ }
3237
3093
  }
3238
- for (const slot of slotSet) if (!Object.hasOwn(slotValues, slot)) defineOwn(slotValues, slot, void 0);
3239
- return {
3240
- dataProps,
3241
- slotValues
3242
- };
3243
- } catch (error) {
3244
- return toUnkeyable(error);
3094
+ for (const [key, file] of Object.entries(segment.statusFiles)) if (key === "4xx" || key === "5xx") {
3095
+ const mod = await loadModule(file);
3096
+ const Component = isValidElementType(mod.default) ? mod.default : null;
3097
+ if (Component) {
3098
+ const categoryStatus = key === "4xx" ? 400 : 500;
3099
+ element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
3100
+ fallbackElement: createElement(Component, {}),
3101
+ status: categoryStatus,
3102
+ children: element
3103
+ } : {
3104
+ fallbackComponent: Component,
3105
+ status: categoryStatus,
3106
+ children: element
3107
+ });
3108
+ }
3109
+ }
3110
+ }
3111
+ if (segment.error) {
3112
+ const errorModule = await loadModule(segment.error);
3113
+ const ErrorComponent = isValidElementType(errorModule.default) ? errorModule.default : null;
3114
+ if (ErrorComponent) element = createElement(errorBoundaryComponent, isMdxFile(segment.error) ? {
3115
+ fallbackElement: createElement(ErrorComponent, {}),
3116
+ children: element
3117
+ } : {
3118
+ fallbackComponent: ErrorComponent,
3119
+ children: element
3120
+ });
3245
3121
  }
3122
+ return element;
3246
3123
  }
3124
+ //#endregion
3125
+ //#region src/server/csrf.ts
3126
+ /** HTTP methods that are considered safe (no mutation). */
3127
+ var SAFE_METHODS = /* @__PURE__ */ new Set([
3128
+ "GET",
3129
+ "HEAD",
3130
+ "OPTIONS"
3131
+ ]);
3247
3132
  /**
3248
- * Compute the cache-entry hash for a component render:
3249
- * `sha256({props, params}).slice(0, 16)` over canonical JSON.
3133
+ * Derive the request's scheme from trusted sources.
3250
3134
  *
3251
- * Returns null when props (or params) contain unkeyable values — the
3252
- * caller must fall back to dynamic rendering.
3135
+ * Priority:
3136
+ * 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
3137
+ * 2. Request URL protocol
3138
+ *
3139
+ * Falls back to 'https' (fail closed) if neither is available.
3253
3140
  */
3254
- function computeComponentCacheKey(props, params) {
3255
- let canonical;
3141
+ function deriveRequestScheme(req) {
3142
+ const forwarded = req.headers.get("X-Forwarded-Proto");
3143
+ if (forwarded) {
3144
+ const proto = forwarded.split(",")[0].trim().toLowerCase();
3145
+ if (proto === "http" || proto === "https") return proto;
3146
+ }
3256
3147
  try {
3257
- const seen = /* @__PURE__ */ new Set();
3258
- canonical = "{\"params\":" + canonicalize$1({ ...params }, seen) + ",\"props\":" + canonicalize$1(props, seen) + "}";
3259
- } catch (error) {
3260
- return toUnkeyable(error);
3148
+ return new URL(req.url).protocol.replace(":", "");
3149
+ } catch {
3150
+ return "https";
3261
3151
  }
3262
- return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
3263
3152
  }
3264
- //#endregion
3265
- //#region src/client/slot-context.ts
3266
3153
  /**
3267
- * SlotContext — delivers live slot content to SlotOutlet holes inside
3268
- * cached component shells (TIM-1173).
3269
- *
3270
- * A `cache.component(Fn, { slots: [...] })` shell is captured with a
3271
- * stable <SlotOutlet slot={name} /> client reference in place of each
3272
- * declared slot prop. At request time the revived shell is wrapped in a
3273
- * SlotsProvider carrying the live slot values; each outlet reads its
3274
- * value from this context by name.
3275
- *
3276
- * The value is a per-instance record — the NEAREST provider wins, so two
3277
- * instances of the same cached component on one page each resolve their
3278
- * own children, and a slot component nested inside another slot
3279
- * component's children reads its own provider, not the outer one.
3280
- *
3281
- * Generalizes the ChildSegmentContext pattern (TIM-1181) from a single
3282
- * implicit `children` hole on layouts to explicitly declared, named slot
3283
- * props on any cached component.
3154
+ * Validate the Origin header against the request's full origin
3155
+ * (scheme + host + port).
3284
3156
  *
3285
- * SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
3286
- * can duplicate this module across chunks; globalThis guarantees a single
3287
- * context instance. Same pattern as ChildSegmentContext.
3157
+ * For mutation methods (POST, PUT, PATCH, DELETE):
3158
+ * - If `csrf: false`, skip validation.
3159
+ * - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
3160
+ * - Otherwise, Origin must match the derived request origin.
3288
3161
  *
3289
- * See design/45-cache-lifetimes.md §Slot Components.
3162
+ * Safe methods (GET, HEAD, OPTIONS) always pass.
3290
3163
  */
3291
- var CTX_KEY = Symbol.for("__timber_slot_ctx");
3292
- function getOrCreateContext() {
3293
- const existing = globalThis[CTX_KEY];
3294
- if (existing !== void 0) return existing;
3295
- if (typeof React.createContext === "function") {
3296
- const ctx = React.createContext(null);
3297
- globalThis[CTX_KEY] = ctx;
3298
- return ctx;
3164
+ function validateCsrf(req, config) {
3165
+ if (SAFE_METHODS.has(req.method)) return { ok: true };
3166
+ if (config.csrf === false) return { ok: true };
3167
+ const origin = req.headers.get("Origin");
3168
+ if (!origin) return {
3169
+ ok: false,
3170
+ status: 403
3171
+ };
3172
+ if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
3173
+ ok: false,
3174
+ status: 403
3175
+ };
3176
+ const host = req.headers.get("Host");
3177
+ if (!host) return {
3178
+ ok: false,
3179
+ status: 403
3180
+ };
3181
+ let originOrigin;
3182
+ try {
3183
+ originOrigin = new URL(origin).origin;
3184
+ } catch {
3185
+ return {
3186
+ ok: false,
3187
+ status: 403
3188
+ };
3189
+ }
3190
+ const scheme = deriveRequestScheme(req);
3191
+ let expectedOrigin;
3192
+ try {
3193
+ expectedOrigin = new URL(`${scheme}://${host}`).origin;
3194
+ } catch {
3195
+ return {
3196
+ ok: false,
3197
+ status: 403
3198
+ };
3299
3199
  }
3200
+ return originOrigin === expectedOrigin ? { ok: true } : {
3201
+ ok: false,
3202
+ status: 403
3203
+ };
3300
3204
  }
3301
- var SlotContext = getOrCreateContext();
3302
3205
  //#endregion
3303
- //#region src/client/slot-outlet.tsx
3206
+ //#region src/server/body-limits.ts
3207
+ var KB = 1024;
3208
+ var MB = 1024 * KB;
3209
+ var GB = 1024 * MB;
3210
+ var DEFAULT_LIMITS = {
3211
+ actionBodySize: 1 * MB,
3212
+ uploadBodySize: 10 * MB,
3213
+ maxFields: 100
3214
+ };
3215
+ var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
3216
+ /** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
3217
+ function parseBodySize(size) {
3218
+ const match = SIZE_PATTERN.exec(size.trim());
3219
+ if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
3220
+ const value = Number.parseFloat(match[1]);
3221
+ const unit = (match[2] ?? "").toLowerCase();
3222
+ switch (unit) {
3223
+ case "kb": return Math.floor(value * KB);
3224
+ case "mb": return Math.floor(value * MB);
3225
+ case "gb": return Math.floor(value * GB);
3226
+ case "": return Math.floor(value);
3227
+ default: throw new Error(`Unknown size unit: "${unit}"`);
3228
+ }
3229
+ }
3230
+ /** Strict integer pattern: one or more digits, nothing else. */
3231
+ var STRICT_INTEGER_RE = /^\d+$/;
3232
+ /** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
3233
+ function enforceBodyLimits(req, kind, config) {
3234
+ const contentLength = req.headers.get("Content-Length");
3235
+ if (!contentLength) return {
3236
+ ok: false,
3237
+ status: 411
3238
+ };
3239
+ const trimmed = contentLength.trim();
3240
+ if (!STRICT_INTEGER_RE.test(trimmed)) return {
3241
+ ok: false,
3242
+ status: 411
3243
+ };
3244
+ return Number.parseInt(trimmed, 10) <= resolveLimit(kind, config) ? { ok: true } : {
3245
+ ok: false,
3246
+ status: 413
3247
+ };
3248
+ }
3304
3249
  /**
3305
- * SlotOutlet — the hole in a cached component shell (TIM-1173).
3306
- *
3307
- * At capture time the prebuilt runtime renders the wrapped component with
3308
- * <SlotOutlet slot={name} /> in place of each declared slot prop. Being a
3309
- * client component, it serializes into the flight payload as a stable,
3310
- * deterministic client reference — the same bytes regardless of what live
3311
- * content will fill the hole. At request time the revived shell sits under
3312
- * a SlotsProvider and each outlet reads its live value from SlotContext.
3313
- *
3314
- * See design/45-cache-lifetimes.md §Slot Components.
3250
+ * Resolve the byte limit for a given body kind, using config overrides or defaults.
3315
3251
  */
3316
- function SlotOutlet({ slot }) {
3317
- const values = useContext(SlotContext);
3318
- return values ? values[slot] ?? null : null;
3252
+ function resolveLimit(kind, config) {
3253
+ const userLimits = config.limits;
3254
+ if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
3255
+ return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
3319
3256
  }
3320
3257
  //#endregion
3321
- //#region src/client/slot-provider.tsx
3258
+ //#region src/server/route-handler.ts
3259
+ /** All recognized HTTP method export names. */
3260
+ var HTTP_METHODS = [
3261
+ "GET",
3262
+ "POST",
3263
+ "PUT",
3264
+ "PATCH",
3265
+ "DELETE",
3266
+ "HEAD",
3267
+ "OPTIONS"
3268
+ ];
3322
3269
  /**
3323
- * SlotsProvider — wraps a revived cached shell to supply live slot content
3324
- * to the SlotOutlet holes inside it (TIM-1173).
3325
- *
3326
- * Tree structure per cached-component instance:
3327
- * SlotsProvider(values={children: <Live />})
3328
- * └── revived shell
3329
- * └── SlotOutlet(slot="children") → reads context → <Live />
3270
+ * Resolve the full list of allowed methods for a route module.
3330
3271
  *
3331
- * See design/45-cache-lifetimes.md §Slot Components.
3272
+ * Includes:
3273
+ * - All explicitly exported methods
3274
+ * - HEAD (implicit when GET is exported)
3275
+ * - OPTIONS (always implicit)
3332
3276
  */
3333
- function SlotsProvider({ values, children }) {
3334
- return createElement(SlotContext.Provider, { value: values }, children);
3277
+ function resolveAllowedMethods(mod) {
3278
+ const methods = [];
3279
+ for (const method of HTTP_METHODS) {
3280
+ if (method === "HEAD" || method === "OPTIONS") continue;
3281
+ if (mod[method]) methods.push(method);
3282
+ }
3283
+ if (mod.GET && !mod.HEAD) methods.push("HEAD");
3284
+ else if (mod.HEAD) methods.push("HEAD");
3285
+ if (!mod.OPTIONS) methods.push("OPTIONS");
3286
+ else methods.push("OPTIONS");
3287
+ return methods;
3335
3288
  }
3336
- //#endregion
3337
- //#region src/server/prebuilt/slots.ts
3338
3289
  /**
3339
- * Slot-component helpers for cache.component (TIM-1173).
3340
- *
3341
- * A `slots: ['children', ...]` declaration caches the component's output
3342
- * as a shell with a stable <SlotOutlet slot={name} /> client-reference
3343
- * hole per declared slot prop; live slot content composes in per instance
3344
- * at request time via SlotsProvider/SlotContext. This generalizes the
3345
- * TIM-1181 layout mechanism (ChildSegmentOutlet) to explicitly declared,
3346
- * named slots on any cached component.
3290
+ * Handle an incoming request against a route.ts module.
3347
3291
  *
3348
- * Both the production wrapper (cache path) and the dev wrapper (no cache,
3349
- * same tree shape for dev/prod parity) build their render from these
3350
- * helpers. See design/45-cache-lifetimes.md §Slot Components.
3351
- */
3352
- /**
3353
- * Is this value the framework-injected `<ChildSegmentOutlet />` element?
3354
- * Total: the `in` probe and `.type` read run against a user-controlled
3355
- * value, and a Proxy trap can throw — that must classify as "not the
3356
- * outlet" (the canonicalizer then rejects the value as unkeyable → live
3357
- * render), not abort the request (codex P2 on PR #890).
3292
+ * Dispatches to the named method handler, auto-generates 405/OPTIONS,
3293
+ * and merges response headers from ctx.headers.
3358
3294
  */
3359
- function isChildSegmentOutletElement(value) {
3360
- try {
3361
- return value != null && typeof value === "object" && "type" in value && value.type === ChildSegmentOutlet;
3362
- } catch {
3363
- return false;
3295
+ async function handleRouteRequest(mod, ctx) {
3296
+ const method = ctx.req.method.toUpperCase();
3297
+ const allowHeader = resolveAllowedMethods(mod).join(", ");
3298
+ if (method === "OPTIONS") {
3299
+ if (mod.OPTIONS) return runHandler(mod.OPTIONS, ctx);
3300
+ return new Response(null, {
3301
+ status: 204,
3302
+ headers: { Allow: allowHeader }
3303
+ });
3304
+ }
3305
+ if (method === "HEAD") {
3306
+ if (mod.HEAD) return runHandler(mod.HEAD, ctx);
3307
+ if (mod.GET) {
3308
+ const res = await runHandler(mod.GET, ctx);
3309
+ return new Response(null, {
3310
+ status: res.status,
3311
+ headers: res.headers
3312
+ });
3313
+ }
3364
3314
  }
3315
+ const handler = mod[method];
3316
+ if (!handler) return new Response(null, {
3317
+ status: 405,
3318
+ headers: { Allow: allowHeader }
3319
+ });
3320
+ return runHandler(handler, ctx);
3365
3321
  }
3366
3322
  /**
3367
- * Dense array of non-empty strings — no holes, no non-string entries.
3368
- * Total: the reads run against a user-controlled value at module load,
3369
- * and a throwing Proxy trap must classify as invalid, not crash startup.
3323
+ * Run a handler, merge ctx.headers into the response, and catch errors.
3370
3324
  */
3371
- function isDenseStringArray(value) {
3325
+ async function runHandler(handler, ctx) {
3372
3326
  try {
3373
- if (!Array.isArray(value)) return false;
3374
- for (let i = 0; i < value.length; i++) {
3375
- if (!Object.hasOwn(value, i)) return false;
3376
- const entry = value[i];
3377
- if (typeof entry !== "string" || entry.length === 0) return false;
3378
- }
3379
- return true;
3380
- } catch {
3381
- return false;
3327
+ return mergeResponseHeaders(await handler(ctx), ctx.headers);
3328
+ } catch (error) {
3329
+ if (error instanceof DenySignal || error instanceof RedirectSignal) throw error;
3330
+ logRouteError({
3331
+ method: ctx.req.method,
3332
+ path: new URL(ctx.req.url).pathname,
3333
+ error
3334
+ });
3335
+ return new Response(null, { status: 500 });
3382
3336
  }
3383
3337
  }
3384
3338
  /**
3385
- * Validate the `slots` option. Both the production and dev wrappers gate
3386
- * on this — slot substitution is active in dev exactly when it would be
3387
- * active in production, so the tree shape a component sees never
3388
- * diverges between the two.
3339
+ * Merge response headers from ctx.headers into the handler's response.
3340
+ * ctx.headers (set by middleware or the handler) are applied to the final response.
3341
+ * Handler-set headers take precedence over ctx.headers.
3389
3342
  */
3390
- function resolveSlotNames(id, options) {
3391
- const slots = options.slots;
3392
- if (slots === void 0) return { kind: "none" };
3393
- if (!isDenseStringArray(slots)) {
3394
- console.warn(`[timber] cache.component "${id}": invalid slots declaration — expected an array of non-empty prop-name strings. Slot caching is disabled.`);
3395
- return { kind: "disabled" };
3396
- }
3397
- if (slots.length === 0) return { kind: "none" };
3398
- if (!(options.ttl !== void 0 || options.tags != null)) {
3399
- console.warn(`[timber] cache.component "${id}": slots require a runtime lifetime (ttl and/or tags). Slot caching is disabled.`);
3400
- return { kind: "disabled" };
3401
- }
3402
- return {
3403
- kind: "active",
3404
- slots
3405
- };
3343
+ function mergeResponseHeaders(res, ctxHeaders) {
3344
+ let hasCtxHeaders = false;
3345
+ ctxHeaders.forEach(() => {
3346
+ hasCtxHeaders = true;
3347
+ });
3348
+ if (!hasCtxHeaders) return res;
3349
+ const merged = new Headers();
3350
+ ctxHeaders.forEach((value, key) => {
3351
+ if (key.toLowerCase() === "set-cookie") merged.append(key, value);
3352
+ else merged.set(key, value);
3353
+ });
3354
+ const resCookies = res.headers.getSetCookie();
3355
+ for (const cookie of resCookies) merged.append("Set-Cookie", cookie);
3356
+ res.headers.forEach((value, key) => {
3357
+ if (key.toLowerCase() !== "set-cookie") merged.set(key, value);
3358
+ });
3359
+ return new Response(res.body, {
3360
+ status: res.status,
3361
+ statusText: res.statusText,
3362
+ headers: merged
3363
+ });
3406
3364
  }
3365
+ //#endregion
3366
+ //#region src/server/render-timeout.ts
3407
3367
  /**
3408
- * Split the live props into the cacheable shell render (data props plus a
3409
- * deterministic SlotOutlet placeholder per declared slot — identical
3410
- * whether or not the caller passed the slot) and the per-instance live
3411
- * slot values.
3368
+ * Render timeout utilities for SSR streaming pipeline.
3412
3369
  *
3413
- * Null when the props object itself is unkeyable at the top level
3414
- * (accessors, hidden properties, exotic prototype — see splitSlotProps):
3415
- * the caller must render live with the original props.
3370
+ * Provides a RenderTimeoutError class and a helper to create
3371
+ * timeout-guarded AbortSignals. Used to defend against hung RSC
3372
+ * streams and infinite SSR renders.
3373
+ *
3374
+ * Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
3416
3375
  */
3417
- function prepareSlotRender(props, slots) {
3418
- const split = splitSlotProps(props ?? {}, slots);
3419
- if (split === null) return null;
3420
- const { dataProps, slotValues } = split;
3421
- const shellProps = { ...dataProps };
3422
- for (const slot of slots) Object.defineProperty(shellProps, slot, {
3423
- value: createElement(SlotOutlet, { slot }),
3424
- enumerable: true,
3425
- writable: true,
3426
- configurable: true
3427
- });
3428
- return {
3429
- shellProps,
3430
- dataProps,
3431
- slotValues
3432
- };
3433
- }
3434
3376
  /**
3435
- * Wrap a rendered shell (revived payload or live element) in a
3436
- * per-instance SlotsProvider feeding the live values to its outlets.
3377
+ * Error thrown when an SSR render or RSC stream read exceeds the
3378
+ * configured timeout. Callers can check `instanceof RenderTimeoutError`
3379
+ * to distinguish timeout from other errors and return a 504 or close
3380
+ * the connection cleanly.
3437
3381
  */
3438
- function wrapInSlotsProvider(shell, slotValues) {
3439
- return createElement(SlotsProvider, {
3440
- values: slotValues,
3441
- children: shell
3442
- });
3443
- }
3382
+ var RenderTimeoutError = class extends Error {
3383
+ timeoutMs;
3384
+ constructor(timeoutMs, context) {
3385
+ const message = context ? `Render timeout after ${timeoutMs}ms: ${context}` : `Render timeout after ${timeoutMs}ms`;
3386
+ super(message);
3387
+ this.name = "RenderTimeoutError";
3388
+ this.timeoutMs = timeoutMs;
3389
+ }
3390
+ };
3444
3391
  //#endregion
3445
3392
  //#region src/server/prebuilt/key-discipline.ts
3446
3393
  /** Components already warned — exported so tests can reset between cases. */
@@ -3609,7 +3556,7 @@ function resolveKeyParams(store) {
3609
3556
  * exactly when production would serve the cache path rather than the
3610
3557
  * live fallback (dev/prod parity, codex P2 on PR #890): requires an ALS
3611
3558
  * store, keyable params (slot-param divergence unkeys), and keyable data
3612
- * props (after the ChildSegmentOutlet strip).
3559
+ * props (after the children slot outlet strip).
3613
3560
  */
3614
3561
  function isSlotRenderKeyable(dataProps) {
3615
3562
  const store = requestContextAls.getStore();
@@ -3617,7 +3564,7 @@ function isSlotRenderKeyable(dataProps) {
3617
3564
  const keyParams = resolveKeyParams(store);
3618
3565
  if (keyParams === null) return false;
3619
3566
  let keyProps = dataProps;
3620
- if (isChildSegmentOutletElement(keyProps.children)) {
3567
+ if (isChildrenSlotOutletElement(keyProps.children)) {
3621
3568
  const { children: _children, ...rest } = keyProps;
3622
3569
  keyProps = rest;
3623
3570
  }
@@ -3824,7 +3771,7 @@ async function tryRevivePayload(id, options, props, keyPropsOverride) {
3824
3771
  const keyParams = (options.prerender ? await isParamIndependent(id) : false) ? {} : resolveKeyParams(store);
3825
3772
  if (keyParams === null) return null;
3826
3773
  let keyProps = keyPropsOverride ?? props ?? {};
3827
- if (isChildSegmentOutletElement(keyProps.children)) {
3774
+ if (isChildrenSlotOutletElement(keyProps.children)) {
3828
3775
  const { children: _children, ...rest } = keyProps;
3829
3776
  keyProps = rest;
3830
3777
  }