@timber-js/app 0.2.0-alpha.34 → 0.2.0-alpha.35
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/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
- package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
- package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
- package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
- package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
- package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
- package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
- package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
- package/dist/_chunks/wrappers-C1SN725w.js +331 -0
- package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +193 -90
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +8 -8
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +23 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +2 -2
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +33 -12
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -81
- package/dist/index.d.ts +87 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +346 -210
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +76 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +104 -0
- package/dist/params/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +32 -0
- package/dist/plugins/client-chunks.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +6 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +153 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +4 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +78 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +29 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1819 -1629
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +28 -40
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +2 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +12 -8
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/package.json +12 -8
- package/src/client/browser-entry.ts +55 -13
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/index.ts +9 -1
- package/src/client/link.tsx +9 -9
- package/src/client/navigation-context.ts +2 -2
- package/src/client/router.ts +102 -55
- package/src/client/rsc-fetch.ts +63 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/stale-reload.ts +28 -0
- package/src/client/top-loader.tsx +2 -2
- package/src/client/use-params.ts +3 -3
- package/src/client/use-query-states.ts +1 -1
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +69 -18
- package/src/index.ts +255 -65
- package/src/params/define.ts +260 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +6 -0
- package/src/plugins/build-manifest.ts +11 -0
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/entries.ts +3 -6
- package/src/plugins/routing.ts +40 -14
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +1 -1
- package/src/plugins/static-build.ts +8 -4
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +55 -6
- package/src/routing/status-file-lint.ts +2 -1
- package/src/routing/types.ts +7 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +504 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +38 -8
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +16 -0
- package/src/server/als-registry.ts +4 -4
- package/src/server/build-manifest.ts +4 -4
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +57 -14
- package/src/server/flight-injection-state.ts +152 -0
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +42 -26
- package/src/server/index.ts +2 -4
- package/src/server/node-stream-transforms.ts +68 -41
- package/src/server/pipeline.ts +98 -26
- package/src/server/request-context.ts +49 -124
- package/src/server/route-element-builder.ts +102 -99
- package/src/server/route-matcher.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +3 -2
- package/src/server/rsc-entry/index.ts +26 -11
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +4 -4
- package/src/server/slot-resolver.ts +204 -206
- package/src/server/ssr-entry.ts +3 -1
- package/src/server/ssr-render.ts +3 -0
- package/src/server/tree-builder.ts +84 -48
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +1 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/create.ts +0 -321
- package/src/server/prerender.ts +0 -139
package/dist/server/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { n as isDevMode, t as isDebug } from "../_chunks/debug-
|
|
2
|
-
import { a as warnDenyAfterFlush, c as warnRedirectInAccess, d as warnSlowSlotWithoutSuspense, f as warnStaticRequestApi, i as warnCacheRequestProps, l as warnRedirectInSlotAccess, n as WarningId, o as warnDenyInSuspense, p as warnSuspenseWrappingChildren, r as setViteServer, s as warnDynamicApiInStaticBuild, t as formatSize, u as warnRedirectInSuspense } from "../_chunks/format-
|
|
3
|
-
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-
|
|
4
|
-
import { a as timingAls, i as revalidationAls, n as formFlashAls, s as waitUntilAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-
|
|
5
|
-
import { a as markResponseFlushed, c as
|
|
6
|
-
import { a as getTraceStore, c as setSpanAttribute, d as withSpan, i as getOtelTraceId, l as spanId, o as replaceTraceId, r as generateTraceId, s as runWithTraceId, t as addSpanEvent, u as traceId } from "../_chunks/tracing-
|
|
1
|
+
import { n as isDevMode, t as isDebug } from "../_chunks/debug-ECi_61pb.js";
|
|
2
|
+
import { a as warnDenyAfterFlush, c as warnRedirectInAccess, d as warnSlowSlotWithoutSuspense, f as warnStaticRequestApi, i as warnCacheRequestProps, l as warnRedirectInSlotAccess, n as WarningId, o as warnDenyInSuspense, p as warnSuspenseWrappingChildren, r as setViteServer, s as warnDynamicApiInStaticBuild, t as formatSize, u as warnRedirectInSuspense } from "../_chunks/format-cX7wzEp2.js";
|
|
3
|
+
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-BU684ls2.js";
|
|
4
|
+
import { a as timingAls, i as revalidationAls, n as formFlashAls, s as waitUntilAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-Ba7URUIn.js";
|
|
5
|
+
import { a as markResponseFlushed, c as runWithRequestContext, i as headers, l as setMutableCookieContext, n as cookies, o as rawSearchParams, r as getSetCookieHeaders, t as applyRequestHeaderOverlay } from "../_chunks/request-context-CZz_T0Bc.js";
|
|
6
|
+
import { a as getTraceStore, c as setSpanAttribute, d as withSpan, i as getOtelTraceId, l as spanId, o as replaceTraceId, r as generateTraceId, s as runWithTraceId, t as addSpanEvent, u as traceId } from "../_chunks/tracing-BPyIzIdu.js";
|
|
7
|
+
import "../_chunks/error-boundary-TYEQJZ1-.js";
|
|
8
|
+
import "../_chunks/segment-context-Dpq2XOKg.js";
|
|
7
9
|
import { readFile } from "node:fs/promises";
|
|
10
|
+
import "react";
|
|
8
11
|
//#region src/server/waituntil-bridge.ts
|
|
9
12
|
/**
|
|
10
13
|
* Per-request waitUntil bridge — ALS bridge for platform adapters.
|
|
@@ -695,740 +698,689 @@ function hasOnRequestError() {
|
|
|
695
698
|
return _onRequestError !== null;
|
|
696
699
|
}
|
|
697
700
|
//#endregion
|
|
698
|
-
//#region src/server/
|
|
699
|
-
/**
|
|
700
|
-
* Metadata route helpers for the request pipeline.
|
|
701
|
-
*
|
|
702
|
-
* Handles serving static metadata files and serializing sitemap responses.
|
|
703
|
-
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
704
|
-
*
|
|
705
|
-
* See design/16-metadata.md §"Metadata Routes"
|
|
706
|
-
*/
|
|
707
|
-
/**
|
|
708
|
-
* Content types that are text-based and should include charset=utf-8.
|
|
709
|
-
* Binary formats (images) should not include charset.
|
|
710
|
-
*/
|
|
711
|
-
var TEXT_CONTENT_TYPES = new Set([
|
|
712
|
-
"application/xml",
|
|
713
|
-
"text/plain",
|
|
714
|
-
"application/json",
|
|
715
|
-
"application/manifest+json",
|
|
716
|
-
"image/svg+xml"
|
|
717
|
-
]);
|
|
701
|
+
//#region src/server/metadata-social.ts
|
|
718
702
|
/**
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
722
|
-
* are served as-is with the appropriate Content-Type header.
|
|
723
|
-
* Text files include charset=utf-8; binary files do not.
|
|
703
|
+
* Render Open Graph metadata into head element descriptors.
|
|
724
704
|
*
|
|
725
|
-
*
|
|
705
|
+
* Handles og:title, og:description, og:image (with dimensions/alt),
|
|
706
|
+
* og:video, og:audio, og:article:author, and other OG properties.
|
|
726
707
|
*/
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
"
|
|
733
|
-
"
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
708
|
+
function renderOpenGraph(og, elements) {
|
|
709
|
+
const simpleProps = [
|
|
710
|
+
["og:title", og.title],
|
|
711
|
+
["og:description", og.description],
|
|
712
|
+
["og:url", og.url],
|
|
713
|
+
["og:site_name", og.siteName],
|
|
714
|
+
["og:locale", og.locale],
|
|
715
|
+
["og:type", og.type],
|
|
716
|
+
["og:article:published_time", og.publishedTime],
|
|
717
|
+
["og:article:modified_time", og.modifiedTime]
|
|
718
|
+
];
|
|
719
|
+
for (const [property, content] of simpleProps) if (content) elements.push({
|
|
720
|
+
tag: "meta",
|
|
721
|
+
attrs: {
|
|
722
|
+
property,
|
|
723
|
+
content
|
|
724
|
+
}
|
|
738
725
|
});
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
726
|
+
if (og.images) if (typeof og.images === "string") elements.push({
|
|
727
|
+
tag: "meta",
|
|
728
|
+
attrs: {
|
|
729
|
+
property: "og:image",
|
|
730
|
+
content: og.images
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
else {
|
|
734
|
+
const imgList = Array.isArray(og.images) ? og.images : [og.images];
|
|
735
|
+
for (const img of imgList) {
|
|
736
|
+
elements.push({
|
|
737
|
+
tag: "meta",
|
|
738
|
+
attrs: {
|
|
739
|
+
property: "og:image",
|
|
740
|
+
content: img.url
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
if (img.width) elements.push({
|
|
744
|
+
tag: "meta",
|
|
745
|
+
attrs: {
|
|
746
|
+
property: "og:image:width",
|
|
747
|
+
content: String(img.width)
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
if (img.height) elements.push({
|
|
751
|
+
tag: "meta",
|
|
752
|
+
attrs: {
|
|
753
|
+
property: "og:image:height",
|
|
754
|
+
content: String(img.height)
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
if (img.alt) elements.push({
|
|
758
|
+
tag: "meta",
|
|
759
|
+
attrs: {
|
|
760
|
+
property: "og:image:alt",
|
|
761
|
+
content: img.alt
|
|
762
|
+
}
|
|
763
|
+
});
|
|
750
764
|
}
|
|
751
|
-
if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
|
|
752
|
-
if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
|
|
753
|
-
xml += "\n </url>";
|
|
754
|
-
return xml;
|
|
755
|
-
}).join("\n")}\n</urlset>`;
|
|
756
|
-
}
|
|
757
|
-
/** Escape special XML characters. */
|
|
758
|
-
function escapeXml(str) {
|
|
759
|
-
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
760
|
-
}
|
|
761
|
-
//#endregion
|
|
762
|
-
//#region src/server/pipeline-interception.ts
|
|
763
|
-
/**
|
|
764
|
-
* Check if an intercepting route applies for this soft navigation.
|
|
765
|
-
*
|
|
766
|
-
* Matches the target pathname against interception rewrites, constrained
|
|
767
|
-
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
768
|
-
*
|
|
769
|
-
* Returns the source pathname to re-match if interception applies, or null.
|
|
770
|
-
*/
|
|
771
|
-
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
772
|
-
for (const rewrite of rewrites) {
|
|
773
|
-
if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
|
|
774
|
-
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
775
765
|
}
|
|
776
|
-
|
|
766
|
+
if (og.videos) for (const video of og.videos) elements.push({
|
|
767
|
+
tag: "meta",
|
|
768
|
+
attrs: {
|
|
769
|
+
property: "og:video",
|
|
770
|
+
content: video.url
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
if (og.audio) for (const audio of og.audio) elements.push({
|
|
774
|
+
tag: "meta",
|
|
775
|
+
attrs: {
|
|
776
|
+
property: "og:audio",
|
|
777
|
+
content: audio.url
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
if (og.authors) for (const author of og.authors) elements.push({
|
|
781
|
+
tag: "meta",
|
|
782
|
+
attrs: {
|
|
783
|
+
property: "og:article:author",
|
|
784
|
+
content: author
|
|
785
|
+
}
|
|
786
|
+
});
|
|
777
787
|
}
|
|
778
788
|
/**
|
|
779
|
-
*
|
|
789
|
+
* Render Twitter Card metadata into head element descriptors.
|
|
780
790
|
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
791
|
+
* Handles twitter:card, twitter:site, twitter:title, twitter:image,
|
|
792
|
+
* twitter:player, and twitter:app (per-platform name/id/url).
|
|
783
793
|
*/
|
|
784
|
-
function
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
794
|
+
function renderTwitter(tw, elements) {
|
|
795
|
+
const simpleProps = [
|
|
796
|
+
["twitter:card", tw.card],
|
|
797
|
+
["twitter:site", tw.site],
|
|
798
|
+
["twitter:site:id", tw.siteId],
|
|
799
|
+
["twitter:title", tw.title],
|
|
800
|
+
["twitter:description", tw.description],
|
|
801
|
+
["twitter:creator", tw.creator],
|
|
802
|
+
["twitter:creator:id", tw.creatorId]
|
|
803
|
+
];
|
|
804
|
+
for (const [name, content] of simpleProps) if (content) elements.push({
|
|
805
|
+
tag: "meta",
|
|
806
|
+
attrs: {
|
|
807
|
+
name,
|
|
808
|
+
content
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
if (tw.images) if (typeof tw.images === "string") elements.push({
|
|
812
|
+
tag: "meta",
|
|
813
|
+
attrs: {
|
|
814
|
+
name: "twitter:image",
|
|
815
|
+
content: tw.images
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
else {
|
|
819
|
+
const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
|
|
820
|
+
for (const img of imgList) {
|
|
821
|
+
const url = typeof img === "string" ? img : img.url;
|
|
822
|
+
elements.push({
|
|
823
|
+
tag: "meta",
|
|
824
|
+
attrs: {
|
|
825
|
+
name: "twitter:image",
|
|
826
|
+
content: url
|
|
827
|
+
}
|
|
828
|
+
});
|
|
795
829
|
}
|
|
796
|
-
if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
|
|
797
|
-
pi++;
|
|
798
830
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
* Pipeline stages (in order):
|
|
807
|
-
* proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
|
|
808
|
-
*
|
|
809
|
-
* Each stage is a pure function or returns a Response to short-circuit.
|
|
810
|
-
* Each request gets a trace ID, structured logging, and OTEL spans.
|
|
811
|
-
*
|
|
812
|
-
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
|
|
813
|
-
* and design/17-logging.md §"Production Logging"
|
|
814
|
-
*/
|
|
815
|
-
/**
|
|
816
|
-
* Create the request handler from a pipeline configuration.
|
|
817
|
-
*
|
|
818
|
-
* Returns a function that processes an incoming Request through all pipeline stages
|
|
819
|
-
* and produces a Response. This is the top-level entry point for the server.
|
|
820
|
-
*/
|
|
821
|
-
function createPipeline(config) {
|
|
822
|
-
const { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, serverTiming = "total", onPipelineError } = config;
|
|
823
|
-
let activeRequests = 0;
|
|
824
|
-
return async (req) => {
|
|
825
|
-
const url = new URL(req.url);
|
|
826
|
-
const method = req.method;
|
|
827
|
-
const path = url.pathname;
|
|
828
|
-
const startTime = performance.now();
|
|
829
|
-
activeRequests++;
|
|
830
|
-
return runWithTraceId(generateTraceId(), async () => {
|
|
831
|
-
return runWithRequestContext(req, async () => {
|
|
832
|
-
const runRequest = async () => {
|
|
833
|
-
logRequestReceived({
|
|
834
|
-
method,
|
|
835
|
-
path
|
|
836
|
-
});
|
|
837
|
-
const response = await withSpan("http.server.request", {
|
|
838
|
-
"http.request.method": method,
|
|
839
|
-
"url.path": path
|
|
840
|
-
}, async () => {
|
|
841
|
-
const otelIds = await getOtelTraceId();
|
|
842
|
-
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
843
|
-
let result;
|
|
844
|
-
if (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
|
|
845
|
-
else result = await handleRequest(req, method, path);
|
|
846
|
-
await setSpanAttribute("http.response.status_code", result.status);
|
|
847
|
-
if (serverTiming === "detailed") {
|
|
848
|
-
const timingHeader = getServerTimingHeader();
|
|
849
|
-
if (timingHeader) {
|
|
850
|
-
result = ensureMutableResponse(result);
|
|
851
|
-
result.headers.set("Server-Timing", timingHeader);
|
|
852
|
-
}
|
|
853
|
-
} else if (serverTiming === "total") {
|
|
854
|
-
const totalMs = Math.round(performance.now() - startTime);
|
|
855
|
-
result = ensureMutableResponse(result);
|
|
856
|
-
result.headers.set("Server-Timing", `total;dur=${totalMs}`);
|
|
857
|
-
}
|
|
858
|
-
return result;
|
|
859
|
-
});
|
|
860
|
-
const durationMs = Math.round(performance.now() - startTime);
|
|
861
|
-
const status = response.status;
|
|
862
|
-
const concurrency = activeRequests;
|
|
863
|
-
activeRequests--;
|
|
864
|
-
logRequestCompleted({
|
|
865
|
-
method,
|
|
866
|
-
path,
|
|
867
|
-
status,
|
|
868
|
-
durationMs,
|
|
869
|
-
concurrency
|
|
870
|
-
});
|
|
871
|
-
if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
|
|
872
|
-
method,
|
|
873
|
-
path,
|
|
874
|
-
durationMs,
|
|
875
|
-
threshold: slowRequestMs,
|
|
876
|
-
concurrency
|
|
877
|
-
});
|
|
878
|
-
return response;
|
|
879
|
-
};
|
|
880
|
-
return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
|
|
881
|
-
});
|
|
831
|
+
if (tw.players) for (const player of tw.players) {
|
|
832
|
+
elements.push({
|
|
833
|
+
tag: "meta",
|
|
834
|
+
attrs: {
|
|
835
|
+
name: "twitter:player",
|
|
836
|
+
content: player.playerUrl
|
|
837
|
+
}
|
|
882
838
|
});
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
else proxyExport = config.proxy;
|
|
889
|
-
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
890
|
-
return await withSpan("timber.proxy", {}, () => serverTiming === "detailed" ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn());
|
|
891
|
-
} catch (error) {
|
|
892
|
-
logProxyError({ error });
|
|
893
|
-
await fireOnRequestError(error, req, "proxy");
|
|
894
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
|
|
895
|
-
return new Response(null, { status: 500 });
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
async function handleRequest(req, method, path) {
|
|
899
|
-
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
900
|
-
if (!result.ok) return new Response(null, { status: result.status });
|
|
901
|
-
const canonicalPathname = result.pathname;
|
|
902
|
-
if (config.matchMetadataRoute) {
|
|
903
|
-
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
904
|
-
if (metaMatch) try {
|
|
905
|
-
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
906
|
-
const mod = await metaMatch.file.load();
|
|
907
|
-
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
908
|
-
const handlerResult = await mod.default();
|
|
909
|
-
if (handlerResult instanceof Response) return handlerResult;
|
|
910
|
-
const contentType = metaMatch.contentType;
|
|
911
|
-
let body;
|
|
912
|
-
if (typeof handlerResult === "string") body = handlerResult;
|
|
913
|
-
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
914
|
-
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
915
|
-
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
916
|
-
return new Response(body, {
|
|
917
|
-
status: 200,
|
|
918
|
-
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
919
|
-
});
|
|
920
|
-
} catch (error) {
|
|
921
|
-
logRenderError({
|
|
922
|
-
method,
|
|
923
|
-
path,
|
|
924
|
-
error
|
|
925
|
-
});
|
|
926
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
|
|
927
|
-
return new Response(null, { status: 500 });
|
|
839
|
+
if (player.width) elements.push({
|
|
840
|
+
tag: "meta",
|
|
841
|
+
attrs: {
|
|
842
|
+
name: "twitter:player:width",
|
|
843
|
+
content: String(player.width)
|
|
928
844
|
}
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
if (intercepted) {
|
|
936
|
-
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
937
|
-
if (sourceMatch) {
|
|
938
|
-
match = sourceMatch;
|
|
939
|
-
interception = { targetPathname: canonicalPathname };
|
|
940
|
-
}
|
|
845
|
+
});
|
|
846
|
+
if (player.height) elements.push({
|
|
847
|
+
tag: "meta",
|
|
848
|
+
attrs: {
|
|
849
|
+
name: "twitter:player:height",
|
|
850
|
+
content: String(player.height)
|
|
941
851
|
}
|
|
942
|
-
}
|
|
943
|
-
if (
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
852
|
+
});
|
|
853
|
+
if (player.streamUrl) elements.push({
|
|
854
|
+
tag: "meta",
|
|
855
|
+
attrs: {
|
|
856
|
+
name: "twitter:player:stream",
|
|
857
|
+
content: player.streamUrl
|
|
947
858
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
if (
|
|
957
|
-
const
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
searchParams: new URL(req.url).searchParams,
|
|
963
|
-
earlyHints: (hints) => {
|
|
964
|
-
for (const hint of hints) {
|
|
965
|
-
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
966
|
-
if (hint.as !== void 0) value += `; as=${hint.as}`;
|
|
967
|
-
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
968
|
-
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
969
|
-
responseHeaders.append("Link", value);
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
};
|
|
973
|
-
try {
|
|
974
|
-
setMutableCookieContext(true);
|
|
975
|
-
const middlewareFn = () => runMiddleware(match.middleware, ctx);
|
|
976
|
-
const middlewareResponse = await withSpan("timber.middleware", {}, () => serverTiming === "detailed" ? withTiming("mw", "middleware.ts", middlewareFn) : middlewareFn());
|
|
977
|
-
setMutableCookieContext(false);
|
|
978
|
-
if (middlewareResponse) {
|
|
979
|
-
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
980
|
-
applyCookieJar(finalResponse.headers);
|
|
981
|
-
logMiddlewareShortCircuit({
|
|
982
|
-
method,
|
|
983
|
-
path,
|
|
984
|
-
status: finalResponse.status
|
|
985
|
-
});
|
|
986
|
-
return finalResponse;
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
if (tw.app) {
|
|
862
|
+
const platforms = [
|
|
863
|
+
["iPhone", "iphone"],
|
|
864
|
+
["iPad", "ipad"],
|
|
865
|
+
["googlePlay", "googleplay"]
|
|
866
|
+
];
|
|
867
|
+
if (tw.app.name) {
|
|
868
|
+
for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
|
|
869
|
+
tag: "meta",
|
|
870
|
+
attrs: {
|
|
871
|
+
name: `twitter:app:name:${tag}`,
|
|
872
|
+
content: tw.app.name
|
|
987
873
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
headers: responseHeaders
|
|
998
|
-
});
|
|
999
|
-
}
|
|
1000
|
-
responseHeaders.set("Location", error.location);
|
|
1001
|
-
return new Response(null, {
|
|
1002
|
-
status: error.status,
|
|
1003
|
-
headers: responseHeaders
|
|
1004
|
-
});
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
for (const [key, tag] of platforms) {
|
|
877
|
+
const id = tw.app.id?.[key];
|
|
878
|
+
if (id) elements.push({
|
|
879
|
+
tag: "meta",
|
|
880
|
+
attrs: {
|
|
881
|
+
name: `twitter:app:id:${tag}`,
|
|
882
|
+
content: id
|
|
1005
883
|
}
|
|
1006
|
-
|
|
1007
|
-
logMiddlewareError({
|
|
1008
|
-
method,
|
|
1009
|
-
path,
|
|
1010
|
-
error
|
|
1011
|
-
});
|
|
1012
|
-
await fireOnRequestError(error, req, "handler");
|
|
1013
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
|
|
1014
|
-
return new Response(null, { status: 500 });
|
|
1015
|
-
}
|
|
884
|
+
});
|
|
1016
885
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
if (error instanceof DenySignal) return new Response(null, { status: error.status });
|
|
1025
|
-
if (error instanceof RedirectSignal) {
|
|
1026
|
-
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1027
|
-
responseHeaders.set("X-Timber-Redirect", error.location);
|
|
1028
|
-
return new Response(null, {
|
|
1029
|
-
status: 204,
|
|
1030
|
-
headers: responseHeaders
|
|
1031
|
-
});
|
|
886
|
+
for (const [key, tag] of platforms) {
|
|
887
|
+
const url = tw.app.url?.[key];
|
|
888
|
+
if (url) elements.push({
|
|
889
|
+
tag: "meta",
|
|
890
|
+
attrs: {
|
|
891
|
+
name: `twitter:app:url:${tag}`,
|
|
892
|
+
content: url
|
|
1032
893
|
}
|
|
1033
|
-
responseHeaders.set("Location", error.location);
|
|
1034
|
-
return new Response(null, {
|
|
1035
|
-
status: error.status,
|
|
1036
|
-
headers: responseHeaders
|
|
1037
|
-
});
|
|
1038
|
-
}
|
|
1039
|
-
logRenderError({
|
|
1040
|
-
method,
|
|
1041
|
-
path,
|
|
1042
|
-
error
|
|
1043
894
|
});
|
|
1044
|
-
await fireOnRequestError(error, req, "render");
|
|
1045
|
-
if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
|
|
1046
|
-
if (config.renderFallbackError) try {
|
|
1047
|
-
return await config.renderFallbackError(error, req, responseHeaders);
|
|
1048
|
-
} catch {}
|
|
1049
|
-
return new Response(null, { status: 500 });
|
|
1050
895
|
}
|
|
1051
896
|
}
|
|
1052
897
|
}
|
|
898
|
+
//#endregion
|
|
899
|
+
//#region src/server/metadata-platform.ts
|
|
1053
900
|
/**
|
|
1054
|
-
*
|
|
1055
|
-
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
1056
|
-
*/
|
|
1057
|
-
async function fireOnRequestError(error, req, phase) {
|
|
1058
|
-
const url = new URL(req.url);
|
|
1059
|
-
const headersObj = {};
|
|
1060
|
-
req.headers.forEach((v, k) => {
|
|
1061
|
-
headersObj[k] = v;
|
|
1062
|
-
});
|
|
1063
|
-
await callOnRequestError(error, {
|
|
1064
|
-
method: req.method,
|
|
1065
|
-
path: url.pathname,
|
|
1066
|
-
headers: headersObj
|
|
1067
|
-
}, {
|
|
1068
|
-
phase,
|
|
1069
|
-
routePath: url.pathname,
|
|
1070
|
-
routeType: "page",
|
|
1071
|
-
traceId: traceId()
|
|
1072
|
-
});
|
|
1073
|
-
}
|
|
1074
|
-
/**
|
|
1075
|
-
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
1076
|
-
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
901
|
+
* Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
|
|
1077
902
|
*/
|
|
1078
|
-
function
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
903
|
+
function renderIcons(icons, elements) {
|
|
904
|
+
if (icons.icon) {
|
|
905
|
+
if (typeof icons.icon === "string") elements.push({
|
|
906
|
+
tag: "link",
|
|
907
|
+
attrs: {
|
|
908
|
+
rel: "icon",
|
|
909
|
+
href: icons.icon
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
|
|
913
|
+
const attrs = {
|
|
914
|
+
rel: "icon",
|
|
915
|
+
href: icon.url
|
|
916
|
+
};
|
|
917
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
918
|
+
if (icon.type) attrs.type = icon.type;
|
|
919
|
+
elements.push({
|
|
920
|
+
tag: "link",
|
|
921
|
+
attrs
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (icons.shortcut) {
|
|
926
|
+
const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
|
|
927
|
+
for (const url of urls) elements.push({
|
|
928
|
+
tag: "link",
|
|
929
|
+
attrs: {
|
|
930
|
+
rel: "shortcut icon",
|
|
931
|
+
href: url
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
if (icons.apple) {
|
|
936
|
+
if (typeof icons.apple === "string") elements.push({
|
|
937
|
+
tag: "link",
|
|
938
|
+
attrs: {
|
|
939
|
+
rel: "apple-touch-icon",
|
|
940
|
+
href: icons.apple
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
|
|
944
|
+
const attrs = {
|
|
945
|
+
rel: "apple-touch-icon",
|
|
946
|
+
href: icon.url
|
|
947
|
+
};
|
|
948
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
949
|
+
elements.push({
|
|
950
|
+
tag: "link",
|
|
951
|
+
attrs
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (icons.other) for (const icon of icons.other) {
|
|
956
|
+
const attrs = {
|
|
957
|
+
rel: icon.rel,
|
|
958
|
+
href: icon.url
|
|
959
|
+
};
|
|
960
|
+
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
961
|
+
if (icon.type) attrs.type = icon.type;
|
|
962
|
+
elements.push({
|
|
963
|
+
tag: "link",
|
|
964
|
+
attrs
|
|
1101
965
|
});
|
|
1102
966
|
}
|
|
1103
967
|
}
|
|
1104
|
-
//#endregion
|
|
1105
|
-
//#region src/server/build-manifest.ts
|
|
1106
968
|
/**
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
1110
|
-
* Deduplicates while preserving order (root layout CSS first).
|
|
969
|
+
* Render alternate link elements (canonical, hreflang, media, types).
|
|
1111
970
|
*/
|
|
1112
|
-
function
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
if (!cssFiles) continue;
|
|
1119
|
-
for (const url of cssFiles) if (!seen.has(url)) {
|
|
1120
|
-
seen.add(url);
|
|
1121
|
-
result.push(url);
|
|
971
|
+
function renderAlternates(alternates, elements) {
|
|
972
|
+
if (alternates.canonical) elements.push({
|
|
973
|
+
tag: "link",
|
|
974
|
+
attrs: {
|
|
975
|
+
rel: "canonical",
|
|
976
|
+
href: alternates.canonical
|
|
1122
977
|
}
|
|
1123
|
-
}
|
|
1124
|
-
|
|
978
|
+
});
|
|
979
|
+
if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
|
|
980
|
+
tag: "link",
|
|
981
|
+
attrs: {
|
|
982
|
+
rel: "alternate",
|
|
983
|
+
hreflang: lang,
|
|
984
|
+
href
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
|
|
988
|
+
tag: "link",
|
|
989
|
+
attrs: {
|
|
990
|
+
rel: "alternate",
|
|
991
|
+
media,
|
|
992
|
+
href
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
|
|
996
|
+
tag: "link",
|
|
997
|
+
attrs: {
|
|
998
|
+
rel: "alternate",
|
|
999
|
+
type,
|
|
1000
|
+
href
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1125
1003
|
}
|
|
1126
1004
|
/**
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
1130
|
-
* Deduplicates by href while preserving order.
|
|
1005
|
+
* Render site verification meta tags (Google, Yahoo, Yandex, custom).
|
|
1131
1006
|
*/
|
|
1132
|
-
function
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1007
|
+
function renderVerification(verification, elements) {
|
|
1008
|
+
const verificationProps = [
|
|
1009
|
+
["google-site-verification", verification.google],
|
|
1010
|
+
["y_key", verification.yahoo],
|
|
1011
|
+
["yandex-verification", verification.yandex]
|
|
1012
|
+
];
|
|
1013
|
+
for (const [name, content] of verificationProps) if (content) elements.push({
|
|
1014
|
+
tag: "meta",
|
|
1015
|
+
attrs: {
|
|
1016
|
+
name,
|
|
1017
|
+
content
|
|
1142
1018
|
}
|
|
1019
|
+
});
|
|
1020
|
+
if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
|
|
1021
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
1022
|
+
elements.push({
|
|
1023
|
+
tag: "meta",
|
|
1024
|
+
attrs: {
|
|
1025
|
+
name,
|
|
1026
|
+
content
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1143
1029
|
}
|
|
1144
|
-
return result;
|
|
1145
1030
|
}
|
|
1146
1031
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
1150
|
-
* for each layout and page. Deduplicates across segments.
|
|
1032
|
+
* Render Apple Web App meta tags and startup image links.
|
|
1151
1033
|
*/
|
|
1152
|
-
function
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1034
|
+
function renderAppleWebApp(appleWebApp, elements) {
|
|
1035
|
+
if (appleWebApp.capable) elements.push({
|
|
1036
|
+
tag: "meta",
|
|
1037
|
+
attrs: {
|
|
1038
|
+
name: "apple-mobile-web-app-capable",
|
|
1039
|
+
content: "yes"
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
1042
|
+
if (appleWebApp.title) elements.push({
|
|
1043
|
+
tag: "meta",
|
|
1044
|
+
attrs: {
|
|
1045
|
+
name: "apple-mobile-web-app-title",
|
|
1046
|
+
content: appleWebApp.title
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
if (appleWebApp.statusBarStyle) elements.push({
|
|
1050
|
+
tag: "meta",
|
|
1051
|
+
attrs: {
|
|
1052
|
+
name: "apple-mobile-web-app-status-bar-style",
|
|
1053
|
+
content: appleWebApp.statusBarStyle
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
if (appleWebApp.startupImage) {
|
|
1057
|
+
const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
|
|
1058
|
+
for (const img of images) {
|
|
1059
|
+
const attrs = {
|
|
1060
|
+
rel: "apple-touch-startup-image",
|
|
1061
|
+
href: typeof img === "string" ? img : img.url
|
|
1062
|
+
};
|
|
1063
|
+
if (typeof img === "object" && img.media) attrs.media = img.media;
|
|
1064
|
+
elements.push({
|
|
1065
|
+
tag: "link",
|
|
1066
|
+
attrs
|
|
1067
|
+
});
|
|
1162
1068
|
}
|
|
1163
1069
|
}
|
|
1164
|
-
return result;
|
|
1165
1070
|
}
|
|
1166
|
-
//#endregion
|
|
1167
|
-
//#region src/server/early-hints.ts
|
|
1168
|
-
/**
|
|
1169
|
-
* 103 Early Hints utilities.
|
|
1170
|
-
*
|
|
1171
|
-
* Early Hints are sent before the final response to let the browser
|
|
1172
|
-
* start fetching critical resources (CSS, fonts, JS) while the server
|
|
1173
|
-
* is still rendering.
|
|
1174
|
-
*
|
|
1175
|
-
* The framework collects hints from two sources:
|
|
1176
|
-
* 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
|
|
1177
|
-
* 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
|
|
1178
|
-
*
|
|
1179
|
-
* Both are emitted as Link headers. Cloudflare CDN automatically converts
|
|
1180
|
-
* Link headers into 103 Early Hints responses.
|
|
1181
|
-
*
|
|
1182
|
-
* Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1183
|
-
*/
|
|
1184
1071
|
/**
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
* Examples:
|
|
1188
|
-
* `</styles/root.css>; rel=preload; as=style`
|
|
1189
|
-
* `</fonts/inter.woff2>; rel=preload; as=font; crossorigin=anonymous`
|
|
1190
|
-
* `</_timber/client.js>; rel=modulepreload`
|
|
1191
|
-
* `<https://fonts.googleapis.com>; rel=preconnect`
|
|
1072
|
+
* Render App Links (al:*) meta tags for deep linking across platforms.
|
|
1192
1073
|
*/
|
|
1193
|
-
function
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
href: url,
|
|
1228
|
-
rel: "preload",
|
|
1229
|
-
as: "style"
|
|
1230
|
-
}));
|
|
1231
|
-
for (const url of manifest.css["_global"] ?? []) add(formatLinkHeader({
|
|
1232
|
-
href: url,
|
|
1233
|
-
rel: "preload",
|
|
1234
|
-
as: "style"
|
|
1235
|
-
}));
|
|
1236
|
-
for (const font of collectRouteFonts(segments, manifest)) add(formatLinkHeader({
|
|
1237
|
-
href: font.href,
|
|
1238
|
-
rel: "preload",
|
|
1239
|
-
as: "font",
|
|
1240
|
-
crossOrigin: "anonymous"
|
|
1241
|
-
}));
|
|
1242
|
-
if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(formatLinkHeader({
|
|
1243
|
-
href: url,
|
|
1244
|
-
rel: "modulepreload"
|
|
1245
|
-
}));
|
|
1246
|
-
return result;
|
|
1247
|
-
}
|
|
1248
|
-
//#endregion
|
|
1249
|
-
//#region src/server/early-hints-sender.ts
|
|
1250
|
-
/**
|
|
1251
|
-
* Per-request 103 Early Hints sender — ALS bridge for platform adapters.
|
|
1252
|
-
*
|
|
1253
|
-
* The pipeline collects Link headers for CSS, fonts, and JS chunks at
|
|
1254
|
-
* route-match time. On platforms that support it (Node.js v18.11+, Bun),
|
|
1255
|
-
* the adapter can send these as a 103 Early Hints interim response before
|
|
1256
|
-
* the final response is ready.
|
|
1257
|
-
*
|
|
1258
|
-
* This module provides an ALS-based bridge: the generated entry point
|
|
1259
|
-
* (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
|
|
1260
|
-
* binding a per-request sender function. The pipeline calls
|
|
1261
|
-
* `sendEarlyHints103()` to fire the 103 if a sender is available.
|
|
1262
|
-
*
|
|
1263
|
-
* On platforms where 103 is handled at the CDN level (e.g., Cloudflare
|
|
1264
|
-
* converts Link headers into 103 automatically), no sender is installed
|
|
1265
|
-
* and `sendEarlyHints103()` is a no-op.
|
|
1266
|
-
*
|
|
1267
|
-
* Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1268
|
-
*/
|
|
1269
|
-
/**
|
|
1270
|
-
* Run a function with a per-request early hints sender installed.
|
|
1271
|
-
*
|
|
1272
|
-
* Called by generated entry points (e.g., Nitro node-server/bun) to
|
|
1273
|
-
* bind the platform's writeEarlyHints capability for the request duration.
|
|
1274
|
-
*/
|
|
1275
|
-
function runWithEarlyHintsSender(sender, fn) {
|
|
1276
|
-
return earlyHintsSenderAls.run(sender, fn);
|
|
1074
|
+
function renderAppLinks(appLinks, elements) {
|
|
1075
|
+
const platformEntries = [
|
|
1076
|
+
["ios", appLinks.ios],
|
|
1077
|
+
["android", appLinks.android],
|
|
1078
|
+
["windows", appLinks.windows],
|
|
1079
|
+
["windows_phone", appLinks.windowsPhone],
|
|
1080
|
+
["windows_universal", appLinks.windowsUniversal]
|
|
1081
|
+
];
|
|
1082
|
+
for (const [platform, entries] of platformEntries) {
|
|
1083
|
+
if (!entries) continue;
|
|
1084
|
+
for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
|
|
1085
|
+
tag: "meta",
|
|
1086
|
+
attrs: {
|
|
1087
|
+
property: `al:${platform}:${key}`,
|
|
1088
|
+
content: String(value)
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
if (appLinks.web) {
|
|
1093
|
+
if (appLinks.web.url) elements.push({
|
|
1094
|
+
tag: "meta",
|
|
1095
|
+
attrs: {
|
|
1096
|
+
property: "al:web:url",
|
|
1097
|
+
content: appLinks.web.url
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
if (appLinks.web.shouldFallback !== void 0) elements.push({
|
|
1101
|
+
tag: "meta",
|
|
1102
|
+
attrs: {
|
|
1103
|
+
property: "al:web:should_fallback",
|
|
1104
|
+
content: appLinks.web.shouldFallback ? "true" : "false"
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1277
1108
|
}
|
|
1278
1109
|
/**
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
* No-op if no sender is installed for the current request (e.g., on
|
|
1282
|
-
* Cloudflare where the CDN handles 103 automatically, or in dev mode).
|
|
1283
|
-
*
|
|
1284
|
-
* Non-fatal: errors from the sender are caught and silently ignored.
|
|
1110
|
+
* Render Apple iTunes smart banner meta tag.
|
|
1285
1111
|
*/
|
|
1286
|
-
function
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
if (
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1112
|
+
function renderItunes(itunes, elements) {
|
|
1113
|
+
const parts = [`app-id=${itunes.appId}`];
|
|
1114
|
+
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
1115
|
+
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
1116
|
+
elements.push({
|
|
1117
|
+
tag: "meta",
|
|
1118
|
+
attrs: {
|
|
1119
|
+
name: "apple-itunes-app",
|
|
1120
|
+
content: parts.join(", ")
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1293
1123
|
}
|
|
1294
1124
|
//#endregion
|
|
1295
|
-
//#region src/server/
|
|
1125
|
+
//#region src/server/metadata-render.ts
|
|
1296
1126
|
/**
|
|
1297
|
-
*
|
|
1127
|
+
* Convert resolved metadata into an array of head element descriptors.
|
|
1298
1128
|
*
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
* 2. Wrap in status-code error boundaries (fallback chain)
|
|
1302
|
-
* 3. Wrap in AccessGate (if segment has access.ts)
|
|
1303
|
-
* 4. Pass as children to the segment's layout
|
|
1304
|
-
* 5. Repeat up the segment chain to root
|
|
1129
|
+
* Each descriptor has a `tag` ('title', 'meta', 'link') and either
|
|
1130
|
+
* `content` (for <title>) or `attrs` (for <meta>/<link>).
|
|
1305
1131
|
*
|
|
1306
|
-
*
|
|
1132
|
+
* The framework's MetadataResolver component consumes these descriptors
|
|
1133
|
+
* and renders them into the <head>.
|
|
1307
1134
|
*/
|
|
1308
|
-
|
|
1309
|
-
const
|
|
1310
|
-
if (
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
tree: null,
|
|
1314
|
-
isApiRoute: true
|
|
1315
|
-
};
|
|
1316
|
-
const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
|
|
1317
|
-
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.`);
|
|
1318
|
-
let element = createElement(PageComponent, {
|
|
1319
|
-
params,
|
|
1320
|
-
searchParams
|
|
1135
|
+
function renderMetadataToElements(metadata) {
|
|
1136
|
+
const elements = [];
|
|
1137
|
+
if (typeof metadata.title === "string") elements.push({
|
|
1138
|
+
tag: "title",
|
|
1139
|
+
content: metadata.title
|
|
1321
1140
|
});
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
const LayoutComponent = (await loadModule(segment.layout)).default;
|
|
1337
|
-
if (LayoutComponent) {
|
|
1338
|
-
const slotProps = {};
|
|
1339
|
-
if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, params, searchParams, loadModule, createElement, errorBoundaryComponent);
|
|
1340
|
-
element = createElement(LayoutComponent, {
|
|
1341
|
-
...slotProps,
|
|
1342
|
-
params,
|
|
1343
|
-
searchParams,
|
|
1344
|
-
children: element
|
|
1345
|
-
});
|
|
1346
|
-
}
|
|
1141
|
+
const simpleMetaProps = [
|
|
1142
|
+
["description", metadata.description],
|
|
1143
|
+
["generator", metadata.generator],
|
|
1144
|
+
["application-name", metadata.applicationName],
|
|
1145
|
+
["referrer", metadata.referrer],
|
|
1146
|
+
["category", metadata.category],
|
|
1147
|
+
["creator", metadata.creator],
|
|
1148
|
+
["publisher", metadata.publisher]
|
|
1149
|
+
];
|
|
1150
|
+
for (const [name, content] of simpleMetaProps) if (content) elements.push({
|
|
1151
|
+
tag: "meta",
|
|
1152
|
+
attrs: {
|
|
1153
|
+
name,
|
|
1154
|
+
content
|
|
1347
1155
|
}
|
|
1348
|
-
}
|
|
1349
|
-
return {
|
|
1350
|
-
tree: element,
|
|
1351
|
-
isApiRoute: false
|
|
1352
|
-
};
|
|
1353
|
-
}
|
|
1354
|
-
/**
|
|
1355
|
-
* Build the element tree for a parallel slot.
|
|
1356
|
-
*
|
|
1357
|
-
* Slots have their own access.ts (SlotAccessGate) and error boundaries.
|
|
1358
|
-
* On access denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
1359
|
-
*/
|
|
1360
|
-
async function buildSlotElement(slotNode, params, searchParams, loadModule, createElement, errorBoundaryComponent) {
|
|
1361
|
-
const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
|
|
1362
|
-
const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
|
|
1363
|
-
if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {
|
|
1364
|
-
params,
|
|
1365
|
-
searchParams
|
|
1366
|
-
}) : null;
|
|
1367
|
-
let element = createElement(PageComponent, {
|
|
1368
|
-
params,
|
|
1369
|
-
searchParams
|
|
1370
1156
|
});
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
deniedFallback: DeniedComponent ? createElement(DeniedComponent, {
|
|
1380
|
-
slot: slotNode.segmentName.replace(/^@/, ""),
|
|
1381
|
-
dangerouslyPassData: void 0
|
|
1382
|
-
}) : null,
|
|
1383
|
-
defaultFallback: DefaultComponent ? createElement(DefaultComponent, {
|
|
1384
|
-
params,
|
|
1385
|
-
searchParams
|
|
1386
|
-
}) : null,
|
|
1387
|
-
children: element
|
|
1157
|
+
if (metadata.keywords) {
|
|
1158
|
+
const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
1159
|
+
elements.push({
|
|
1160
|
+
tag: "meta",
|
|
1161
|
+
attrs: {
|
|
1162
|
+
name: "keywords",
|
|
1163
|
+
content
|
|
1164
|
+
}
|
|
1388
1165
|
});
|
|
1389
1166
|
}
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
1398
|
-
* 3. error.tsx (general error boundary)
|
|
1399
|
-
*
|
|
1400
|
-
* This creates the fallback chain described in design/10-error-handling.md.
|
|
1401
|
-
*/
|
|
1402
|
-
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
|
|
1403
|
-
if (segment.statusFiles) {
|
|
1404
|
-
for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
|
|
1405
|
-
const status = parseInt(key, 10);
|
|
1406
|
-
if (!isNaN(status)) {
|
|
1407
|
-
const Component = (await loadModule(file)).default;
|
|
1408
|
-
if (Component) element = createElement(errorBoundaryComponent, {
|
|
1409
|
-
fallbackComponent: Component,
|
|
1410
|
-
status,
|
|
1411
|
-
children: element
|
|
1412
|
-
});
|
|
1167
|
+
if (metadata.robots) {
|
|
1168
|
+
const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
|
|
1169
|
+
elements.push({
|
|
1170
|
+
tag: "meta",
|
|
1171
|
+
attrs: {
|
|
1172
|
+
name: "robots",
|
|
1173
|
+
content
|
|
1413
1174
|
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1175
|
+
});
|
|
1176
|
+
if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
|
|
1177
|
+
const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
|
|
1178
|
+
elements.push({
|
|
1179
|
+
tag: "meta",
|
|
1180
|
+
attrs: {
|
|
1181
|
+
name: "googlebot",
|
|
1182
|
+
content: gbContent
|
|
1183
|
+
}
|
|
1421
1184
|
});
|
|
1422
1185
|
}
|
|
1423
1186
|
}
|
|
1424
|
-
if (
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1187
|
+
if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
|
|
1188
|
+
if (metadata.twitter) renderTwitter(metadata.twitter, elements);
|
|
1189
|
+
if (metadata.icons) renderIcons(metadata.icons, elements);
|
|
1190
|
+
if (metadata.manifest) elements.push({
|
|
1191
|
+
tag: "link",
|
|
1192
|
+
attrs: {
|
|
1193
|
+
rel: "manifest",
|
|
1194
|
+
href: metadata.manifest
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
if (metadata.alternates) renderAlternates(metadata.alternates, elements);
|
|
1198
|
+
if (metadata.verification) renderVerification(metadata.verification, elements);
|
|
1199
|
+
if (metadata.formatDetection) {
|
|
1200
|
+
const parts = [];
|
|
1201
|
+
if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
|
|
1202
|
+
if (metadata.formatDetection.email === false) parts.push("email=no");
|
|
1203
|
+
if (metadata.formatDetection.address === false) parts.push("address=no");
|
|
1204
|
+
if (parts.length > 0) elements.push({
|
|
1205
|
+
tag: "meta",
|
|
1206
|
+
attrs: {
|
|
1207
|
+
name: "format-detection",
|
|
1208
|
+
content: parts.join(", ")
|
|
1209
|
+
}
|
|
1429
1210
|
});
|
|
1430
1211
|
}
|
|
1431
|
-
|
|
1212
|
+
if (metadata.authors) {
|
|
1213
|
+
const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
|
|
1214
|
+
for (const author of authorList) {
|
|
1215
|
+
if (author.name) elements.push({
|
|
1216
|
+
tag: "meta",
|
|
1217
|
+
attrs: {
|
|
1218
|
+
name: "author",
|
|
1219
|
+
content: author.name
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
if (author.url) elements.push({
|
|
1223
|
+
tag: "link",
|
|
1224
|
+
attrs: {
|
|
1225
|
+
rel: "author",
|
|
1226
|
+
href: author.url
|
|
1227
|
+
}
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
|
|
1232
|
+
if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
|
|
1233
|
+
if (metadata.itunes) renderItunes(metadata.itunes, elements);
|
|
1234
|
+
if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
|
|
1235
|
+
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
1236
|
+
elements.push({
|
|
1237
|
+
tag: "meta",
|
|
1238
|
+
attrs: {
|
|
1239
|
+
name,
|
|
1240
|
+
content
|
|
1241
|
+
}
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
return elements;
|
|
1245
|
+
}
|
|
1246
|
+
function renderRobotsObject(robots) {
|
|
1247
|
+
const parts = [];
|
|
1248
|
+
if (robots.index === true) parts.push("index");
|
|
1249
|
+
if (robots.index === false) parts.push("noindex");
|
|
1250
|
+
if (robots.follow === true) parts.push("follow");
|
|
1251
|
+
if (robots.follow === false) parts.push("nofollow");
|
|
1252
|
+
return parts.join(", ");
|
|
1253
|
+
}
|
|
1254
|
+
//#endregion
|
|
1255
|
+
//#region src/server/metadata.ts
|
|
1256
|
+
/**
|
|
1257
|
+
* Resolve a title value with an optional template.
|
|
1258
|
+
*
|
|
1259
|
+
* - string → apply template if present
|
|
1260
|
+
* - { absolute: '...' } → use as-is, skip template
|
|
1261
|
+
* - { default: '...' } → use as fallback (no template applied)
|
|
1262
|
+
* - undefined → undefined
|
|
1263
|
+
*/
|
|
1264
|
+
function resolveTitle(title, template) {
|
|
1265
|
+
if (title === void 0 || title === null) return;
|
|
1266
|
+
if (typeof title === "string") return template ? template.replace("%s", title) : title;
|
|
1267
|
+
if (title.absolute !== void 0) return title.absolute;
|
|
1268
|
+
if (title.default !== void 0) return title.default;
|
|
1269
|
+
}
|
|
1270
|
+
/**
|
|
1271
|
+
* Resolve metadata from a segment chain.
|
|
1272
|
+
*
|
|
1273
|
+
* Processes entries from root layout to page (in segment order).
|
|
1274
|
+
* The merge algorithm:
|
|
1275
|
+
* 1. Shallow-merge all keys except title (later wins)
|
|
1276
|
+
* 2. Track the most recent title template
|
|
1277
|
+
* 3. Resolve the final title using the template
|
|
1278
|
+
*
|
|
1279
|
+
* In error state, the page entry is dropped and noindex is injected.
|
|
1280
|
+
*
|
|
1281
|
+
* See design/16-metadata.md §"Merge Algorithm"
|
|
1282
|
+
*/
|
|
1283
|
+
function resolveMetadata(entries, options = {}) {
|
|
1284
|
+
const { errorState = false } = options;
|
|
1285
|
+
const merged = {};
|
|
1286
|
+
let titleTemplate;
|
|
1287
|
+
let lastDefault;
|
|
1288
|
+
let rawTitle;
|
|
1289
|
+
for (const { metadata, isPage } of entries) {
|
|
1290
|
+
if (errorState && isPage) continue;
|
|
1291
|
+
if (metadata.title !== void 0 && typeof metadata.title === "object") {
|
|
1292
|
+
if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
|
|
1293
|
+
if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
|
|
1294
|
+
}
|
|
1295
|
+
for (const key of Object.keys(metadata)) {
|
|
1296
|
+
if (key === "title") continue;
|
|
1297
|
+
merged[key] = metadata[key];
|
|
1298
|
+
}
|
|
1299
|
+
if (metadata.title !== void 0) rawTitle = metadata.title;
|
|
1300
|
+
}
|
|
1301
|
+
if (errorState) {
|
|
1302
|
+
rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
|
|
1303
|
+
titleTemplate = void 0;
|
|
1304
|
+
}
|
|
1305
|
+
const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
|
|
1306
|
+
if (resolvedTitle !== void 0) merged.title = resolvedTitle;
|
|
1307
|
+
if (errorState) merged.robots = "noindex";
|
|
1308
|
+
return merged;
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Check if a string is an absolute URL.
|
|
1312
|
+
*/
|
|
1313
|
+
function isAbsoluteUrl(url) {
|
|
1314
|
+
return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Resolve a relative URL against a base URL.
|
|
1318
|
+
*/
|
|
1319
|
+
function resolveUrl(url, base) {
|
|
1320
|
+
if (isAbsoluteUrl(url)) return url;
|
|
1321
|
+
return new URL(url, base).toString();
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Resolve relative URLs in metadata fields against metadataBase.
|
|
1325
|
+
*
|
|
1326
|
+
* Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
|
|
1327
|
+
* If metadataBase is not set, returns the metadata unchanged.
|
|
1328
|
+
*/
|
|
1329
|
+
function resolveMetadataUrls(metadata) {
|
|
1330
|
+
const base = metadata.metadataBase;
|
|
1331
|
+
if (!base) return metadata;
|
|
1332
|
+
const result = { ...metadata };
|
|
1333
|
+
if (result.openGraph) {
|
|
1334
|
+
result.openGraph = { ...result.openGraph };
|
|
1335
|
+
if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
|
|
1336
|
+
else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
|
|
1337
|
+
...img,
|
|
1338
|
+
url: resolveUrl(img.url, base)
|
|
1339
|
+
}));
|
|
1340
|
+
else if (result.openGraph.images) result.openGraph.images = {
|
|
1341
|
+
...result.openGraph.images,
|
|
1342
|
+
url: resolveUrl(result.openGraph.images.url, base)
|
|
1343
|
+
};
|
|
1344
|
+
if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
|
|
1345
|
+
}
|
|
1346
|
+
if (result.twitter) {
|
|
1347
|
+
result.twitter = { ...result.twitter };
|
|
1348
|
+
if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
|
|
1349
|
+
else if (Array.isArray(result.twitter.images)) {
|
|
1350
|
+
const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
|
|
1351
|
+
...img,
|
|
1352
|
+
url: resolveUrl(img.url, base)
|
|
1353
|
+
});
|
|
1354
|
+
const allStrings = resolved.every((r) => typeof r === "string");
|
|
1355
|
+
result.twitter.images = allStrings ? resolved : resolved;
|
|
1356
|
+
} else if (result.twitter.images) result.twitter.images = {
|
|
1357
|
+
...result.twitter.images,
|
|
1358
|
+
url: resolveUrl(result.twitter.images.url, base)
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
if (result.alternates) {
|
|
1362
|
+
result.alternates = { ...result.alternates };
|
|
1363
|
+
if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
|
|
1364
|
+
if (result.alternates.languages) {
|
|
1365
|
+
const langs = {};
|
|
1366
|
+
for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
|
|
1367
|
+
result.alternates.languages = langs;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
if (result.icons) {
|
|
1371
|
+
result.icons = { ...result.icons };
|
|
1372
|
+
if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
|
|
1373
|
+
else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
|
|
1374
|
+
...i,
|
|
1375
|
+
url: resolveUrl(i.url, base)
|
|
1376
|
+
}));
|
|
1377
|
+
if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
|
|
1378
|
+
else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
|
|
1379
|
+
...i,
|
|
1380
|
+
url: resolveUrl(i.url, base)
|
|
1381
|
+
}));
|
|
1382
|
+
}
|
|
1383
|
+
return result;
|
|
1432
1384
|
}
|
|
1433
1385
|
//#endregion
|
|
1434
1386
|
//#region src/server/access-gate.tsx
|
|
@@ -1461,24 +1413,21 @@ async function wrapWithErrorBoundaries(segment, element, loadModule, createEleme
|
|
|
1461
1413
|
* gets the same data by calling the same cached functions (React.cache dedup).
|
|
1462
1414
|
*/
|
|
1463
1415
|
function AccessGate(props) {
|
|
1464
|
-
const { accessFn, params,
|
|
1416
|
+
const { accessFn, params, segmentName, verdict, children } = props;
|
|
1465
1417
|
if (verdict !== void 0) {
|
|
1466
1418
|
if (verdict === "pass") return children;
|
|
1467
1419
|
throw verdict;
|
|
1468
1420
|
}
|
|
1469
|
-
return accessGateFallback(accessFn, params,
|
|
1421
|
+
return accessGateFallback(accessFn, params, segmentName, children);
|
|
1470
1422
|
}
|
|
1471
1423
|
/**
|
|
1472
1424
|
* Async fallback for AccessGate when no pre-computed verdict is available.
|
|
1473
1425
|
* Calls accessFn with OTEL instrumentation.
|
|
1474
1426
|
*/
|
|
1475
|
-
async function accessGateFallback(accessFn, params,
|
|
1427
|
+
async function accessGateFallback(accessFn, params, segmentName, children) {
|
|
1476
1428
|
await withSpan("timber.access", { "timber.segment": segmentName ?? "unknown" }, async () => {
|
|
1477
1429
|
try {
|
|
1478
|
-
await accessFn({
|
|
1479
|
-
params,
|
|
1480
|
-
searchParams
|
|
1481
|
-
});
|
|
1430
|
+
await accessFn({ params });
|
|
1482
1431
|
await setSpanAttribute("timber.result", "pass");
|
|
1483
1432
|
} catch (error) {
|
|
1484
1433
|
if (error instanceof DenySignal) {
|
|
@@ -1498,1093 +1447,1305 @@ async function accessGateFallback(accessFn, params, searchParams, segmentName, c
|
|
|
1498
1447
|
* The HTTP status code is unaffected — slot denial is a UI concern, not
|
|
1499
1448
|
* a protocol concern. The parent layout and sibling slots still render.
|
|
1500
1449
|
*
|
|
1450
|
+
* DeniedComponent is passed instead of a pre-built element so that
|
|
1451
|
+
* DenySignal.data can be forwarded as the dangerouslyPassData prop
|
|
1452
|
+
* and the slot name can be passed as the slot prop. See TIM-488.
|
|
1453
|
+
*
|
|
1501
1454
|
* redirect() in slot access.ts is a dev-mode error — redirecting from a
|
|
1502
1455
|
* slot doesn't make architectural sense.
|
|
1503
1456
|
*/
|
|
1504
1457
|
async function SlotAccessGate(props) {
|
|
1505
|
-
const { accessFn, params,
|
|
1458
|
+
const { accessFn, params, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
|
|
1506
1459
|
try {
|
|
1507
|
-
await accessFn({
|
|
1508
|
-
params,
|
|
1509
|
-
searchParams
|
|
1510
|
-
});
|
|
1460
|
+
await accessFn({ params });
|
|
1511
1461
|
} catch (error) {
|
|
1512
|
-
if (error instanceof DenySignal) return
|
|
1462
|
+
if (error instanceof DenySignal) return buildDeniedFallback(DeniedComponent, slotName, error.data, createElement) ?? defaultFallback ?? null;
|
|
1513
1463
|
if (error instanceof RedirectSignal) {
|
|
1514
1464
|
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.");
|
|
1515
|
-
return
|
|
1465
|
+
return buildDeniedFallback(DeniedComponent, slotName, void 0, createElement) ?? defaultFallback ?? null;
|
|
1516
1466
|
}
|
|
1517
1467
|
if (isDebug()) console.warn("[timber] Unhandled error in slot access.ts. Use deny() for access control, not unhandled throws.", error);
|
|
1518
1468
|
throw error;
|
|
1519
1469
|
}
|
|
1520
1470
|
return children;
|
|
1521
1471
|
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Build the denied fallback element dynamically with DenySignal data.
|
|
1474
|
+
* Returns null if no DeniedComponent is available.
|
|
1475
|
+
*/
|
|
1476
|
+
function buildDeniedFallback(DeniedComponent, slotName, data, createElement) {
|
|
1477
|
+
if (!DeniedComponent) return null;
|
|
1478
|
+
return createElement(DeniedComponent, {
|
|
1479
|
+
slot: slotName,
|
|
1480
|
+
dangerouslyPassData: data
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1522
1483
|
//#endregion
|
|
1523
|
-
//#region src/server/
|
|
1484
|
+
//#region src/server/route-element-builder.ts
|
|
1524
1485
|
/**
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1486
|
+
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
1487
|
+
* The pipeline catches this and responds with 404.
|
|
1527
1488
|
*/
|
|
1528
|
-
var
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1489
|
+
var ParamCoercionError = class extends Error {
|
|
1490
|
+
constructor(message) {
|
|
1491
|
+
super(message);
|
|
1492
|
+
this.name = "ParamCoercionError";
|
|
1493
|
+
}
|
|
1532
1494
|
};
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/server/version-skew.ts
|
|
1533
1497
|
/**
|
|
1534
|
-
*
|
|
1498
|
+
* Version Skew Detection — graceful recovery when stale clients hit new deployments.
|
|
1535
1499
|
*
|
|
1536
|
-
*
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1500
|
+
* When a new version of the app is deployed, clients with open tabs still have
|
|
1501
|
+
* the old JavaScript bundle. Without version skew handling, these stale clients
|
|
1502
|
+
* will experience:
|
|
1539
1503
|
*
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1504
|
+
* 1. Server action calls that crash (action IDs are content-hashed)
|
|
1505
|
+
* 2. Chunk load failures (old filenames gone from CDN)
|
|
1506
|
+
* 3. RSC payload mismatches (component references differ between builds)
|
|
1507
|
+
*
|
|
1508
|
+
* This module implements deployment ID comparison:
|
|
1509
|
+
* - A per-build deployment ID is generated at build time (see build-manifest.ts)
|
|
1510
|
+
* - The client sends it via `X-Timber-Deployment-Id` header on every RSC/action request
|
|
1511
|
+
* - The server compares it against the current build's ID
|
|
1512
|
+
* - On mismatch: signal the client to reload (not crash)
|
|
1513
|
+
*
|
|
1514
|
+
* The deployment ID is always-on in production. Dev mode skips the check
|
|
1515
|
+
* (HMR handles code updates without full reloads).
|
|
1516
|
+
*
|
|
1517
|
+
* See design/25-production-deployments.md, TIM-446
|
|
1543
1518
|
*/
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1519
|
+
/** Header sent by the client with every RSC/action request. */
|
|
1520
|
+
var DEPLOYMENT_ID_HEADER = "X-Timber-Deployment-Id";
|
|
1521
|
+
/** Response header that signals the client to do a full page reload. */
|
|
1522
|
+
var RELOAD_HEADER = "X-Timber-Reload";
|
|
1523
|
+
/**
|
|
1524
|
+
* The current build's deployment ID. Set at startup from the manifest init
|
|
1525
|
+
* module (globalThis.__TIMBER_DEPLOYMENT_ID__). Null in dev mode.
|
|
1526
|
+
*/
|
|
1527
|
+
var currentDeploymentId = null;
|
|
1528
|
+
/**
|
|
1529
|
+
* Check if a request's deployment ID matches the current build.
|
|
1530
|
+
*
|
|
1531
|
+
* Returns `{ ok: true }` when:
|
|
1532
|
+
* - Dev mode (no deployment ID set — HMR handles updates)
|
|
1533
|
+
* - No deployment ID header (initial page load, non-RSC request)
|
|
1534
|
+
* - Deployment IDs match
|
|
1535
|
+
*
|
|
1536
|
+
* Returns `{ ok: false }` when:
|
|
1537
|
+
* - Client sends a deployment ID that differs from the current build
|
|
1538
|
+
*/
|
|
1539
|
+
function checkVersionSkew(req) {
|
|
1540
|
+
if (!currentDeploymentId) return {
|
|
1541
|
+
ok: true,
|
|
1542
|
+
clientId: null
|
|
1543
|
+
};
|
|
1544
|
+
const clientId = req.headers.get(DEPLOYMENT_ID_HEADER);
|
|
1545
|
+
if (!clientId) return {
|
|
1546
|
+
ok: true,
|
|
1547
|
+
clientId: null
|
|
1548
|
+
};
|
|
1549
|
+
if (clientId === currentDeploymentId) return {
|
|
1550
|
+
ok: true,
|
|
1551
|
+
clientId
|
|
1552
|
+
};
|
|
1553
|
+
return {
|
|
1554
|
+
ok: false,
|
|
1555
|
+
clientId
|
|
1556
|
+
};
|
|
1548
1557
|
}
|
|
1549
1558
|
/**
|
|
1550
|
-
*
|
|
1551
|
-
*
|
|
1552
|
-
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
1553
|
-
* Pass 3 — error.tsx (leaf → root)
|
|
1559
|
+
* Apply version skew reload headers to a response.
|
|
1560
|
+
* Sets X-Timber-Reload: 1 to signal the client to do a full page reload.
|
|
1554
1561
|
*/
|
|
1555
|
-
function
|
|
1556
|
-
|
|
1557
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1558
|
-
const segment = segments[i];
|
|
1559
|
-
if (!segment.statusFiles) continue;
|
|
1560
|
-
const exact = segment.statusFiles.get(statusStr);
|
|
1561
|
-
if (exact) return {
|
|
1562
|
-
file: exact,
|
|
1563
|
-
status,
|
|
1564
|
-
kind: "exact",
|
|
1565
|
-
segmentIndex: i
|
|
1566
|
-
};
|
|
1567
|
-
const category = segment.statusFiles.get("4xx");
|
|
1568
|
-
if (category) return {
|
|
1569
|
-
file: category,
|
|
1570
|
-
status,
|
|
1571
|
-
kind: "category",
|
|
1572
|
-
segmentIndex: i
|
|
1573
|
-
};
|
|
1574
|
-
}
|
|
1575
|
-
for (let i = segments.length - 1; i >= 0; i--) {
|
|
1576
|
-
const segment = segments[i];
|
|
1577
|
-
if (!segment.legacyStatusFiles) continue;
|
|
1578
|
-
for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
|
|
1579
|
-
const file = segment.legacyStatusFiles.get(name);
|
|
1580
|
-
if (file) return {
|
|
1581
|
-
file,
|
|
1582
|
-
status,
|
|
1583
|
-
kind: "legacy",
|
|
1584
|
-
segmentIndex: i
|
|
1585
|
-
};
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
|
|
1589
|
-
file: segments[i].error,
|
|
1590
|
-
status,
|
|
1591
|
-
kind: "error",
|
|
1592
|
-
segmentIndex: i
|
|
1593
|
-
};
|
|
1594
|
-
return null;
|
|
1562
|
+
function applyReloadHeaders(headers) {
|
|
1563
|
+
headers.set(RELOAD_HEADER, "1");
|
|
1595
1564
|
}
|
|
1565
|
+
//#endregion
|
|
1566
|
+
//#region src/server/pipeline-metadata.ts
|
|
1596
1567
|
/**
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1568
|
+
* Metadata route helpers for the request pipeline.
|
|
1569
|
+
*
|
|
1570
|
+
* Handles serving static metadata files and serializing sitemap responses.
|
|
1571
|
+
* Extracted from pipeline.ts to keep files under 500 lines.
|
|
1572
|
+
*
|
|
1573
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
1600
1574
|
*/
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1575
|
+
/**
|
|
1576
|
+
* Content types that are text-based and should include charset=utf-8.
|
|
1577
|
+
* Binary formats (images) should not include charset.
|
|
1578
|
+
*/
|
|
1579
|
+
var TEXT_CONTENT_TYPES = new Set([
|
|
1580
|
+
"application/xml",
|
|
1581
|
+
"text/plain",
|
|
1582
|
+
"application/json",
|
|
1583
|
+
"application/manifest+json",
|
|
1584
|
+
"image/svg+xml"
|
|
1585
|
+
]);
|
|
1586
|
+
/**
|
|
1587
|
+
* Serve a static metadata file by reading it from disk.
|
|
1588
|
+
*
|
|
1589
|
+
* Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
|
|
1590
|
+
* are served as-is with the appropriate Content-Type header.
|
|
1591
|
+
* Text files include charset=utf-8; binary files do not.
|
|
1592
|
+
*
|
|
1593
|
+
* See design/16-metadata.md §"Metadata Routes"
|
|
1594
|
+
*/
|
|
1595
|
+
async function serveStaticMetadataFile(metaMatch) {
|
|
1596
|
+
const { contentType, file } = metaMatch;
|
|
1597
|
+
const isText = TEXT_CONTENT_TYPES.has(contentType);
|
|
1598
|
+
const body = await readFile(file.filePath);
|
|
1599
|
+
const headers = {
|
|
1600
|
+
"Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
|
|
1601
|
+
"Content-Length": String(body.byteLength)
|
|
1602
|
+
};
|
|
1603
|
+
return new Response(body, {
|
|
1604
|
+
status: 200,
|
|
1605
|
+
headers
|
|
1606
|
+
});
|
|
1622
1607
|
}
|
|
1623
1608
|
/**
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1609
|
+
* Serialize a sitemap array to XML.
|
|
1610
|
+
* Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
|
|
1626
1611
|
*/
|
|
1627
|
-
function
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
if (exact) return {
|
|
1634
|
-
file: exact,
|
|
1635
|
-
status,
|
|
1636
|
-
kind: "exact",
|
|
1637
|
-
segmentIndex: i
|
|
1638
|
-
};
|
|
1639
|
-
const category = segment.statusFiles.get("5xx");
|
|
1640
|
-
if (category) return {
|
|
1641
|
-
file: category,
|
|
1642
|
-
status,
|
|
1643
|
-
kind: "category",
|
|
1644
|
-
segmentIndex: i
|
|
1645
|
-
};
|
|
1612
|
+
function serializeSitemap(entries) {
|
|
1613
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
|
|
1614
|
+
let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
|
|
1615
|
+
if (e.lastModified) {
|
|
1616
|
+
const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
|
|
1617
|
+
xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
|
|
1646
1618
|
}
|
|
1647
|
-
if (
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1619
|
+
if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
|
|
1620
|
+
if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
|
|
1621
|
+
xml += "\n </url>";
|
|
1622
|
+
return xml;
|
|
1623
|
+
}).join("\n")}\n</urlset>`;
|
|
1624
|
+
}
|
|
1625
|
+
/** Escape special XML characters. */
|
|
1626
|
+
function escapeXml(str) {
|
|
1627
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1655
1628
|
}
|
|
1629
|
+
//#endregion
|
|
1630
|
+
//#region src/server/pipeline-interception.ts
|
|
1656
1631
|
/**
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1632
|
+
* Check if an intercepting route applies for this soft navigation.
|
|
1633
|
+
*
|
|
1634
|
+
* Matches the target pathname against interception rewrites, constrained
|
|
1635
|
+
* by the source URL (X-Timber-URL header — where the user navigates FROM).
|
|
1636
|
+
*
|
|
1637
|
+
* Returns the source pathname to re-match if interception applies, or null.
|
|
1660
1638
|
*/
|
|
1661
|
-
function
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
if (!segment.jsonStatusFiles) continue;
|
|
1666
|
-
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
1667
|
-
if (exact) return {
|
|
1668
|
-
file: exact,
|
|
1669
|
-
status,
|
|
1670
|
-
kind: "exact",
|
|
1671
|
-
segmentIndex: i
|
|
1672
|
-
};
|
|
1673
|
-
const category = segment.jsonStatusFiles.get("5xx");
|
|
1674
|
-
if (category) return {
|
|
1675
|
-
file: category,
|
|
1676
|
-
status,
|
|
1677
|
-
kind: "category",
|
|
1678
|
-
segmentIndex: i
|
|
1679
|
-
};
|
|
1639
|
+
function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
|
|
1640
|
+
for (const rewrite of rewrites) {
|
|
1641
|
+
if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
|
|
1642
|
+
if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
|
|
1680
1643
|
}
|
|
1681
1644
|
return null;
|
|
1682
1645
|
}
|
|
1683
1646
|
/**
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
1687
|
-
* Fallback chain: denied.tsx → default.tsx → null.
|
|
1647
|
+
* Check if a pathname matches a URL pattern with dynamic segments.
|
|
1688
1648
|
*
|
|
1689
|
-
*
|
|
1649
|
+
* Supports [param] (single segment) and [...param] (one or more segments).
|
|
1650
|
+
* Static segments must match exactly.
|
|
1690
1651
|
*/
|
|
1691
|
-
function
|
|
1692
|
-
const
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1652
|
+
function pathnameMatchesPattern(pathname, pattern) {
|
|
1653
|
+
const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
|
|
1654
|
+
const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
|
|
1655
|
+
let pi = 0;
|
|
1656
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
1657
|
+
const segment = patternParts[i];
|
|
1658
|
+
if (segment.startsWith("[...") || segment.startsWith("[[...")) return pi < pathParts.length || segment.startsWith("[[...");
|
|
1659
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
1660
|
+
if (pi >= pathParts.length) return false;
|
|
1661
|
+
pi++;
|
|
1662
|
+
continue;
|
|
1663
|
+
}
|
|
1664
|
+
if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
|
|
1665
|
+
pi++;
|
|
1666
|
+
}
|
|
1667
|
+
return pi === pathParts.length;
|
|
1704
1668
|
}
|
|
1705
1669
|
//#endregion
|
|
1706
|
-
//#region src/server/
|
|
1670
|
+
//#region src/server/pipeline.ts
|
|
1707
1671
|
/**
|
|
1708
|
-
*
|
|
1672
|
+
* Request pipeline — the central dispatch for all timber.js requests.
|
|
1709
1673
|
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
1712
|
-
* throws) caught before flush produce correct HTTP status codes.
|
|
1674
|
+
* Pipeline stages (in order):
|
|
1675
|
+
* proxy.ts → canonicalize → route match → 103 Early Hints → middleware.ts → render
|
|
1713
1676
|
*
|
|
1714
|
-
*
|
|
1677
|
+
* Each stage is a pure function or returns a Response to short-circuit.
|
|
1678
|
+
* Each request gets a trace ID, structured logging, and OTEL spans.
|
|
1679
|
+
*
|
|
1680
|
+
* See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
|
|
1681
|
+
* and design/17-logging.md §"Production Logging"
|
|
1715
1682
|
*/
|
|
1716
1683
|
/**
|
|
1717
|
-
*
|
|
1718
|
-
*
|
|
1719
|
-
* The flush controller:
|
|
1720
|
-
* 1. Calls the render function to start renderToReadableStream
|
|
1721
|
-
* 2. Waits for shellReady (onShellReady)
|
|
1722
|
-
* 3. If a render-phase signal was thrown (deny, redirect, error), produces
|
|
1723
|
-
* the correct HTTP status code
|
|
1724
|
-
* 4. If the shell rendered successfully, commits the status and streams
|
|
1684
|
+
* Run segment param coercion on the matched route's segments.
|
|
1725
1685
|
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
* - `RenderError` → HTTP status from error (default 500)
|
|
1730
|
-
* - Unhandled error → HTTP 500
|
|
1686
|
+
* Loads params.ts modules from segments that have them, extracts the
|
|
1687
|
+
* segmentParams definition, and coerces raw string params through codecs.
|
|
1688
|
+
* Throws ParamCoercionError if any codec fails (→ 404).
|
|
1731
1689
|
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
* @returns The committed HTTP Response.
|
|
1690
|
+
* This runs BEFORE middleware, so ctx.segmentParams is already typed.
|
|
1691
|
+
* See design/07-routing.md §"Where Coercion Runs"
|
|
1735
1692
|
*/
|
|
1736
|
-
async function
|
|
1737
|
-
const
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1693
|
+
async function coerceSegmentParams(match) {
|
|
1694
|
+
const segments = match.segments;
|
|
1695
|
+
for (const segment of segments) {
|
|
1696
|
+
if (!segment.params) continue;
|
|
1697
|
+
const segmentParamsDef = (await segment.params.load()).segmentParams;
|
|
1698
|
+
if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
|
|
1699
|
+
try {
|
|
1700
|
+
const coerced = segmentParamsDef.parse(match.params);
|
|
1701
|
+
Object.assign(match.params, coerced);
|
|
1702
|
+
} catch (err) {
|
|
1703
|
+
throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
|
|
1704
|
+
}
|
|
1743
1705
|
}
|
|
1744
|
-
try {
|
|
1745
|
-
await renderResult.shellReady;
|
|
1746
|
-
} catch (error) {
|
|
1747
|
-
return handleSignal(error, responseHeaders);
|
|
1748
|
-
}
|
|
1749
|
-
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
|
1750
|
-
return {
|
|
1751
|
-
response: new Response(renderResult.stream, {
|
|
1752
|
-
status: defaultStatus,
|
|
1753
|
-
headers: responseHeaders
|
|
1754
|
-
}),
|
|
1755
|
-
status: defaultStatus,
|
|
1756
|
-
isRedirect: false,
|
|
1757
|
-
isDenial: false
|
|
1758
|
-
};
|
|
1759
1706
|
}
|
|
1760
1707
|
/**
|
|
1761
|
-
*
|
|
1708
|
+
* Create the request handler from a pipeline configuration.
|
|
1709
|
+
*
|
|
1710
|
+
* Returns a function that processes an incoming Request through all pipeline stages
|
|
1711
|
+
* and produces a Response. This is the top-level entry point for the server.
|
|
1762
1712
|
*/
|
|
1763
|
-
function
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1713
|
+
function createPipeline(config) {
|
|
1714
|
+
const { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, serverTiming = "total", onPipelineError } = config;
|
|
1715
|
+
let activeRequests = 0;
|
|
1716
|
+
return async (req) => {
|
|
1717
|
+
const url = new URL(req.url);
|
|
1718
|
+
const method = req.method;
|
|
1719
|
+
const path = url.pathname;
|
|
1720
|
+
const startTime = performance.now();
|
|
1721
|
+
activeRequests++;
|
|
1722
|
+
return runWithTraceId(generateTraceId(), async () => {
|
|
1723
|
+
return runWithRequestContext(req, async () => {
|
|
1724
|
+
const runRequest = async () => {
|
|
1725
|
+
logRequestReceived({
|
|
1726
|
+
method,
|
|
1727
|
+
path
|
|
1728
|
+
});
|
|
1729
|
+
const response = await withSpan("http.server.request", {
|
|
1730
|
+
"http.request.method": method,
|
|
1731
|
+
"url.path": path
|
|
1732
|
+
}, async () => {
|
|
1733
|
+
const otelIds = await getOtelTraceId();
|
|
1734
|
+
if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
1735
|
+
let result;
|
|
1736
|
+
if (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
|
|
1737
|
+
else result = await handleRequest(req, method, path);
|
|
1738
|
+
await setSpanAttribute("http.response.status_code", result.status);
|
|
1739
|
+
if (serverTiming === "detailed") {
|
|
1740
|
+
const timingHeader = getServerTimingHeader();
|
|
1741
|
+
if (timingHeader) {
|
|
1742
|
+
result = ensureMutableResponse(result);
|
|
1743
|
+
result.headers.set("Server-Timing", timingHeader);
|
|
1744
|
+
}
|
|
1745
|
+
} else if (serverTiming === "total") {
|
|
1746
|
+
const totalMs = Math.round(performance.now() - startTime);
|
|
1747
|
+
result = ensureMutableResponse(result);
|
|
1748
|
+
result.headers.set("Server-Timing", `total;dur=${totalMs}`);
|
|
1749
|
+
}
|
|
1750
|
+
return result;
|
|
1751
|
+
});
|
|
1752
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
1753
|
+
const status = response.status;
|
|
1754
|
+
const concurrency = activeRequests;
|
|
1755
|
+
activeRequests--;
|
|
1756
|
+
logRequestCompleted({
|
|
1757
|
+
method,
|
|
1758
|
+
path,
|
|
1759
|
+
status,
|
|
1760
|
+
durationMs,
|
|
1761
|
+
concurrency
|
|
1762
|
+
});
|
|
1763
|
+
if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
|
|
1764
|
+
method,
|
|
1765
|
+
path,
|
|
1766
|
+
durationMs,
|
|
1767
|
+
threshold: slowRequestMs,
|
|
1768
|
+
concurrency
|
|
1769
|
+
});
|
|
1770
|
+
return response;
|
|
1771
|
+
};
|
|
1772
|
+
return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
|
|
1773
|
+
});
|
|
1774
|
+
});
|
|
1803
1775
|
};
|
|
1776
|
+
async function runProxyPhase(req, method, path) {
|
|
1777
|
+
try {
|
|
1778
|
+
let proxyExport;
|
|
1779
|
+
if (config.proxyLoader) proxyExport = (await config.proxyLoader()).default;
|
|
1780
|
+
else proxyExport = config.proxy;
|
|
1781
|
+
const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
|
|
1782
|
+
return await withSpan("timber.proxy", {}, () => serverTiming === "detailed" ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn());
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
logProxyError({ error });
|
|
1785
|
+
await fireOnRequestError(error, req, "proxy");
|
|
1786
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
|
|
1787
|
+
return new Response(null, { status: 500 });
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Build a redirect Response from a RedirectSignal.
|
|
1792
|
+
*
|
|
1793
|
+
* For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
|
|
1794
|
+
* so the client router can perform a soft SPA redirect. A raw 302 would be
|
|
1795
|
+
* turned into an opaque redirect by fetch({redirect:'manual'}), crashing
|
|
1796
|
+
* createFromFetch. See design/19-client-navigation.md.
|
|
1797
|
+
*/
|
|
1798
|
+
function buildRedirectResponse(signal, req, headers) {
|
|
1799
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1800
|
+
headers.set("X-Timber-Redirect", signal.location);
|
|
1801
|
+
return new Response(null, {
|
|
1802
|
+
status: 204,
|
|
1803
|
+
headers
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
headers.set("Location", signal.location);
|
|
1807
|
+
return new Response(null, {
|
|
1808
|
+
status: signal.status,
|
|
1809
|
+
headers
|
|
1810
|
+
});
|
|
1811
|
+
}
|
|
1812
|
+
async function handleRequest(req, method, path) {
|
|
1813
|
+
const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
|
|
1814
|
+
if (!result.ok) return new Response(null, { status: result.status });
|
|
1815
|
+
const canonicalPathname = result.pathname;
|
|
1816
|
+
if (config.matchMetadataRoute) {
|
|
1817
|
+
const metaMatch = config.matchMetadataRoute(canonicalPathname);
|
|
1818
|
+
if (metaMatch) try {
|
|
1819
|
+
if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
|
|
1820
|
+
const mod = await metaMatch.file.load();
|
|
1821
|
+
if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
|
|
1822
|
+
const handlerResult = await mod.default();
|
|
1823
|
+
if (handlerResult instanceof Response) return handlerResult;
|
|
1824
|
+
const contentType = metaMatch.contentType;
|
|
1825
|
+
let body;
|
|
1826
|
+
if (typeof handlerResult === "string") body = handlerResult;
|
|
1827
|
+
else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
|
|
1828
|
+
else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
|
|
1829
|
+
else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
|
|
1830
|
+
return new Response(body, {
|
|
1831
|
+
status: 200,
|
|
1832
|
+
headers: { "Content-Type": `${contentType}; charset=utf-8` }
|
|
1833
|
+
});
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
logRenderError({
|
|
1836
|
+
method,
|
|
1837
|
+
path,
|
|
1838
|
+
error
|
|
1839
|
+
});
|
|
1840
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
|
|
1841
|
+
return new Response(null, { status: 500 });
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
|
|
1845
|
+
if (!checkVersionSkew(req).ok) {
|
|
1846
|
+
const reloadHeaders = new Headers();
|
|
1847
|
+
applyReloadHeaders(reloadHeaders);
|
|
1848
|
+
return new Response(null, {
|
|
1849
|
+
status: 204,
|
|
1850
|
+
headers: reloadHeaders
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
let match = matchRoute(canonicalPathname);
|
|
1855
|
+
let interception;
|
|
1856
|
+
const sourceUrl = req.headers.get("X-Timber-URL");
|
|
1857
|
+
if (sourceUrl && config.interceptionRewrites?.length) {
|
|
1858
|
+
const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
|
|
1859
|
+
if (intercepted) {
|
|
1860
|
+
const sourceMatch = matchRoute(intercepted.sourcePathname);
|
|
1861
|
+
if (sourceMatch) {
|
|
1862
|
+
match = sourceMatch;
|
|
1863
|
+
interception = { targetPathname: canonicalPathname };
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
if (!match) {
|
|
1868
|
+
if (config.renderNoMatch) {
|
|
1869
|
+
const responseHeaders = new Headers();
|
|
1870
|
+
return config.renderNoMatch(req, responseHeaders);
|
|
1871
|
+
}
|
|
1872
|
+
return new Response(null, { status: 404 });
|
|
1873
|
+
}
|
|
1874
|
+
const responseHeaders = new Headers();
|
|
1875
|
+
const requestHeaderOverlay = new Headers();
|
|
1876
|
+
responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
|
|
1877
|
+
if (earlyHints) try {
|
|
1878
|
+
await earlyHints(match, req, responseHeaders);
|
|
1879
|
+
} catch {}
|
|
1880
|
+
try {
|
|
1881
|
+
await coerceSegmentParams(match);
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
if (error instanceof ParamCoercionError) return new Response(null, { status: 404 });
|
|
1884
|
+
throw error;
|
|
1885
|
+
}
|
|
1886
|
+
if (match.middleware) {
|
|
1887
|
+
const ctx = {
|
|
1888
|
+
req,
|
|
1889
|
+
requestHeaders: requestHeaderOverlay,
|
|
1890
|
+
headers: responseHeaders,
|
|
1891
|
+
segmentParams: match.params,
|
|
1892
|
+
earlyHints: (hints) => {
|
|
1893
|
+
for (const hint of hints) {
|
|
1894
|
+
let value;
|
|
1895
|
+
if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
1896
|
+
else value = `<${hint.href}>; rel=${hint.rel}`;
|
|
1897
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
1898
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
1899
|
+
responseHeaders.append("Link", value);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
try {
|
|
1904
|
+
setMutableCookieContext(true);
|
|
1905
|
+
const middlewareFn = () => runMiddleware(match.middleware, ctx);
|
|
1906
|
+
const middlewareResponse = await withSpan("timber.middleware", {}, () => serverTiming === "detailed" ? withTiming("mw", "middleware.ts", middlewareFn) : middlewareFn());
|
|
1907
|
+
setMutableCookieContext(false);
|
|
1908
|
+
if (middlewareResponse) {
|
|
1909
|
+
const finalResponse = ensureMutableResponse(middlewareResponse);
|
|
1910
|
+
applyCookieJar(finalResponse.headers);
|
|
1911
|
+
logMiddlewareShortCircuit({
|
|
1912
|
+
method,
|
|
1913
|
+
path,
|
|
1914
|
+
status: finalResponse.status
|
|
1915
|
+
});
|
|
1916
|
+
return finalResponse;
|
|
1917
|
+
}
|
|
1918
|
+
applyRequestHeaderOverlay(requestHeaderOverlay);
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
setMutableCookieContext(false);
|
|
1921
|
+
if (error instanceof RedirectSignal) {
|
|
1922
|
+
applyCookieJar(responseHeaders);
|
|
1923
|
+
return buildRedirectResponse(error, req, responseHeaders);
|
|
1924
|
+
}
|
|
1925
|
+
if (error instanceof DenySignal) return new Response(null, { status: error.status });
|
|
1926
|
+
logMiddlewareError({
|
|
1927
|
+
method,
|
|
1928
|
+
path,
|
|
1929
|
+
error
|
|
1930
|
+
});
|
|
1931
|
+
await fireOnRequestError(error, req, "handler");
|
|
1932
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
|
|
1933
|
+
return new Response(null, { status: 500 });
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
applyCookieJar(responseHeaders);
|
|
1937
|
+
try {
|
|
1938
|
+
const renderFn = () => render(req, match, responseHeaders, requestHeaderOverlay, interception);
|
|
1939
|
+
const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => serverTiming === "detailed" ? withTiming("render", "RSC + SSR render", renderFn) : renderFn());
|
|
1940
|
+
markResponseFlushed();
|
|
1941
|
+
return response;
|
|
1942
|
+
} catch (error) {
|
|
1943
|
+
if (error instanceof DenySignal) return new Response(null, { status: error.status });
|
|
1944
|
+
if (error instanceof RedirectSignal) return buildRedirectResponse(error, req, responseHeaders);
|
|
1945
|
+
logRenderError({
|
|
1946
|
+
method,
|
|
1947
|
+
path,
|
|
1948
|
+
error
|
|
1949
|
+
});
|
|
1950
|
+
await fireOnRequestError(error, req, "render");
|
|
1951
|
+
if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
|
|
1952
|
+
if (config.renderFallbackError) try {
|
|
1953
|
+
return await config.renderFallbackError(error, req, responseHeaders);
|
|
1954
|
+
} catch {}
|
|
1955
|
+
return new Response(null, { status: 500 });
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1804
1958
|
}
|
|
1805
|
-
//#endregion
|
|
1806
|
-
//#region src/server/csrf.ts
|
|
1807
|
-
/** HTTP methods that are considered safe (no mutation). */
|
|
1808
|
-
var SAFE_METHODS = new Set([
|
|
1809
|
-
"GET",
|
|
1810
|
-
"HEAD",
|
|
1811
|
-
"OPTIONS"
|
|
1812
|
-
]);
|
|
1813
1959
|
/**
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
1817
|
-
* - If `csrf: false`, skip validation.
|
|
1818
|
-
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
1819
|
-
* - Otherwise, Origin's host must match the request's Host header.
|
|
1820
|
-
*
|
|
1821
|
-
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
1960
|
+
* Fire the user's onRequestError hook with request context.
|
|
1961
|
+
* Extracts request info from the Request object and calls the instrumentation hook.
|
|
1822
1962
|
*/
|
|
1823
|
-
function
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1963
|
+
async function fireOnRequestError(error, req, phase) {
|
|
1964
|
+
const url = new URL(req.url);
|
|
1965
|
+
const headersObj = {};
|
|
1966
|
+
req.headers.forEach((v, k) => {
|
|
1967
|
+
headersObj[k] = v;
|
|
1968
|
+
});
|
|
1969
|
+
await callOnRequestError(error, {
|
|
1970
|
+
method: req.method,
|
|
1971
|
+
path: url.pathname,
|
|
1972
|
+
headers: headersObj
|
|
1973
|
+
}, {
|
|
1974
|
+
phase,
|
|
1975
|
+
routePath: url.pathname,
|
|
1976
|
+
routeType: "page",
|
|
1977
|
+
traceId: traceId()
|
|
1978
|
+
});
|
|
1979
|
+
}
|
|
1980
|
+
/**
|
|
1981
|
+
* Apply all Set-Cookie headers from the cookie jar to a Headers object.
|
|
1982
|
+
* Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
|
|
1983
|
+
*/
|
|
1984
|
+
function applyCookieJar(headers) {
|
|
1985
|
+
for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
|
|
1986
|
+
}
|
|
1987
|
+
/**
|
|
1988
|
+
* Ensure a Response has mutable headers so the pipeline can safely append
|
|
1989
|
+
* Set-Cookie and Server-Timing entries.
|
|
1990
|
+
*
|
|
1991
|
+
* `Response.redirect()` and some platform-level responses return objects
|
|
1992
|
+
* with immutable headers. Calling `.set()` or `.append()` on them throws
|
|
1993
|
+
* `TypeError: immutable`. This helper detects the immutable case by
|
|
1994
|
+
* attempting a no-op write and, on failure, clones into a fresh Response
|
|
1995
|
+
* with mutable headers.
|
|
1996
|
+
*/
|
|
1997
|
+
function ensureMutableResponse(response) {
|
|
1841
1998
|
try {
|
|
1842
|
-
|
|
1999
|
+
response.headers.set("X-Timber-Probe", "1");
|
|
2000
|
+
response.headers.delete("X-Timber-Probe");
|
|
2001
|
+
return response;
|
|
1843
2002
|
} catch {
|
|
1844
|
-
return {
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
2003
|
+
return new Response(response.body, {
|
|
2004
|
+
status: response.status,
|
|
2005
|
+
statusText: response.statusText,
|
|
2006
|
+
headers: new Headers(response.headers)
|
|
2007
|
+
});
|
|
1848
2008
|
}
|
|
1849
|
-
return originHost === host ? { ok: true } : {
|
|
1850
|
-
ok: false,
|
|
1851
|
-
status: 403
|
|
1852
|
-
};
|
|
1853
2009
|
}
|
|
1854
2010
|
//#endregion
|
|
1855
|
-
//#region src/server/
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
case "mb": return Math.floor(value * MB);
|
|
1874
|
-
case "gb": return Math.floor(value * GB);
|
|
1875
|
-
case "": return Math.floor(value);
|
|
1876
|
-
default: throw new Error(`Unknown size unit: "${unit}"`);
|
|
2011
|
+
//#region src/server/build-manifest.ts
|
|
2012
|
+
/**
|
|
2013
|
+
* Collect all CSS files needed for a matched route's segment chain.
|
|
2014
|
+
*
|
|
2015
|
+
* Walks segments root → leaf, collecting CSS for each layout and page.
|
|
2016
|
+
* Deduplicates while preserving order (root layout CSS first).
|
|
2017
|
+
*/
|
|
2018
|
+
function collectRouteCss(segments, manifest) {
|
|
2019
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2020
|
+
const result = [];
|
|
2021
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2022
|
+
if (!file) continue;
|
|
2023
|
+
const cssFiles = manifest.css[file.filePath];
|
|
2024
|
+
if (!cssFiles) continue;
|
|
2025
|
+
for (const url of cssFiles) if (!seen.has(url)) {
|
|
2026
|
+
seen.add(url);
|
|
2027
|
+
result.push(url);
|
|
2028
|
+
}
|
|
1877
2029
|
}
|
|
2030
|
+
return result;
|
|
1878
2031
|
}
|
|
1879
|
-
/**
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2032
|
+
/**
|
|
2033
|
+
* Collect all font entries needed for a matched route's segment chain.
|
|
2034
|
+
*
|
|
2035
|
+
* Walks segments root → leaf, collecting fonts for each layout and page.
|
|
2036
|
+
* Deduplicates by href while preserving order.
|
|
2037
|
+
*/
|
|
2038
|
+
function collectRouteFonts(segments, manifest) {
|
|
2039
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2040
|
+
const result = [];
|
|
2041
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2042
|
+
if (!file) continue;
|
|
2043
|
+
const fonts = manifest.fonts[file.filePath];
|
|
2044
|
+
if (!fonts) continue;
|
|
2045
|
+
for (const entry of fonts) if (!seen.has(entry.href)) {
|
|
2046
|
+
seen.add(entry.href);
|
|
2047
|
+
result.push(entry);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
return result;
|
|
1895
2051
|
}
|
|
1896
2052
|
/**
|
|
1897
|
-
*
|
|
2053
|
+
* Collect modulepreload URLs for a matched route's segment chain.
|
|
2054
|
+
*
|
|
2055
|
+
* Walks segments root → leaf, collecting transitive JS dependencies
|
|
2056
|
+
* for each layout and page. Deduplicates across segments.
|
|
1898
2057
|
*/
|
|
1899
|
-
function
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
|
|
2058
|
+
function collectRouteModulepreloads(segments, manifest) {
|
|
2059
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2060
|
+
const result = [];
|
|
2061
|
+
for (const segment of segments) for (const file of [segment.layout, segment.page]) {
|
|
2062
|
+
if (!file) continue;
|
|
2063
|
+
const preloads = manifest.modulepreload[file.filePath];
|
|
2064
|
+
if (!preloads) continue;
|
|
2065
|
+
for (const url of preloads) if (!seen.has(url)) {
|
|
2066
|
+
seen.add(url);
|
|
2067
|
+
result.push(url);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
return result;
|
|
1903
2071
|
}
|
|
1904
2072
|
//#endregion
|
|
1905
|
-
//#region src/server/
|
|
2073
|
+
//#region src/server/early-hints.ts
|
|
1906
2074
|
/**
|
|
1907
|
-
*
|
|
2075
|
+
* 103 Early Hints utilities.
|
|
1908
2076
|
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
2077
|
+
* Early Hints are sent before the final response to let the browser
|
|
2078
|
+
* start fetching critical resources (CSS, fonts, JS) while the server
|
|
2079
|
+
* is still rendering.
|
|
2080
|
+
*
|
|
2081
|
+
* The framework collects hints from two sources:
|
|
2082
|
+
* 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
|
|
2083
|
+
* 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
|
|
2084
|
+
*
|
|
2085
|
+
* Both are emitted as Link headers. Cloudflare CDN automatically converts
|
|
2086
|
+
* Link headers into 103 Early Hints responses.
|
|
2087
|
+
*
|
|
2088
|
+
* Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1911
2089
|
*/
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
if (
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
}
|
|
1936
|
-
});
|
|
1937
|
-
else {
|
|
1938
|
-
const imgList = Array.isArray(og.images) ? og.images : [og.images];
|
|
1939
|
-
for (const img of imgList) {
|
|
1940
|
-
elements.push({
|
|
1941
|
-
tag: "meta",
|
|
1942
|
-
attrs: {
|
|
1943
|
-
property: "og:image",
|
|
1944
|
-
content: img.url
|
|
1945
|
-
}
|
|
1946
|
-
});
|
|
1947
|
-
if (img.width) elements.push({
|
|
1948
|
-
tag: "meta",
|
|
1949
|
-
attrs: {
|
|
1950
|
-
property: "og:image:width",
|
|
1951
|
-
content: String(img.width)
|
|
1952
|
-
}
|
|
1953
|
-
});
|
|
1954
|
-
if (img.height) elements.push({
|
|
1955
|
-
tag: "meta",
|
|
1956
|
-
attrs: {
|
|
1957
|
-
property: "og:image:height",
|
|
1958
|
-
content: String(img.height)
|
|
1959
|
-
}
|
|
1960
|
-
});
|
|
1961
|
-
if (img.alt) elements.push({
|
|
1962
|
-
tag: "meta",
|
|
1963
|
-
attrs: {
|
|
1964
|
-
property: "og:image:alt",
|
|
1965
|
-
content: img.alt
|
|
1966
|
-
}
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
2090
|
+
/**
|
|
2091
|
+
* Format a single EarlyHint as a Link header value.
|
|
2092
|
+
*
|
|
2093
|
+
* Attribute order: `as` before `rel` to match Cloudflare CDN's cached
|
|
2094
|
+
* Early Hints format. Cloudflare caches Link headers from 200 responses
|
|
2095
|
+
* and re-emits them as 103 Early Hints on subsequent requests. If our
|
|
2096
|
+
* attribute order differs from Cloudflare's cached copy, the browser
|
|
2097
|
+
* sees two preload headers for the same URL (different attribute order)
|
|
2098
|
+
* and warns "Preload was ignored." Matching the order ensures the
|
|
2099
|
+
* browser deduplicates them correctly.
|
|
2100
|
+
*
|
|
2101
|
+
* Examples:
|
|
2102
|
+
* `</styles/root.css>; as=style; rel=preload`
|
|
2103
|
+
* `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
|
|
2104
|
+
* `</_timber/client.js>; rel=modulepreload`
|
|
2105
|
+
* `<https://fonts.googleapis.com>; rel=preconnect`
|
|
2106
|
+
*/
|
|
2107
|
+
function formatLinkHeader(hint) {
|
|
2108
|
+
if (hint.as !== void 0) {
|
|
2109
|
+
let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
|
|
2110
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
2111
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
2112
|
+
return value;
|
|
1969
2113
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2114
|
+
let value = `<${hint.href}>; rel=${hint.rel}`;
|
|
2115
|
+
if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
|
|
2116
|
+
if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
|
|
2117
|
+
return value;
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* Collect all Link header strings for a matched route's segment chain.
|
|
2121
|
+
*
|
|
2122
|
+
* Walks the build manifest to emit hints for:
|
|
2123
|
+
* - CSS stylesheets (as=style; rel=preload)
|
|
2124
|
+
* - Font assets (as=font; rel=preload; crossorigin)
|
|
2125
|
+
* - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
|
|
2126
|
+
*
|
|
2127
|
+
* Also emits global CSS from the `_global` manifest key. Route files
|
|
2128
|
+
* are server components that don't appear in the client bundle, so
|
|
2129
|
+
* per-route CSS keying doesn't work with the RSC plugin. The `_global`
|
|
2130
|
+
* key contains all CSS assets from the client build — fine for early
|
|
2131
|
+
* hints since they're just prefetch signals.
|
|
2132
|
+
*
|
|
2133
|
+
* Returns formatted Link header strings, deduplicated by URL, root → leaf order.
|
|
2134
|
+
* Returns an empty array in dev mode (manifest is empty).
|
|
2135
|
+
*/
|
|
2136
|
+
function collectEarlyHintHeaders(segments, manifest, options) {
|
|
2137
|
+
const result = [];
|
|
2138
|
+
const seenUrls = /* @__PURE__ */ new Set();
|
|
2139
|
+
const add = (url, header) => {
|
|
2140
|
+
if (!seenUrls.has(url)) {
|
|
2141
|
+
seenUrls.add(url);
|
|
2142
|
+
result.push(header);
|
|
1989
2143
|
}
|
|
1990
|
-
}
|
|
2144
|
+
};
|
|
2145
|
+
for (const url of collectRouteCss(segments, manifest)) add(url, formatLinkHeader({
|
|
2146
|
+
href: url,
|
|
2147
|
+
rel: "preload",
|
|
2148
|
+
as: "style"
|
|
2149
|
+
}));
|
|
2150
|
+
for (const url of manifest.css["_global"] ?? []) add(url, formatLinkHeader({
|
|
2151
|
+
href: url,
|
|
2152
|
+
rel: "preload",
|
|
2153
|
+
as: "style"
|
|
2154
|
+
}));
|
|
2155
|
+
for (const font of collectRouteFonts(segments, manifest)) add(font.href, formatLinkHeader({
|
|
2156
|
+
href: font.href,
|
|
2157
|
+
rel: "preload",
|
|
2158
|
+
as: "font",
|
|
2159
|
+
crossOrigin: "anonymous"
|
|
2160
|
+
}));
|
|
2161
|
+
if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(url, formatLinkHeader({
|
|
2162
|
+
href: url,
|
|
2163
|
+
rel: "modulepreload"
|
|
2164
|
+
}));
|
|
2165
|
+
return result;
|
|
1991
2166
|
}
|
|
2167
|
+
//#endregion
|
|
2168
|
+
//#region src/server/early-hints-sender.ts
|
|
1992
2169
|
/**
|
|
1993
|
-
*
|
|
2170
|
+
* Per-request 103 Early Hints sender — ALS bridge for platform adapters.
|
|
1994
2171
|
*
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
2172
|
+
* The pipeline collects Link headers for CSS, fonts, and JS chunks at
|
|
2173
|
+
* route-match time. On platforms that support it (Node.js v18.11+, Bun),
|
|
2174
|
+
* the adapter can send these as a 103 Early Hints interim response before
|
|
2175
|
+
* the final response is ready.
|
|
2176
|
+
*
|
|
2177
|
+
* This module provides an ALS-based bridge: the generated entry point
|
|
2178
|
+
* (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
|
|
2179
|
+
* binding a per-request sender function. The pipeline calls
|
|
2180
|
+
* `sendEarlyHints103()` to fire the 103 if a sender is available.
|
|
2181
|
+
*
|
|
2182
|
+
* On platforms where 103 is handled at the CDN level (e.g., Cloudflare
|
|
2183
|
+
* converts Link headers into 103 automatically), no sender is installed
|
|
2184
|
+
* and `sendEarlyHints103()` is a no-op.
|
|
2185
|
+
*
|
|
2186
|
+
* Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
|
|
1997
2187
|
*/
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
}
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2188
|
+
/**
|
|
2189
|
+
* Run a function with a per-request early hints sender installed.
|
|
2190
|
+
*
|
|
2191
|
+
* Called by generated entry points (e.g., Nitro node-server/bun) to
|
|
2192
|
+
* bind the platform's writeEarlyHints capability for the request duration.
|
|
2193
|
+
*/
|
|
2194
|
+
function runWithEarlyHintsSender(sender, fn) {
|
|
2195
|
+
return earlyHintsSenderAls.run(sender, fn);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Send collected Link headers as a 103 Early Hints response.
|
|
2199
|
+
*
|
|
2200
|
+
* No-op if no sender is installed for the current request (e.g., on
|
|
2201
|
+
* Cloudflare where the CDN handles 103 automatically, or in dev mode).
|
|
2202
|
+
*
|
|
2203
|
+
* Non-fatal: errors from the sender are caught and silently ignored.
|
|
2204
|
+
*/
|
|
2205
|
+
function sendEarlyHints103(links) {
|
|
2206
|
+
if (!links.length) return;
|
|
2207
|
+
const sender = earlyHintsSenderAls.getStore();
|
|
2208
|
+
if (!sender) return;
|
|
2209
|
+
try {
|
|
2210
|
+
sender(links);
|
|
2211
|
+
} catch {}
|
|
2212
|
+
}
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region src/server/tree-builder.ts
|
|
2215
|
+
/**
|
|
2216
|
+
* Build the unified element tree from a matched segment chain.
|
|
2217
|
+
*
|
|
2218
|
+
* Construction is bottom-up:
|
|
2219
|
+
* 1. Start with the page component (leaf segment)
|
|
2220
|
+
* 2. Wrap in status-code error boundaries (fallback chain)
|
|
2221
|
+
* 3. Wrap in AccessGate (if segment has access.ts)
|
|
2222
|
+
* 4. Pass as children to the segment's layout
|
|
2223
|
+
* 5. Repeat up the segment chain to root
|
|
2224
|
+
*
|
|
2225
|
+
* Parallel slots are resolved at each layout level and composed as named props.
|
|
2226
|
+
*/
|
|
2227
|
+
async function buildElementTree(config) {
|
|
2228
|
+
const { segments, params, loadModule, createElement, errorBoundaryComponent } = config;
|
|
2229
|
+
if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
|
|
2230
|
+
const leaf = segments[segments.length - 1];
|
|
2231
|
+
if (leaf.route && !leaf.page) return {
|
|
2232
|
+
tree: null,
|
|
2233
|
+
isApiRoute: true
|
|
2234
|
+
};
|
|
2235
|
+
const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
|
|
2236
|
+
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.`);
|
|
2237
|
+
let element = createElement(PageComponent, { params });
|
|
2238
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2239
|
+
const segment = segments[i];
|
|
2240
|
+
element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
|
|
2241
|
+
if (segment.access) {
|
|
2242
|
+
const accessFn = (await loadModule(segment.access)).default;
|
|
2243
|
+
element = createElement("timber:access-gate", {
|
|
2244
|
+
accessFn,
|
|
2245
|
+
params,
|
|
2246
|
+
segmentName: segment.segmentName,
|
|
2247
|
+
children: element
|
|
2032
2248
|
});
|
|
2033
2249
|
}
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
tag: "meta",
|
|
2045
|
-
attrs: {
|
|
2046
|
-
name: "twitter:player:width",
|
|
2047
|
-
content: String(player.width)
|
|
2048
|
-
}
|
|
2049
|
-
});
|
|
2050
|
-
if (player.height) elements.push({
|
|
2051
|
-
tag: "meta",
|
|
2052
|
-
attrs: {
|
|
2053
|
-
name: "twitter:player:height",
|
|
2054
|
-
content: String(player.height)
|
|
2055
|
-
}
|
|
2056
|
-
});
|
|
2057
|
-
if (player.streamUrl) elements.push({
|
|
2058
|
-
tag: "meta",
|
|
2059
|
-
attrs: {
|
|
2060
|
-
name: "twitter:player:stream",
|
|
2061
|
-
content: player.streamUrl
|
|
2250
|
+
if (segment.layout) {
|
|
2251
|
+
const LayoutComponent = (await loadModule(segment.layout)).default;
|
|
2252
|
+
if (LayoutComponent) {
|
|
2253
|
+
const slotProps = {};
|
|
2254
|
+
if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, params, loadModule, createElement, errorBoundaryComponent);
|
|
2255
|
+
element = createElement(LayoutComponent, {
|
|
2256
|
+
...slotProps,
|
|
2257
|
+
params,
|
|
2258
|
+
children: element
|
|
2259
|
+
});
|
|
2062
2260
|
}
|
|
2063
|
-
});
|
|
2064
|
-
}
|
|
2065
|
-
if (tw.app) {
|
|
2066
|
-
const platforms = [
|
|
2067
|
-
["iPhone", "iphone"],
|
|
2068
|
-
["iPad", "ipad"],
|
|
2069
|
-
["googlePlay", "googleplay"]
|
|
2070
|
-
];
|
|
2071
|
-
if (tw.app.name) {
|
|
2072
|
-
for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
|
|
2073
|
-
tag: "meta",
|
|
2074
|
-
attrs: {
|
|
2075
|
-
name: `twitter:app:name:${tag}`,
|
|
2076
|
-
content: tw.app.name
|
|
2077
|
-
}
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
for (const [key, tag] of platforms) {
|
|
2081
|
-
const id = tw.app.id?.[key];
|
|
2082
|
-
if (id) elements.push({
|
|
2083
|
-
tag: "meta",
|
|
2084
|
-
attrs: {
|
|
2085
|
-
name: `twitter:app:id:${tag}`,
|
|
2086
|
-
content: id
|
|
2087
|
-
}
|
|
2088
|
-
});
|
|
2089
|
-
}
|
|
2090
|
-
for (const [key, tag] of platforms) {
|
|
2091
|
-
const url = tw.app.url?.[key];
|
|
2092
|
-
if (url) elements.push({
|
|
2093
|
-
tag: "meta",
|
|
2094
|
-
attrs: {
|
|
2095
|
-
name: `twitter:app:url:${tag}`,
|
|
2096
|
-
content: url
|
|
2097
|
-
}
|
|
2098
|
-
});
|
|
2099
2261
|
}
|
|
2100
2262
|
}
|
|
2263
|
+
return {
|
|
2264
|
+
tree: element,
|
|
2265
|
+
isApiRoute: false
|
|
2266
|
+
};
|
|
2101
2267
|
}
|
|
2102
|
-
//#endregion
|
|
2103
|
-
//#region src/server/metadata-platform.ts
|
|
2104
2268
|
/**
|
|
2105
|
-
*
|
|
2269
|
+
* Build the element tree for a parallel slot.
|
|
2270
|
+
*
|
|
2271
|
+
* Slots have their own access.ts (SlotAccessGate) and error boundaries.
|
|
2272
|
+
* On access denial: denied.tsx → default.tsx → null (graceful degradation).
|
|
2106
2273
|
*/
|
|
2107
|
-
function
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2274
|
+
async function buildSlotElement(slotNode, params, loadModule, createElement, errorBoundaryComponent) {
|
|
2275
|
+
const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
|
|
2276
|
+
const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
|
|
2277
|
+
if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, { params }) : null;
|
|
2278
|
+
let element = createElement(PageComponent, { params });
|
|
2279
|
+
element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
|
|
2280
|
+
if (slotNode.access) {
|
|
2281
|
+
const accessFn = (await loadModule(slotNode.access)).default;
|
|
2282
|
+
const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default ?? null;
|
|
2283
|
+
const defaultFallback = DefaultComponent ? createElement(DefaultComponent, { params }) : null;
|
|
2284
|
+
element = createElement("timber:slot-access-gate", {
|
|
2285
|
+
accessFn,
|
|
2286
|
+
params,
|
|
2287
|
+
DeniedComponent,
|
|
2288
|
+
slotName: slotNode.segmentName.replace(/^@/, ""),
|
|
2289
|
+
createElement,
|
|
2290
|
+
defaultFallback,
|
|
2291
|
+
children: element
|
|
2115
2292
|
});
|
|
2116
|
-
else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
|
|
2117
|
-
const attrs = {
|
|
2118
|
-
rel: "icon",
|
|
2119
|
-
href: icon.url
|
|
2120
|
-
};
|
|
2121
|
-
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2122
|
-
if (icon.type) attrs.type = icon.type;
|
|
2123
|
-
elements.push({
|
|
2124
|
-
tag: "link",
|
|
2125
|
-
attrs
|
|
2126
|
-
});
|
|
2127
|
-
}
|
|
2128
2293
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2294
|
+
return element;
|
|
2295
|
+
}
|
|
2296
|
+
/** MDX/markdown extensions — these are server components that cannot be passed as function props. */
|
|
2297
|
+
var MDX_EXTENSIONS = new Set(["mdx", "md"]);
|
|
2298
|
+
/**
|
|
2299
|
+
* Check if a route file is an MDX/markdown file based on its extension.
|
|
2300
|
+
* MDX components are server components by default and cannot cross the
|
|
2301
|
+
* RSC→client boundary as function props. They must be pre-rendered as
|
|
2302
|
+
* elements and passed as fallbackElement instead of fallbackComponent.
|
|
2303
|
+
*/
|
|
2304
|
+
function isMdxFile(file) {
|
|
2305
|
+
return MDX_EXTENSIONS.has(file.extension);
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Wrap an element with error boundaries from a segment's status-code files.
|
|
2309
|
+
*
|
|
2310
|
+
* Wrapping order (innermost to outermost):
|
|
2311
|
+
* 1. Specific status files (503.tsx, 429.tsx, etc.)
|
|
2312
|
+
* 2. Category catch-alls (4xx.tsx, 5xx.tsx)
|
|
2313
|
+
* 3. error.tsx (general error boundary)
|
|
2314
|
+
*
|
|
2315
|
+
* This creates the fallback chain described in design/10-error-handling.md.
|
|
2316
|
+
*
|
|
2317
|
+
* MDX status files are server components and cannot be passed as function
|
|
2318
|
+
* props to TimberErrorBoundary (a 'use client' component). Instead, they
|
|
2319
|
+
* are pre-rendered as elements and passed as fallbackElement. The error
|
|
2320
|
+
* boundary renders the element directly when an error is caught.
|
|
2321
|
+
* See TIM-503.
|
|
2322
|
+
*/
|
|
2323
|
+
async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
|
|
2324
|
+
if (segment.statusFiles) {
|
|
2325
|
+
for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
|
|
2326
|
+
const status = parseInt(key, 10);
|
|
2327
|
+
if (!isNaN(status)) {
|
|
2328
|
+
const Component = (await loadModule(file)).default;
|
|
2329
|
+
if (Component) element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
|
|
2330
|
+
fallbackElement: createElement(Component, { status }),
|
|
2331
|
+
status,
|
|
2332
|
+
children: element
|
|
2333
|
+
} : {
|
|
2334
|
+
fallbackComponent: Component,
|
|
2335
|
+
status,
|
|
2336
|
+
children: element
|
|
2337
|
+
});
|
|
2136
2338
|
}
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2339
|
+
}
|
|
2340
|
+
for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
|
|
2341
|
+
const Component = (await loadModule(file)).default;
|
|
2342
|
+
if (Component) {
|
|
2343
|
+
const categoryStatus = key === "4xx" ? 400 : 500;
|
|
2344
|
+
element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
|
|
2345
|
+
fallbackElement: createElement(Component, {}),
|
|
2346
|
+
status: categoryStatus,
|
|
2347
|
+
children: element
|
|
2348
|
+
} : {
|
|
2349
|
+
fallbackComponent: Component,
|
|
2350
|
+
status: categoryStatus,
|
|
2351
|
+
children: element
|
|
2352
|
+
});
|
|
2145
2353
|
}
|
|
2146
|
-
});
|
|
2147
|
-
else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
|
|
2148
|
-
const attrs = {
|
|
2149
|
-
rel: "apple-touch-icon",
|
|
2150
|
-
href: icon.url
|
|
2151
|
-
};
|
|
2152
|
-
if (icon.sizes) attrs.sizes = icon.sizes;
|
|
2153
|
-
elements.push({
|
|
2154
|
-
tag: "link",
|
|
2155
|
-
attrs
|
|
2156
|
-
});
|
|
2157
2354
|
}
|
|
2158
2355
|
}
|
|
2159
|
-
if (
|
|
2160
|
-
const
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
tag: "link",
|
|
2168
|
-
attrs
|
|
2356
|
+
if (segment.error) {
|
|
2357
|
+
const ErrorComponent = (await loadModule(segment.error)).default;
|
|
2358
|
+
if (ErrorComponent) element = createElement(errorBoundaryComponent, isMdxFile(segment.error) ? {
|
|
2359
|
+
fallbackElement: createElement(ErrorComponent, {}),
|
|
2360
|
+
children: element
|
|
2361
|
+
} : {
|
|
2362
|
+
fallbackComponent: ErrorComponent,
|
|
2363
|
+
children: element
|
|
2169
2364
|
});
|
|
2170
2365
|
}
|
|
2366
|
+
return element;
|
|
2171
2367
|
}
|
|
2368
|
+
//#endregion
|
|
2369
|
+
//#region src/server/status-code-resolver.ts
|
|
2172
2370
|
/**
|
|
2173
|
-
*
|
|
2371
|
+
* Maps legacy file convention names to their corresponding HTTP status codes.
|
|
2372
|
+
* Only used in the 4xx component fallback chain.
|
|
2174
2373
|
*/
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
media,
|
|
2196
|
-
href
|
|
2197
|
-
}
|
|
2198
|
-
});
|
|
2199
|
-
if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
|
|
2200
|
-
tag: "link",
|
|
2201
|
-
attrs: {
|
|
2202
|
-
rel: "alternate",
|
|
2203
|
-
type,
|
|
2204
|
-
href
|
|
2205
|
-
}
|
|
2206
|
-
});
|
|
2374
|
+
var LEGACY_FILE_TO_STATUS = {
|
|
2375
|
+
"not-found": 404,
|
|
2376
|
+
"forbidden": 403,
|
|
2377
|
+
"unauthorized": 401
|
|
2378
|
+
};
|
|
2379
|
+
/**
|
|
2380
|
+
* Resolve the status-code file to render for a given HTTP status code.
|
|
2381
|
+
*
|
|
2382
|
+
* Walks the segment chain from leaf to root following the fallback chain
|
|
2383
|
+
* defined in design/10-error-handling.md. Returns null if no file is found
|
|
2384
|
+
* (caller should render the framework default).
|
|
2385
|
+
*
|
|
2386
|
+
* @param status - The HTTP status code (4xx or 5xx).
|
|
2387
|
+
* @param segments - The matched segment chain from root (index 0) to leaf (last).
|
|
2388
|
+
* @param format - The response format family ('component' or 'json'). Defaults to 'component'.
|
|
2389
|
+
*/
|
|
2390
|
+
function resolveStatusFile(status, segments, format = "component") {
|
|
2391
|
+
if (status >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
|
|
2392
|
+
if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
|
|
2393
|
+
return null;
|
|
2207
2394
|
}
|
|
2208
2395
|
/**
|
|
2209
|
-
*
|
|
2396
|
+
* 4xx component fallback chain (three separate passes):
|
|
2397
|
+
* Pass 1 — status files (leaf → root): {status}.tsx → 4xx.tsx
|
|
2398
|
+
* Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
|
|
2399
|
+
* Pass 3 — error.tsx (leaf → root)
|
|
2210
2400
|
*/
|
|
2211
|
-
function
|
|
2212
|
-
const
|
|
2213
|
-
|
|
2214
|
-
[
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2401
|
+
function resolve4xx(status, segments) {
|
|
2402
|
+
const statusStr = String(status);
|
|
2403
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2404
|
+
const segment = segments[i];
|
|
2405
|
+
if (!segment.statusFiles) continue;
|
|
2406
|
+
const exact = segment.statusFiles.get(statusStr);
|
|
2407
|
+
if (exact) return {
|
|
2408
|
+
file: exact,
|
|
2409
|
+
status,
|
|
2410
|
+
kind: "exact",
|
|
2411
|
+
segmentIndex: i
|
|
2412
|
+
};
|
|
2413
|
+
const category = segment.statusFiles.get("4xx");
|
|
2414
|
+
if (category) return {
|
|
2415
|
+
file: category,
|
|
2416
|
+
status,
|
|
2417
|
+
kind: "category",
|
|
2418
|
+
segmentIndex: i
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2422
|
+
const segment = segments[i];
|
|
2423
|
+
if (!segment.legacyStatusFiles) continue;
|
|
2424
|
+
for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
|
|
2425
|
+
const file = segment.legacyStatusFiles.get(name);
|
|
2426
|
+
if (file) return {
|
|
2427
|
+
file,
|
|
2428
|
+
status,
|
|
2429
|
+
kind: "legacy",
|
|
2430
|
+
segmentIndex: i
|
|
2431
|
+
};
|
|
2222
2432
|
}
|
|
2223
|
-
});
|
|
2224
|
-
if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
|
|
2225
|
-
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
2226
|
-
elements.push({
|
|
2227
|
-
tag: "meta",
|
|
2228
|
-
attrs: {
|
|
2229
|
-
name,
|
|
2230
|
-
content
|
|
2231
|
-
}
|
|
2232
|
-
});
|
|
2233
2433
|
}
|
|
2434
|
+
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
|
|
2435
|
+
file: segments[i].error,
|
|
2436
|
+
status,
|
|
2437
|
+
kind: "error",
|
|
2438
|
+
segmentIndex: i
|
|
2439
|
+
};
|
|
2440
|
+
return null;
|
|
2234
2441
|
}
|
|
2235
2442
|
/**
|
|
2236
|
-
*
|
|
2443
|
+
* 4xx JSON fallback chain (single pass):
|
|
2444
|
+
* Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
|
|
2445
|
+
* No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
|
|
2237
2446
|
*/
|
|
2238
|
-
function
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2447
|
+
function resolve4xxJson(status, segments) {
|
|
2448
|
+
const statusStr = String(status);
|
|
2449
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2450
|
+
const segment = segments[i];
|
|
2451
|
+
if (!segment.jsonStatusFiles) continue;
|
|
2452
|
+
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
2453
|
+
if (exact) return {
|
|
2454
|
+
file: exact,
|
|
2455
|
+
status,
|
|
2456
|
+
kind: "exact",
|
|
2457
|
+
segmentIndex: i
|
|
2458
|
+
};
|
|
2459
|
+
const category = segment.jsonStatusFiles.get("4xx");
|
|
2460
|
+
if (category) return {
|
|
2461
|
+
file: category,
|
|
2462
|
+
status,
|
|
2463
|
+
kind: "category",
|
|
2464
|
+
segmentIndex: i
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
return null;
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* 5xx component fallback chain (single pass, per-segment):
|
|
2471
|
+
* At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
|
|
2472
|
+
*/
|
|
2473
|
+
function resolve5xx(status, segments) {
|
|
2474
|
+
const statusStr = String(status);
|
|
2475
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2476
|
+
const segment = segments[i];
|
|
2477
|
+
if (segment.statusFiles) {
|
|
2478
|
+
const exact = segment.statusFiles.get(statusStr);
|
|
2479
|
+
if (exact) return {
|
|
2480
|
+
file: exact,
|
|
2481
|
+
status,
|
|
2482
|
+
kind: "exact",
|
|
2483
|
+
segmentIndex: i
|
|
2484
|
+
};
|
|
2485
|
+
const category = segment.statusFiles.get("5xx");
|
|
2486
|
+
if (category) return {
|
|
2487
|
+
file: category,
|
|
2488
|
+
status,
|
|
2489
|
+
kind: "category",
|
|
2490
|
+
segmentIndex: i
|
|
2266
2491
|
};
|
|
2267
|
-
if (typeof img === "object" && img.media) attrs.media = img.media;
|
|
2268
|
-
elements.push({
|
|
2269
|
-
tag: "link",
|
|
2270
|
-
attrs
|
|
2271
|
-
});
|
|
2272
2492
|
}
|
|
2493
|
+
if (segment.error) return {
|
|
2494
|
+
file: segment.error,
|
|
2495
|
+
status,
|
|
2496
|
+
kind: "error",
|
|
2497
|
+
segmentIndex: i
|
|
2498
|
+
};
|
|
2273
2499
|
}
|
|
2500
|
+
return null;
|
|
2274
2501
|
}
|
|
2275
2502
|
/**
|
|
2276
|
-
*
|
|
2503
|
+
* 5xx JSON fallback chain (single pass):
|
|
2504
|
+
* At each segment (leaf → root): {status}.json → 5xx.json
|
|
2505
|
+
* No error.tsx equivalent — JSON chain terminates at category catch-all.
|
|
2277
2506
|
*/
|
|
2278
|
-
function
|
|
2279
|
-
const
|
|
2280
|
-
|
|
2281
|
-
[
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
if (appLinks.web.url) elements.push({
|
|
2298
|
-
tag: "meta",
|
|
2299
|
-
attrs: {
|
|
2300
|
-
property: "al:web:url",
|
|
2301
|
-
content: appLinks.web.url
|
|
2302
|
-
}
|
|
2303
|
-
});
|
|
2304
|
-
if (appLinks.web.shouldFallback !== void 0) elements.push({
|
|
2305
|
-
tag: "meta",
|
|
2306
|
-
attrs: {
|
|
2307
|
-
property: "al:web:should_fallback",
|
|
2308
|
-
content: appLinks.web.shouldFallback ? "true" : "false"
|
|
2309
|
-
}
|
|
2310
|
-
});
|
|
2507
|
+
function resolve5xxJson(status, segments) {
|
|
2508
|
+
const statusStr = String(status);
|
|
2509
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
2510
|
+
const segment = segments[i];
|
|
2511
|
+
if (!segment.jsonStatusFiles) continue;
|
|
2512
|
+
const exact = segment.jsonStatusFiles.get(statusStr);
|
|
2513
|
+
if (exact) return {
|
|
2514
|
+
file: exact,
|
|
2515
|
+
status,
|
|
2516
|
+
kind: "exact",
|
|
2517
|
+
segmentIndex: i
|
|
2518
|
+
};
|
|
2519
|
+
const category = segment.jsonStatusFiles.get("5xx");
|
|
2520
|
+
if (category) return {
|
|
2521
|
+
file: category,
|
|
2522
|
+
status,
|
|
2523
|
+
kind: "category",
|
|
2524
|
+
segmentIndex: i
|
|
2525
|
+
};
|
|
2311
2526
|
}
|
|
2527
|
+
return null;
|
|
2312
2528
|
}
|
|
2313
2529
|
/**
|
|
2314
|
-
*
|
|
2315
|
-
*/
|
|
2316
|
-
function renderItunes(itunes, elements) {
|
|
2317
|
-
const parts = [`app-id=${itunes.appId}`];
|
|
2318
|
-
if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
|
|
2319
|
-
if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
|
|
2320
|
-
elements.push({
|
|
2321
|
-
tag: "meta",
|
|
2322
|
-
attrs: {
|
|
2323
|
-
name: "apple-itunes-app",
|
|
2324
|
-
content: parts.join(", ")
|
|
2325
|
-
}
|
|
2326
|
-
});
|
|
2327
|
-
}
|
|
2328
|
-
//#endregion
|
|
2329
|
-
//#region src/server/metadata-render.ts
|
|
2330
|
-
/**
|
|
2331
|
-
* Convert resolved metadata into an array of head element descriptors.
|
|
2530
|
+
* Resolve the denial file for a parallel route slot.
|
|
2332
2531
|
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
2532
|
+
* Slot denial is graceful degradation — no HTTP status on the wire.
|
|
2533
|
+
* Fallback chain: denied.tsx → default.tsx → null.
|
|
2335
2534
|
*
|
|
2336
|
-
* The
|
|
2337
|
-
* and renders them into the <head>.
|
|
2535
|
+
* @param slotNode - The segment node for the slot (segmentType === 'slot').
|
|
2338
2536
|
*/
|
|
2339
|
-
function
|
|
2340
|
-
const
|
|
2341
|
-
if (
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
["publisher", metadata.publisher]
|
|
2353
|
-
];
|
|
2354
|
-
for (const [name, content] of simpleMetaProps) if (content) elements.push({
|
|
2355
|
-
tag: "meta",
|
|
2356
|
-
attrs: {
|
|
2357
|
-
name,
|
|
2358
|
-
content
|
|
2359
|
-
}
|
|
2360
|
-
});
|
|
2361
|
-
if (metadata.keywords) {
|
|
2362
|
-
const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
2363
|
-
elements.push({
|
|
2364
|
-
tag: "meta",
|
|
2365
|
-
attrs: {
|
|
2366
|
-
name: "keywords",
|
|
2367
|
-
content
|
|
2368
|
-
}
|
|
2369
|
-
});
|
|
2370
|
-
}
|
|
2371
|
-
if (metadata.robots) {
|
|
2372
|
-
const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
|
|
2373
|
-
elements.push({
|
|
2374
|
-
tag: "meta",
|
|
2375
|
-
attrs: {
|
|
2376
|
-
name: "robots",
|
|
2377
|
-
content
|
|
2378
|
-
}
|
|
2379
|
-
});
|
|
2380
|
-
if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
|
|
2381
|
-
const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
|
|
2382
|
-
elements.push({
|
|
2383
|
-
tag: "meta",
|
|
2384
|
-
attrs: {
|
|
2385
|
-
name: "googlebot",
|
|
2386
|
-
content: gbContent
|
|
2387
|
-
}
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
}
|
|
2391
|
-
if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
|
|
2392
|
-
if (metadata.twitter) renderTwitter(metadata.twitter, elements);
|
|
2393
|
-
if (metadata.icons) renderIcons(metadata.icons, elements);
|
|
2394
|
-
if (metadata.manifest) elements.push({
|
|
2395
|
-
tag: "link",
|
|
2396
|
-
attrs: {
|
|
2397
|
-
rel: "manifest",
|
|
2398
|
-
href: metadata.manifest
|
|
2399
|
-
}
|
|
2400
|
-
});
|
|
2401
|
-
if (metadata.alternates) renderAlternates(metadata.alternates, elements);
|
|
2402
|
-
if (metadata.verification) renderVerification(metadata.verification, elements);
|
|
2403
|
-
if (metadata.formatDetection) {
|
|
2404
|
-
const parts = [];
|
|
2405
|
-
if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
|
|
2406
|
-
if (metadata.formatDetection.email === false) parts.push("email=no");
|
|
2407
|
-
if (metadata.formatDetection.address === false) parts.push("address=no");
|
|
2408
|
-
if (parts.length > 0) elements.push({
|
|
2409
|
-
tag: "meta",
|
|
2410
|
-
attrs: {
|
|
2411
|
-
name: "format-detection",
|
|
2412
|
-
content: parts.join(", ")
|
|
2413
|
-
}
|
|
2414
|
-
});
|
|
2415
|
-
}
|
|
2416
|
-
if (metadata.authors) {
|
|
2417
|
-
const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
|
|
2418
|
-
for (const author of authorList) {
|
|
2419
|
-
if (author.name) elements.push({
|
|
2420
|
-
tag: "meta",
|
|
2421
|
-
attrs: {
|
|
2422
|
-
name: "author",
|
|
2423
|
-
content: author.name
|
|
2424
|
-
}
|
|
2425
|
-
});
|
|
2426
|
-
if (author.url) elements.push({
|
|
2427
|
-
tag: "link",
|
|
2428
|
-
attrs: {
|
|
2429
|
-
rel: "author",
|
|
2430
|
-
href: author.url
|
|
2431
|
-
}
|
|
2432
|
-
});
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
|
|
2436
|
-
if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
|
|
2437
|
-
if (metadata.itunes) renderItunes(metadata.itunes, elements);
|
|
2438
|
-
if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
|
|
2439
|
-
const content = Array.isArray(value) ? value.join(", ") : value;
|
|
2440
|
-
elements.push({
|
|
2441
|
-
tag: "meta",
|
|
2442
|
-
attrs: {
|
|
2443
|
-
name,
|
|
2444
|
-
content
|
|
2445
|
-
}
|
|
2446
|
-
});
|
|
2447
|
-
}
|
|
2448
|
-
return elements;
|
|
2449
|
-
}
|
|
2450
|
-
function renderRobotsObject(robots) {
|
|
2451
|
-
const parts = [];
|
|
2452
|
-
if (robots.index === true) parts.push("index");
|
|
2453
|
-
if (robots.index === false) parts.push("noindex");
|
|
2454
|
-
if (robots.follow === true) parts.push("follow");
|
|
2455
|
-
if (robots.follow === false) parts.push("nofollow");
|
|
2456
|
-
return parts.join(", ");
|
|
2537
|
+
function resolveSlotDenied(slotNode) {
|
|
2538
|
+
const slotName = slotNode.segmentName.replace(/^@/, "");
|
|
2539
|
+
if (slotNode.denied) return {
|
|
2540
|
+
file: slotNode.denied,
|
|
2541
|
+
slotName,
|
|
2542
|
+
kind: "denied"
|
|
2543
|
+
};
|
|
2544
|
+
if (slotNode.default) return {
|
|
2545
|
+
file: slotNode.default,
|
|
2546
|
+
slotName,
|
|
2547
|
+
kind: "default"
|
|
2548
|
+
};
|
|
2549
|
+
return null;
|
|
2457
2550
|
}
|
|
2458
2551
|
//#endregion
|
|
2459
|
-
//#region src/server/
|
|
2552
|
+
//#region src/server/flush.ts
|
|
2460
2553
|
/**
|
|
2461
|
-
*
|
|
2554
|
+
* Flush controller for timber.js rendering.
|
|
2462
2555
|
*
|
|
2463
|
-
*
|
|
2464
|
-
*
|
|
2465
|
-
*
|
|
2466
|
-
*
|
|
2556
|
+
* Holds the response until `onShellReady` fires, then commits the HTTP status
|
|
2557
|
+
* code and flushes the shell. Render-phase signals (deny, redirect, unhandled
|
|
2558
|
+
* throws) caught before flush produce correct HTTP status codes.
|
|
2559
|
+
*
|
|
2560
|
+
* See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
|
|
2467
2561
|
*/
|
|
2468
|
-
function resolveTitle(title, template) {
|
|
2469
|
-
if (title === void 0 || title === null) return;
|
|
2470
|
-
if (typeof title === "string") return template ? template.replace("%s", title) : title;
|
|
2471
|
-
if (title.absolute !== void 0) return title.absolute;
|
|
2472
|
-
if (title.default !== void 0) return title.default;
|
|
2473
|
-
}
|
|
2474
2562
|
/**
|
|
2475
|
-
*
|
|
2563
|
+
* Execute a render and hold the response until the shell is ready.
|
|
2476
2564
|
*
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2480
|
-
*
|
|
2481
|
-
*
|
|
2565
|
+
* The flush controller:
|
|
2566
|
+
* 1. Calls the render function to start renderToReadableStream
|
|
2567
|
+
* 2. Waits for shellReady (onShellReady)
|
|
2568
|
+
* 3. If a render-phase signal was thrown (deny, redirect, error), produces
|
|
2569
|
+
* the correct HTTP status code
|
|
2570
|
+
* 4. If the shell rendered successfully, commits the status and streams
|
|
2482
2571
|
*
|
|
2483
|
-
*
|
|
2572
|
+
* Render-phase signals caught before flush:
|
|
2573
|
+
* - `DenySignal` → HTTP 4xx with appropriate status code
|
|
2574
|
+
* - `RedirectSignal` → HTTP 3xx with Location header
|
|
2575
|
+
* - `RenderError` → HTTP status from error (default 500)
|
|
2576
|
+
* - Unhandled error → HTTP 500
|
|
2484
2577
|
*
|
|
2485
|
-
*
|
|
2578
|
+
* @param renderFn - Function that starts the React render.
|
|
2579
|
+
* @param options - Flush configuration.
|
|
2580
|
+
* @returns The committed HTTP Response.
|
|
2486
2581
|
*/
|
|
2487
|
-
function
|
|
2488
|
-
const {
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
if (errorState && isPage) continue;
|
|
2495
|
-
if (metadata.title !== void 0 && typeof metadata.title === "object") {
|
|
2496
|
-
if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
|
|
2497
|
-
if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
|
|
2498
|
-
}
|
|
2499
|
-
for (const key of Object.keys(metadata)) {
|
|
2500
|
-
if (key === "title") continue;
|
|
2501
|
-
merged[key] = metadata[key];
|
|
2502
|
-
}
|
|
2503
|
-
if (metadata.title !== void 0) rawTitle = metadata.title;
|
|
2582
|
+
async function flushResponse(renderFn, options = {}) {
|
|
2583
|
+
const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
|
|
2584
|
+
let renderResult;
|
|
2585
|
+
try {
|
|
2586
|
+
renderResult = await renderFn();
|
|
2587
|
+
} catch (error) {
|
|
2588
|
+
return handleSignal(error, responseHeaders);
|
|
2504
2589
|
}
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2590
|
+
try {
|
|
2591
|
+
await renderResult.shellReady;
|
|
2592
|
+
} catch (error) {
|
|
2593
|
+
return handleSignal(error, responseHeaders);
|
|
2508
2594
|
}
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2595
|
+
responseHeaders.set("Content-Type", "text/html; charset=utf-8");
|
|
2596
|
+
return {
|
|
2597
|
+
response: new Response(renderResult.stream, {
|
|
2598
|
+
status: defaultStatus,
|
|
2599
|
+
headers: responseHeaders
|
|
2600
|
+
}),
|
|
2601
|
+
status: defaultStatus,
|
|
2602
|
+
isRedirect: false,
|
|
2603
|
+
isDenial: false
|
|
2604
|
+
};
|
|
2519
2605
|
}
|
|
2520
2606
|
/**
|
|
2521
|
-
*
|
|
2607
|
+
* Handle a render-phase signal and produce the correct HTTP response.
|
|
2522
2608
|
*/
|
|
2523
|
-
function
|
|
2524
|
-
if (
|
|
2525
|
-
|
|
2609
|
+
function handleSignal(error, responseHeaders) {
|
|
2610
|
+
if (error instanceof RedirectSignal) {
|
|
2611
|
+
responseHeaders.set("Location", error.location);
|
|
2612
|
+
return {
|
|
2613
|
+
response: new Response(null, {
|
|
2614
|
+
status: error.status,
|
|
2615
|
+
headers: responseHeaders
|
|
2616
|
+
}),
|
|
2617
|
+
status: error.status,
|
|
2618
|
+
isRedirect: true,
|
|
2619
|
+
isDenial: false
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
if (error instanceof DenySignal) return {
|
|
2623
|
+
response: new Response(null, {
|
|
2624
|
+
status: error.status,
|
|
2625
|
+
headers: responseHeaders
|
|
2626
|
+
}),
|
|
2627
|
+
status: error.status,
|
|
2628
|
+
isRedirect: false,
|
|
2629
|
+
isDenial: true
|
|
2630
|
+
};
|
|
2631
|
+
if (error instanceof RenderError) return {
|
|
2632
|
+
response: new Response(null, {
|
|
2633
|
+
status: error.status,
|
|
2634
|
+
headers: responseHeaders
|
|
2635
|
+
}),
|
|
2636
|
+
status: error.status,
|
|
2637
|
+
isRedirect: false,
|
|
2638
|
+
isDenial: false
|
|
2639
|
+
};
|
|
2640
|
+
console.error("[timber] Unhandled render-phase error:", error);
|
|
2641
|
+
return {
|
|
2642
|
+
response: new Response(null, {
|
|
2643
|
+
status: 500,
|
|
2644
|
+
headers: responseHeaders
|
|
2645
|
+
}),
|
|
2646
|
+
status: 500,
|
|
2647
|
+
isRedirect: false,
|
|
2648
|
+
isDenial: false
|
|
2649
|
+
};
|
|
2526
2650
|
}
|
|
2651
|
+
//#endregion
|
|
2652
|
+
//#region src/server/csrf.ts
|
|
2653
|
+
/** HTTP methods that are considered safe (no mutation). */
|
|
2654
|
+
var SAFE_METHODS = new Set([
|
|
2655
|
+
"GET",
|
|
2656
|
+
"HEAD",
|
|
2657
|
+
"OPTIONS"
|
|
2658
|
+
]);
|
|
2527
2659
|
/**
|
|
2528
|
-
*
|
|
2660
|
+
* Validate the Origin header against the request's Host.
|
|
2529
2661
|
*
|
|
2530
|
-
*
|
|
2531
|
-
* If
|
|
2662
|
+
* For mutation methods (POST, PUT, PATCH, DELETE):
|
|
2663
|
+
* - If `csrf: false`, skip validation.
|
|
2664
|
+
* - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
|
|
2665
|
+
* - Otherwise, Origin's host must match the request's Host header.
|
|
2666
|
+
*
|
|
2667
|
+
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
2532
2668
|
*/
|
|
2533
|
-
function
|
|
2534
|
-
|
|
2535
|
-
if (
|
|
2536
|
-
const
|
|
2537
|
-
if (
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
});
|
|
2558
|
-
const allStrings = resolved.every((r) => typeof r === "string");
|
|
2559
|
-
result.twitter.images = allStrings ? resolved : resolved;
|
|
2560
|
-
} else if (result.twitter.images) result.twitter.images = {
|
|
2561
|
-
...result.twitter.images,
|
|
2562
|
-
url: resolveUrl(result.twitter.images.url, base)
|
|
2669
|
+
function validateCsrf(req, config) {
|
|
2670
|
+
if (SAFE_METHODS.has(req.method)) return { ok: true };
|
|
2671
|
+
if (config.csrf === false) return { ok: true };
|
|
2672
|
+
const origin = req.headers.get("Origin");
|
|
2673
|
+
if (!origin) return {
|
|
2674
|
+
ok: false,
|
|
2675
|
+
status: 403
|
|
2676
|
+
};
|
|
2677
|
+
if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
|
|
2678
|
+
ok: false,
|
|
2679
|
+
status: 403
|
|
2680
|
+
};
|
|
2681
|
+
const host = req.headers.get("Host");
|
|
2682
|
+
if (!host) return {
|
|
2683
|
+
ok: false,
|
|
2684
|
+
status: 403
|
|
2685
|
+
};
|
|
2686
|
+
let originHost;
|
|
2687
|
+
try {
|
|
2688
|
+
originHost = new URL(origin).host;
|
|
2689
|
+
} catch {
|
|
2690
|
+
return {
|
|
2691
|
+
ok: false,
|
|
2692
|
+
status: 403
|
|
2563
2693
|
};
|
|
2564
2694
|
}
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2695
|
+
return originHost === host ? { ok: true } : {
|
|
2696
|
+
ok: false,
|
|
2697
|
+
status: 403
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
//#endregion
|
|
2701
|
+
//#region src/server/body-limits.ts
|
|
2702
|
+
var KB = 1024;
|
|
2703
|
+
var MB = 1024 * KB;
|
|
2704
|
+
var GB = 1024 * MB;
|
|
2705
|
+
var DEFAULT_LIMITS = {
|
|
2706
|
+
actionBodySize: 1 * MB,
|
|
2707
|
+
uploadBodySize: 10 * MB,
|
|
2708
|
+
maxFields: 100
|
|
2709
|
+
};
|
|
2710
|
+
var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
|
|
2711
|
+
/** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
|
|
2712
|
+
function parseBodySize(size) {
|
|
2713
|
+
const match = SIZE_PATTERN.exec(size.trim());
|
|
2714
|
+
if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
|
|
2715
|
+
const value = Number.parseFloat(match[1]);
|
|
2716
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
2717
|
+
switch (unit) {
|
|
2718
|
+
case "kb": return Math.floor(value * KB);
|
|
2719
|
+
case "mb": return Math.floor(value * MB);
|
|
2720
|
+
case "gb": return Math.floor(value * GB);
|
|
2721
|
+
case "": return Math.floor(value);
|
|
2722
|
+
default: throw new Error(`Unknown size unit: "${unit}"`);
|
|
2586
2723
|
}
|
|
2587
|
-
|
|
2724
|
+
}
|
|
2725
|
+
/** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
|
|
2726
|
+
function enforceBodyLimits(req, kind, config) {
|
|
2727
|
+
const contentLength = req.headers.get("Content-Length");
|
|
2728
|
+
if (!contentLength) return {
|
|
2729
|
+
ok: false,
|
|
2730
|
+
status: 411
|
|
2731
|
+
};
|
|
2732
|
+
const bodySize = Number.parseInt(contentLength, 10);
|
|
2733
|
+
if (Number.isNaN(bodySize)) return {
|
|
2734
|
+
ok: false,
|
|
2735
|
+
status: 411
|
|
2736
|
+
};
|
|
2737
|
+
return bodySize <= resolveLimit(kind, config) ? { ok: true } : {
|
|
2738
|
+
ok: false,
|
|
2739
|
+
status: 413
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
/**
|
|
2743
|
+
* Resolve the byte limit for a given body kind, using config overrides or defaults.
|
|
2744
|
+
*/
|
|
2745
|
+
function resolveLimit(kind, config) {
|
|
2746
|
+
const userLimits = config.limits;
|
|
2747
|
+
if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
|
|
2748
|
+
return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
|
|
2588
2749
|
}
|
|
2589
2750
|
//#endregion
|
|
2590
2751
|
//#region src/server/form-data.ts
|
|
@@ -2697,6 +2858,35 @@ var coerce = {
|
|
|
2697
2858
|
} catch {
|
|
2698
2859
|
return;
|
|
2699
2860
|
}
|
|
2861
|
+
},
|
|
2862
|
+
date(value) {
|
|
2863
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2864
|
+
if (value instanceof Date) return value;
|
|
2865
|
+
if (typeof value !== "string") return void 0;
|
|
2866
|
+
const date = new Date(value);
|
|
2867
|
+
if (Number.isNaN(date.getTime())) return void 0;
|
|
2868
|
+
const ymdMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
2869
|
+
if (ymdMatch) {
|
|
2870
|
+
const inputYear = Number(ymdMatch[1]);
|
|
2871
|
+
const inputMonth = Number(ymdMatch[2]);
|
|
2872
|
+
const inputDay = Number(ymdMatch[3]);
|
|
2873
|
+
const isUTC = value.length === 10 || value.endsWith("Z");
|
|
2874
|
+
const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();
|
|
2875
|
+
const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
|
|
2876
|
+
const parsedDay = isUTC ? date.getUTCDate() : date.getDate();
|
|
2877
|
+
if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) return;
|
|
2878
|
+
}
|
|
2879
|
+
return date;
|
|
2880
|
+
},
|
|
2881
|
+
file(options) {
|
|
2882
|
+
return (value) => {
|
|
2883
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2884
|
+
if (!(value instanceof File)) return void 0;
|
|
2885
|
+
if (value.size === 0 && value.name === "") return void 0;
|
|
2886
|
+
if (options?.maxSize !== void 0 && value.size > options.maxSize) return;
|
|
2887
|
+
if (options?.accept !== void 0 && !options.accept.includes(value.type)) return;
|
|
2888
|
+
return value;
|
|
2889
|
+
};
|
|
2700
2890
|
}
|
|
2701
2891
|
};
|
|
2702
2892
|
//#endregion
|
|
@@ -3195,6 +3385,6 @@ function mergeResponseHeaders(res, ctxHeaders) {
|
|
|
3195
3385
|
});
|
|
3196
3386
|
}
|
|
3197
3387
|
//#endregion
|
|
3198
|
-
export { AccessGate, ActionError, DEFAULT_LIMITS, DenySignal, METADATA_ROUTE_CONVENTIONS, RedirectSignal, RedirectType, RenderError, SlotAccessGate, WarningId, addSpanEvent, buildElementTree, buildNoJsResponse, callOnRequestError, canonicalize, classifyMetadataRoute, coerce, collectEarlyHintHeaders, cookies, createActionClient, createPipeline, deny, enforceBodyLimits, executeAction, flushResponse, formatLinkHeader, generateTraceId, getFormFlash, getLogger, getMetadataRouteAutoLink, getMetadataRouteServePath, getSetCookieHeaders, handleRouteRequest, hasOnRequestError, headers, isRscActionRequest, loadInstrumentation, logCacheMiss, logMiddlewareError, logMiddlewareShortCircuit, logProxyError, logRenderError, logRequestCompleted, logRequestReceived, logSlowRequest, logSwrRefetchFailed, logWaitUntilRejected, logWaitUntilUnsupported, markResponseFlushed, notFound, parseBodySize, parseFormData, permanentRedirect, redirect, redirectExternal, renderMetadataToElements, replaceTraceId, resolveAllowedMethods, resolveMetadata, resolveMetadataUrls, resolveSlotDenied, resolveStatusFile, resolveTitle, revalidatePath, revalidateTag, runMiddleware, runProxy, runWithEarlyHintsSender, runWithRequestContext, runWithTraceId,
|
|
3388
|
+
export { AccessGate, ActionError, DEFAULT_LIMITS, DenySignal, METADATA_ROUTE_CONVENTIONS, RedirectSignal, RedirectType, RenderError, SlotAccessGate, WarningId, addSpanEvent, buildElementTree, buildNoJsResponse, callOnRequestError, canonicalize, classifyMetadataRoute, coerce, collectEarlyHintHeaders, cookies, createActionClient, createPipeline, deny, enforceBodyLimits, executeAction, flushResponse, formatLinkHeader, generateTraceId, getFormFlash, getLogger, getMetadataRouteAutoLink, getMetadataRouteServePath, getSetCookieHeaders, handleRouteRequest, hasOnRequestError, headers, isRscActionRequest, loadInstrumentation, logCacheMiss, logMiddlewareError, logMiddlewareShortCircuit, logProxyError, logRenderError, logRequestCompleted, logRequestReceived, logSlowRequest, logSwrRefetchFailed, logWaitUntilRejected, logWaitUntilUnsupported, markResponseFlushed, notFound, parseBodySize, parseFormData, permanentRedirect, rawSearchParams, redirect, redirectExternal, renderMetadataToElements, replaceTraceId, resolveAllowedMethods, resolveMetadata, resolveMetadataUrls, resolveSlotDenied, resolveStatusFile, resolveTitle, revalidatePath, revalidateTag, runMiddleware, runProxy, runWithEarlyHintsSender, runWithRequestContext, runWithTraceId, sendEarlyHints103, setLogger, setMutableCookieContext, setViteServer, spanId, traceId, validateCsrf, validated, waitUntil, warnCacheRequestProps, warnDenyAfterFlush, warnDenyInSuspense, warnDynamicApiInStaticBuild, warnRedirectInAccess, warnRedirectInSlotAccess, warnRedirectInSuspense, warnSlowSlotWithoutSuspense, warnStaticRequestApi, warnSuspenseWrappingChildren, withSpan };
|
|
3199
3389
|
|
|
3200
3390
|
//# sourceMappingURL=index.js.map
|