@timber-js/app 0.2.0-alpha.171 → 0.2.0-alpha.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_chunks/{actions-O_LsyCE4.js → actions-pN8r5Vnh.js} +3 -3
- package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-pN8r5Vnh.js.map} +1 -1
- package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-2hT5kfsr.js} +2 -2
- package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-2hT5kfsr.js.map} +1 -1
- package/dist/_chunks/{canonicalize-Du3o_ptW.js → canonicalize-DQHyFClh.js} +2 -1
- package/dist/_chunks/canonicalize-DQHyFClh.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-B5FDplGI.js → cli-schema-sync-DvdvFwwE.js} +3 -3
- package/dist/_chunks/{cli-schema-sync-B5FDplGI.js.map → cli-schema-sync-DvdvFwwE.js.map} +1 -1
- package/dist/_chunks/{logger-AWfuX-KJ.js → logger-kUT0QH0K.js} +23 -1
- package/dist/_chunks/logger-kUT0QH0K.js.map +1 -0
- package/dist/_chunks/{walkers-DCoE-LJf.js → walkers-Bv63zfAC.js} +2 -2
- package/dist/_chunks/{walkers-DCoE-LJf.js.map → walkers-Bv63zfAC.js.map} +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.d.ts +3 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -5
- package/dist/cli.js.map +1 -1
- package/dist/client/internal.js +40 -6
- package/dist/client/internal.js.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -1
- package/dist/client/segment-cache.d.ts +20 -3
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +10 -2
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/slot-context.d.ts +10 -8
- package/dist/client/slot-context.d.ts.map +1 -1
- package/dist/client/slot-provider.d.ts +5 -0
- package/dist/client/slot-provider.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/routing/index.js +2 -2
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +26 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +1614 -1667
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-routes.d.ts +13 -0
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/metadata.d.ts +8 -0
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/prebuilt/slots.d.ts +33 -8
- package/dist/server/prebuilt/slots.d.ts.map +1 -1
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +15 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +9 -11
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +18 -10
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +2 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +46 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +36 -3
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +7 -0
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +15 -5
- package/src/client/rsc-fetch.ts +1 -1
- package/src/client/segment-cache.ts +83 -10
- package/src/client/segment-outlet.tsx +24 -2
- package/src/client/slot-context.ts +10 -8
- package/src/client/slot-provider.tsx +9 -2
- package/src/server/access-gate.tsx +28 -1
- package/src/server/als-registry.ts +44 -0
- package/src/server/cookie-context.ts +7 -1
- package/src/server/deny-renderer.ts +1 -1
- package/src/server/metadata-routes.ts +95 -0
- package/src/server/metadata.ts +21 -0
- package/src/server/prebuilt/slots.ts +39 -16
- package/src/server/prebuilt-builder.ts +6 -5
- package/src/server/prebuilt-runtime.ts +11 -11
- package/src/server/request-context.ts +53 -1
- package/src/server/route-element-builder.ts +72 -144
- package/src/server/rsc-entry/helpers.ts +68 -14
- package/src/server/rsc-entry/render-route.ts +11 -3
- package/src/server/rsc-entry/rsc-payload.ts +16 -3
- package/src/server/rsc-entry/ssr-renderer.ts +3 -1
- package/src/server/slot-resolver.ts +321 -12
- package/src/server/state-tree-diff.ts +104 -8
- package/src/server/tree-builder.ts +10 -0
- package/dist/_chunks/canonicalize-Du3o_ptW.js.map +0 -1
- package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
- package/dist/client/child-segment-context.d.ts +0 -22
- package/dist/client/child-segment-context.d.ts.map +0 -1
- package/dist/client/child-segment-outlet.d.ts +0 -18
- package/dist/client/child-segment-outlet.d.ts.map +0 -1
- package/dist/client/child-segment-provider.d.ts +0 -21
- package/dist/client/child-segment-provider.d.ts.map +0 -1
- package/src/client/child-segment-context.ts +0 -40
- package/src/client/child-segment-outlet.tsx +0 -25
- package/src/client/child-segment-provider.tsx +0 -27
package/dist/server/internal.js
CHANGED
|
@@ -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-
|
|
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-
|
|
4
|
-
import { i as coerce, t as executeAction } from "../_chunks/actions-
|
|
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-
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
*
|
|
1601
|
-
* context
|
|
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:
|
|
1604
|
-
*
|
|
1605
|
-
*
|
|
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/
|
|
1561
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
1611
1562
|
*/
|
|
1612
|
-
var
|
|
1613
|
-
|
|
1614
|
-
|
|
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(
|
|
1619
|
-
globalThis[CTX_KEY$
|
|
1568
|
+
const ctx = React.createContext(null);
|
|
1569
|
+
globalThis[CTX_KEY$1] = ctx;
|
|
1620
1570
|
return ctx;
|
|
1621
1571
|
}
|
|
1622
1572
|
}
|
|
1623
|
-
getOrCreateContext$
|
|
1573
|
+
var SlotContext = getOrCreateContext$1();
|
|
1624
1574
|
//#endregion
|
|
1625
|
-
//#region src/client/
|
|
1575
|
+
//#region src/client/slot-outlet.tsx
|
|
1626
1576
|
/**
|
|
1627
|
-
*
|
|
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
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
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
|
-
*
|
|
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/
|
|
1593
|
+
//#region src/client/slot-provider.tsx
|
|
1651
1594
|
/**
|
|
1652
|
-
*
|
|
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
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
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
|
-
*
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
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 §
|
|
1608
|
+
* See design/45-cache-lifetimes.md §Slot Components.
|
|
1669
1609
|
*/
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
const
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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/
|
|
1619
|
+
//#region src/server/prebuilt/cache-key.ts
|
|
1683
1620
|
/**
|
|
1684
|
-
*
|
|
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
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
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
|
-
*
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
*
|
|
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
|
-
*
|
|
1708
|
-
*
|
|
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
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1718
|
-
|
|
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
|
-
*
|
|
1721
|
-
*
|
|
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
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
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
|
-
|
|
1744
|
-
"
|
|
1745
|
-
|
|
1746
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
const
|
|
1782
|
-
|
|
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 (
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
*
|
|
1784
|
+
* Compute the cache-entry hash for a component render:
|
|
1785
|
+
* `sha256({props, params}).slice(0, 16)` over canonical JSON.
|
|
1808
1786
|
*
|
|
1809
|
-
*
|
|
1810
|
-
*
|
|
1787
|
+
* Returns null when props (or params) contain unkeyable values — the
|
|
1788
|
+
* caller must fall back to dynamic rendering.
|
|
1811
1789
|
*/
|
|
1812
|
-
function
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1821
|
-
*
|
|
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
|
-
*
|
|
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
|
|
1826
|
-
|
|
1827
|
-
if (
|
|
1828
|
-
if (
|
|
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
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
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
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
*
|
|
1870
|
-
*
|
|
1871
|
-
*
|
|
1872
|
-
*
|
|
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
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
-
*
|
|
1894
|
-
*
|
|
1895
|
-
*
|
|
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
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
*
|
|
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
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
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
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1966
|
+
throw new ParamCoercionError(message);
|
|
1983
1967
|
}
|
|
1984
1968
|
}
|
|
1985
1969
|
}
|
|
1986
1970
|
//#endregion
|
|
1987
|
-
//#region src/
|
|
1971
|
+
//#region src/client/segment-update-context.ts
|
|
1988
1972
|
/**
|
|
1989
|
-
*
|
|
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
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2004
|
+
* SegmentOutlet — client component boundary at each layout segment.
|
|
2003
2005
|
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
*
|
|
2007
|
-
*
|
|
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
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
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
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
}
|
|
2037
|
+
};
|
|
2038
|
+
//#endregion
|
|
2039
|
+
//#region src/server/pipeline-metadata.ts
|
|
2020
2040
|
/**
|
|
2021
|
-
*
|
|
2041
|
+
* Metadata route helpers for the request pipeline.
|
|
2022
2042
|
*
|
|
2023
|
-
*
|
|
2024
|
-
*
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
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
|
-
*
|
|
2060
|
-
*
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
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
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
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
|
-
*
|
|
2119
|
-
*
|
|
2083
|
+
* Serialize a sitemap array to XML.
|
|
2084
|
+
* Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
|
|
2120
2085
|
*/
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
}
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2147
2102
|
}
|
|
2103
|
+
//#endregion
|
|
2104
|
+
//#region src/server/pipeline-interception.ts
|
|
2148
2105
|
/**
|
|
2149
|
-
*
|
|
2106
|
+
* Interception route matching for the request pipeline.
|
|
2150
2107
|
*
|
|
2151
|
-
*
|
|
2152
|
-
* route
|
|
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
|
-
*
|
|
2157
|
-
* the `next()` continuation passed to `runProxy()`.
|
|
2111
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
2158
2112
|
*
|
|
2159
|
-
*
|
|
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
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
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
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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
|
-
|
|
2197
|
-
|
|
2198
|
-
return res;
|
|
2164
|
+
pi++;
|
|
2165
|
+
continue;
|
|
2199
2166
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
2178
|
+
* Pipeline outcome translator — converts a `PhaseOutcome` (the value
|
|
2179
|
+
* each phase function returns) into a final `Response`.
|
|
2322
2180
|
*
|
|
2323
|
-
*
|
|
2324
|
-
*
|
|
2325
|
-
*
|
|
2326
|
-
*
|
|
2327
|
-
*
|
|
2328
|
-
*
|
|
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
|
|
2331
|
-
|
|
2332
|
-
const
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2434
|
-
*
|
|
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
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2454
|
-
*
|
|
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
|
-
*
|
|
2311
|
+
* Validate and canonicalize the X-Timber-URL header value.
|
|
2472
2312
|
*
|
|
2473
|
-
*
|
|
2474
|
-
*
|
|
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
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
for (
|
|
2480
|
-
|
|
2481
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2520
|
-
*
|
|
2521
|
-
*
|
|
2522
|
-
*
|
|
2523
|
-
*
|
|
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
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
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
|
-
*
|
|
2539
|
-
*
|
|
2540
|
-
*
|
|
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
|
|
2549
|
-
const
|
|
2550
|
-
const
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
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
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
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
|
-
*
|
|
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
|
|
2613
|
-
|
|
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
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2650
|
-
*
|
|
2651
|
-
|
|
2652
|
-
|
|
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
|
-
*
|
|
2662
|
-
*
|
|
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
|
-
*
|
|
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
|
|
2671
|
-
const
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
if (
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
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
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
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
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
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
|
-
*
|
|
2630
|
+
* Create the request handler from a pipeline configuration.
|
|
2715
2631
|
*
|
|
2716
|
-
*
|
|
2717
|
-
*
|
|
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
|
-
|
|
2720
|
-
const
|
|
2721
|
-
const
|
|
2722
|
-
|
|
2723
|
-
let
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
const
|
|
2727
|
-
const
|
|
2728
|
-
const
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2762
|
-
*
|
|
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
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2816
|
-
|
|
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
|
-
*
|
|
2762
|
+
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
2763
|
+
* Deduplicates by href while preserving order.
|
|
2831
2764
|
*/
|
|
2832
|
-
function
|
|
2833
|
-
const
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
if (
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2782
|
+
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
2783
|
+
* for each layout and page. Deduplicates across segments.
|
|
2854
2784
|
*/
|
|
2855
|
-
function
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
const
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
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
|
|
2892
|
-
ok: false,
|
|
2893
|
-
status: 403
|
|
2894
|
-
};
|
|
2797
|
+
return result;
|
|
2895
2798
|
}
|
|
2896
2799
|
//#endregion
|
|
2897
|
-
//#region src/server/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
2818
|
+
* Format a single EarlyHint as a Link header value.
|
|
2962
2819
|
*
|
|
2963
|
-
*
|
|
2964
|
-
*
|
|
2965
|
-
* -
|
|
2966
|
-
*
|
|
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
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
if (
|
|
2972
|
-
if (
|
|
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
|
-
|
|
2975
|
-
|
|
2976
|
-
if (
|
|
2977
|
-
|
|
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
|
-
*
|
|
2847
|
+
* Collect all Link header strings for a matched route's segment chain.
|
|
2982
2848
|
*
|
|
2983
|
-
*
|
|
2984
|
-
*
|
|
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
|
-
|
|
2987
|
-
const
|
|
2988
|
-
const
|
|
2989
|
-
|
|
2990
|
-
if (
|
|
2991
|
-
|
|
2992
|
-
|
|
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
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
});
|
|
3011
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3031
|
-
*
|
|
3032
|
-
*
|
|
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
|
|
3035
|
-
|
|
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
|
-
*
|
|
2914
|
+
* Send collected Link headers as a 103 Early Hints response.
|
|
3060
2915
|
*
|
|
3061
|
-
*
|
|
3062
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3088
|
-
*
|
|
3089
|
-
*
|
|
3090
|
-
*
|
|
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
|
-
*
|
|
3093
|
-
*
|
|
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
|
-
*
|
|
3106
|
-
*
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
*
|
|
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
|
-
*
|
|
3118
|
-
*
|
|
3119
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
3126
|
-
|
|
3127
|
-
if (
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
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 (
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
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
|
-
*
|
|
3169
|
-
*
|
|
3170
|
-
*
|
|
3171
|
-
*
|
|
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
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
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
|
-
*
|
|
3183
|
-
*
|
|
3184
|
-
*
|
|
3185
|
-
*
|
|
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
|
|
3192
|
-
|
|
3193
|
-
return null;
|
|
3057
|
+
function isMdxFile(file) {
|
|
3058
|
+
return MDX_EXTENSIONS.has(file.extension);
|
|
3194
3059
|
}
|
|
3195
3060
|
/**
|
|
3196
|
-
*
|
|
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
|
-
*
|
|
3201
|
-
*
|
|
3202
|
-
*
|
|
3203
|
-
*
|
|
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
|
-
*
|
|
3207
|
-
*
|
|
3208
|
-
*
|
|
3209
|
-
*
|
|
3210
|
-
*
|
|
3211
|
-
*
|
|
3212
|
-
*
|
|
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
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
}
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
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
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
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
|
-
*
|
|
3249
|
-
* `sha256({props, params}).slice(0, 16)` over canonical JSON.
|
|
3133
|
+
* Derive the request's scheme from trusted sources.
|
|
3250
3134
|
*
|
|
3251
|
-
*
|
|
3252
|
-
*
|
|
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
|
|
3255
|
-
|
|
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
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
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
|
-
*
|
|
3268
|
-
*
|
|
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
|
-
*
|
|
3286
|
-
*
|
|
3287
|
-
*
|
|
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
|
-
*
|
|
3162
|
+
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
3290
3163
|
*/
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
if (
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
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/
|
|
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
|
-
*
|
|
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
|
|
3317
|
-
const
|
|
3318
|
-
return
|
|
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/
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3272
|
+
* Includes:
|
|
3273
|
+
* - All explicitly exported methods
|
|
3274
|
+
* - HEAD (implicit when GET is exported)
|
|
3275
|
+
* - OPTIONS (always implicit)
|
|
3332
3276
|
*/
|
|
3333
|
-
function
|
|
3334
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3349
|
-
*
|
|
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
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
return
|
|
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
|
-
*
|
|
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
|
|
3325
|
+
async function runHandler(handler, ctx) {
|
|
3372
3326
|
try {
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
return
|
|
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
|
-
*
|
|
3386
|
-
*
|
|
3387
|
-
*
|
|
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
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
3414
|
-
*
|
|
3415
|
-
*
|
|
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
|
-
*
|
|
3436
|
-
*
|
|
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
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
3774
|
+
if (isChildrenSlotOutletElement(keyProps.children)) {
|
|
3828
3775
|
const { children: _children, ...rest } = keyProps;
|
|
3829
3776
|
keyProps = rest;
|
|
3830
3777
|
}
|