@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.
Files changed (230) hide show
  1. package/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
  2. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
  3. package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
  4. package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
  5. package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
  6. package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
  7. package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
  8. package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
  9. package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
  10. package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
  11. package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
  12. package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
  13. package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
  14. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
  15. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  16. package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
  17. package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
  18. package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
  19. package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
  20. package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
  21. package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
  22. package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
  23. package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
  24. package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
  25. package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
  26. package/dist/_chunks/wrappers-C1SN725w.js +331 -0
  27. package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
  28. package/dist/cache/index.js +1 -1
  29. package/dist/client/error-boundary.d.ts +10 -1
  30. package/dist/client/error-boundary.d.ts.map +1 -1
  31. package/dist/client/error-boundary.js +1 -125
  32. package/dist/client/index.d.ts +2 -2
  33. package/dist/client/index.d.ts.map +1 -1
  34. package/dist/client/index.js +193 -90
  35. package/dist/client/index.js.map +1 -1
  36. package/dist/client/link.d.ts +8 -8
  37. package/dist/client/link.d.ts.map +1 -1
  38. package/dist/client/navigation-context.d.ts +2 -2
  39. package/dist/client/router.d.ts +25 -3
  40. package/dist/client/router.d.ts.map +1 -1
  41. package/dist/client/rsc-fetch.d.ts +23 -2
  42. package/dist/client/rsc-fetch.d.ts.map +1 -1
  43. package/dist/client/segment-cache.d.ts +1 -1
  44. package/dist/client/segment-cache.d.ts.map +1 -1
  45. package/dist/client/stale-reload.d.ts +15 -0
  46. package/dist/client/stale-reload.d.ts.map +1 -1
  47. package/dist/client/top-loader.d.ts +1 -1
  48. package/dist/client/top-loader.d.ts.map +1 -1
  49. package/dist/client/use-params.d.ts +2 -2
  50. package/dist/client/use-params.d.ts.map +1 -1
  51. package/dist/client/use-query-states.d.ts +1 -1
  52. package/dist/codec.d.ts +21 -0
  53. package/dist/codec.d.ts.map +1 -0
  54. package/dist/cookies/define-cookie.d.ts +33 -12
  55. package/dist/cookies/define-cookie.d.ts.map +1 -1
  56. package/dist/cookies/index.js +1 -81
  57. package/dist/index.d.ts +87 -12
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +346 -210
  60. package/dist/index.js.map +1 -1
  61. package/dist/params/define.d.ts +76 -0
  62. package/dist/params/define.d.ts.map +1 -0
  63. package/dist/params/index.d.ts +8 -0
  64. package/dist/params/index.d.ts.map +1 -0
  65. package/dist/params/index.js +104 -0
  66. package/dist/params/index.js.map +1 -0
  67. package/dist/plugins/adapter-build.d.ts.map +1 -1
  68. package/dist/plugins/build-manifest.d.ts.map +1 -1
  69. package/dist/plugins/client-chunks.d.ts +32 -0
  70. package/dist/plugins/client-chunks.d.ts.map +1 -0
  71. package/dist/plugins/entries.d.ts.map +1 -1
  72. package/dist/plugins/routing.d.ts.map +1 -1
  73. package/dist/plugins/server-bundle.d.ts.map +1 -1
  74. package/dist/plugins/static-build.d.ts.map +1 -1
  75. package/dist/routing/codegen.d.ts +2 -2
  76. package/dist/routing/codegen.d.ts.map +1 -1
  77. package/dist/routing/index.js +1 -1
  78. package/dist/routing/scanner.d.ts.map +1 -1
  79. package/dist/routing/status-file-lint.d.ts +2 -1
  80. package/dist/routing/status-file-lint.d.ts.map +1 -1
  81. package/dist/routing/types.d.ts +6 -4
  82. package/dist/routing/types.d.ts.map +1 -1
  83. package/dist/rsc-runtime/rsc.d.ts +1 -1
  84. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  85. package/dist/search-params/codecs.d.ts +1 -1
  86. package/dist/search-params/define.d.ts +153 -0
  87. package/dist/search-params/define.d.ts.map +1 -0
  88. package/dist/search-params/index.d.ts +4 -5
  89. package/dist/search-params/index.d.ts.map +1 -1
  90. package/dist/search-params/index.js +3 -474
  91. package/dist/search-params/registry.d.ts +1 -1
  92. package/dist/search-params/wrappers.d.ts +53 -0
  93. package/dist/search-params/wrappers.d.ts.map +1 -0
  94. package/dist/server/access-gate.d.ts +4 -0
  95. package/dist/server/access-gate.d.ts.map +1 -1
  96. package/dist/server/action-encryption.d.ts +76 -0
  97. package/dist/server/action-encryption.d.ts.map +1 -0
  98. package/dist/server/action-handler.d.ts.map +1 -1
  99. package/dist/server/als-registry.d.ts +4 -4
  100. package/dist/server/als-registry.d.ts.map +1 -1
  101. package/dist/server/build-manifest.d.ts +2 -2
  102. package/dist/server/early-hints.d.ts +13 -5
  103. package/dist/server/early-hints.d.ts.map +1 -1
  104. package/dist/server/error-boundary-wrapper.d.ts +4 -0
  105. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  106. package/dist/server/flight-injection-state.d.ts +78 -0
  107. package/dist/server/flight-injection-state.d.ts.map +1 -0
  108. package/dist/server/form-data.d.ts +29 -0
  109. package/dist/server/form-data.d.ts.map +1 -1
  110. package/dist/server/html-injectors.d.ts.map +1 -1
  111. package/dist/server/index.d.ts +1 -1
  112. package/dist/server/index.d.ts.map +1 -1
  113. package/dist/server/index.js +1819 -1629
  114. package/dist/server/index.js.map +1 -1
  115. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  116. package/dist/server/pipeline.d.ts.map +1 -1
  117. package/dist/server/request-context.d.ts +28 -40
  118. package/dist/server/request-context.d.ts.map +1 -1
  119. package/dist/server/route-element-builder.d.ts +7 -0
  120. package/dist/server/route-element-builder.d.ts.map +1 -1
  121. package/dist/server/route-matcher.d.ts +2 -2
  122. package/dist/server/route-matcher.d.ts.map +1 -1
  123. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  124. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  125. package/dist/server/slot-resolver.d.ts.map +1 -1
  126. package/dist/server/ssr-entry.d.ts.map +1 -1
  127. package/dist/server/ssr-render.d.ts +3 -0
  128. package/dist/server/ssr-render.d.ts.map +1 -1
  129. package/dist/server/tree-builder.d.ts +12 -8
  130. package/dist/server/tree-builder.d.ts.map +1 -1
  131. package/dist/server/types.d.ts +1 -3
  132. package/dist/server/types.d.ts.map +1 -1
  133. package/dist/server/version-skew.d.ts +61 -0
  134. package/dist/server/version-skew.d.ts.map +1 -0
  135. package/dist/shims/navigation-client.d.ts +1 -1
  136. package/dist/shims/navigation-client.d.ts.map +1 -1
  137. package/dist/shims/navigation.d.ts +1 -1
  138. package/dist/shims/navigation.d.ts.map +1 -1
  139. package/dist/utils/state-machine.d.ts +80 -0
  140. package/dist/utils/state-machine.d.ts.map +1 -0
  141. package/package.json +12 -8
  142. package/src/client/browser-entry.ts +55 -13
  143. package/src/client/error-boundary.tsx +18 -1
  144. package/src/client/index.ts +9 -1
  145. package/src/client/link.tsx +9 -9
  146. package/src/client/navigation-context.ts +2 -2
  147. package/src/client/router.ts +102 -55
  148. package/src/client/rsc-fetch.ts +63 -2
  149. package/src/client/segment-cache.ts +1 -1
  150. package/src/client/stale-reload.ts +28 -0
  151. package/src/client/top-loader.tsx +2 -2
  152. package/src/client/use-params.ts +3 -3
  153. package/src/client/use-query-states.ts +1 -1
  154. package/src/codec.ts +21 -0
  155. package/src/cookies/define-cookie.ts +69 -18
  156. package/src/index.ts +255 -65
  157. package/src/params/define.ts +260 -0
  158. package/src/params/index.ts +28 -0
  159. package/src/plugins/adapter-build.ts +6 -0
  160. package/src/plugins/build-manifest.ts +11 -0
  161. package/src/plugins/client-chunks.ts +65 -0
  162. package/src/plugins/entries.ts +3 -6
  163. package/src/plugins/routing.ts +40 -14
  164. package/src/plugins/server-bundle.ts +32 -1
  165. package/src/plugins/shims.ts +1 -1
  166. package/src/plugins/static-build.ts +8 -4
  167. package/src/routing/codegen.ts +109 -88
  168. package/src/routing/scanner.ts +55 -6
  169. package/src/routing/status-file-lint.ts +2 -1
  170. package/src/routing/types.ts +7 -4
  171. package/src/rsc-runtime/rsc.ts +2 -0
  172. package/src/search-params/codecs.ts +1 -1
  173. package/src/search-params/define.ts +504 -0
  174. package/src/search-params/index.ts +12 -18
  175. package/src/search-params/registry.ts +1 -1
  176. package/src/search-params/wrappers.ts +85 -0
  177. package/src/server/access-gate.tsx +38 -8
  178. package/src/server/action-encryption.ts +144 -0
  179. package/src/server/action-handler.ts +16 -0
  180. package/src/server/als-registry.ts +4 -4
  181. package/src/server/build-manifest.ts +4 -4
  182. package/src/server/early-hints.ts +36 -15
  183. package/src/server/error-boundary-wrapper.ts +57 -14
  184. package/src/server/flight-injection-state.ts +152 -0
  185. package/src/server/form-data.ts +76 -0
  186. package/src/server/html-injectors.ts +42 -26
  187. package/src/server/index.ts +2 -4
  188. package/src/server/node-stream-transforms.ts +68 -41
  189. package/src/server/pipeline.ts +98 -26
  190. package/src/server/request-context.ts +49 -124
  191. package/src/server/route-element-builder.ts +102 -99
  192. package/src/server/route-matcher.ts +2 -2
  193. package/src/server/rsc-entry/error-renderer.ts +3 -2
  194. package/src/server/rsc-entry/index.ts +26 -11
  195. package/src/server/rsc-entry/rsc-payload.ts +2 -2
  196. package/src/server/rsc-entry/ssr-renderer.ts +4 -4
  197. package/src/server/slot-resolver.ts +204 -206
  198. package/src/server/ssr-entry.ts +3 -1
  199. package/src/server/ssr-render.ts +3 -0
  200. package/src/server/tree-builder.ts +84 -48
  201. package/src/server/types.ts +1 -3
  202. package/src/server/version-skew.ts +104 -0
  203. package/src/shims/navigation-client.ts +1 -1
  204. package/src/shims/navigation.ts +1 -1
  205. package/src/utils/state-machine.ts +111 -0
  206. package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
  207. package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
  208. package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
  209. package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
  210. package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
  211. package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
  212. package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
  213. package/dist/client/error-boundary.js.map +0 -1
  214. package/dist/cookies/index.js.map +0 -1
  215. package/dist/plugins/dynamic-transform.d.ts +0 -72
  216. package/dist/plugins/dynamic-transform.d.ts.map +0 -1
  217. package/dist/search-params/analyze.d.ts +0 -54
  218. package/dist/search-params/analyze.d.ts.map +0 -1
  219. package/dist/search-params/builtin-codecs.d.ts +0 -105
  220. package/dist/search-params/builtin-codecs.d.ts.map +0 -1
  221. package/dist/search-params/create.d.ts +0 -106
  222. package/dist/search-params/create.d.ts.map +0 -1
  223. package/dist/search-params/index.js.map +0 -1
  224. package/dist/server/prerender.d.ts +0 -77
  225. package/dist/server/prerender.d.ts.map +0 -1
  226. package/src/plugins/dynamic-transform.ts +0 -161
  227. package/src/search-params/analyze.ts +0 -192
  228. package/src/search-params/builtin-codecs.ts +0 -228
  229. package/src/search-params/create.ts +0 -321
  230. package/src/server/prerender.ts +0 -139
@@ -1,10 +1,13 @@
1
- import { n as isDevMode, t as isDebug } from "../_chunks/debug-B3Gypr3D.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-RyoGQL74.js";
3
- import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-Cjmvi3rQ.js";
4
- import { a as timingAls, i as revalidationAls, n as formFlashAls, s as waitUntilAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-B7DbZ2hS.js";
5
- import { a as markResponseFlushed, c as setCookieSecrets, i as headers, l as setMutableCookieContext, n as cookies, o as runWithRequestContext, r as getSetCookieHeaders, s as searchParams, t as applyRequestHeaderOverlay, u as setParsedSearchParams } from "../_chunks/request-context-BQUC8PHn.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-CemImE6h.js";
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/pipeline-metadata.ts
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
- * Serve a static metadata file by reading it from disk.
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
- * See design/16-metadata.md §"Metadata Routes"
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
- async function serveStaticMetadataFile(metaMatch) {
728
- const { contentType, file } = metaMatch;
729
- const isText = TEXT_CONTENT_TYPES.has(contentType);
730
- const body = await readFile(file.filePath);
731
- const headers = {
732
- "Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
733
- "Content-Length": String(body.byteLength)
734
- };
735
- return new Response(body, {
736
- status: 200,
737
- headers
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
- * Serialize a sitemap array to XML.
742
- * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
743
- */
744
- function serializeSitemap(entries) {
745
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
746
- let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
747
- if (e.lastModified) {
748
- const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
749
- xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
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
- return null;
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
- * Check if a pathname matches a URL pattern with dynamic segments.
789
+ * Render Twitter Card metadata into head element descriptors.
780
790
  *
781
- * Supports [param] (single segment) and [...param] (one or more segments).
782
- * Static segments must match exactly.
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 pathnameMatchesPattern(pathname, pattern) {
785
- const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
786
- const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
787
- let pi = 0;
788
- for (let i = 0; i < patternParts.length; i++) {
789
- const segment = patternParts[i];
790
- if (segment.startsWith("[...") || segment.startsWith("[[...")) return pi < pathParts.length || segment.startsWith("[[...");
791
- if (segment.startsWith("[") && segment.endsWith("]")) {
792
- if (pi >= pathParts.length) return false;
793
- pi++;
794
- continue;
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
- return pi === pathParts.length;
800
- }
801
- //#endregion
802
- //#region src/server/pipeline.ts
803
- /**
804
- * Request pipeline — the central dispatch for all timber.js requests.
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
- async function runProxyPhase(req, method, path) {
885
- try {
886
- let proxyExport;
887
- if (config.proxyLoader) proxyExport = (await config.proxyLoader()).default;
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
- let match = matchRoute(canonicalPathname);
931
- let interception;
932
- const sourceUrl = req.headers.get("X-Timber-URL");
933
- if (sourceUrl && config.interceptionRewrites?.length) {
934
- const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
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 (!match) {
944
- if (config.renderNoMatch) {
945
- const responseHeaders = new Headers();
946
- return config.renderNoMatch(req, responseHeaders);
852
+ });
853
+ if (player.streamUrl) elements.push({
854
+ tag: "meta",
855
+ attrs: {
856
+ name: "twitter:player:stream",
857
+ content: player.streamUrl
947
858
  }
948
- return new Response(null, { status: 404 });
949
- }
950
- const responseHeaders = new Headers();
951
- const requestHeaderOverlay = new Headers();
952
- responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
953
- if (earlyHints) try {
954
- await earlyHints(match, req, responseHeaders);
955
- } catch {}
956
- if (match.middleware) {
957
- const ctx = {
958
- req,
959
- requestHeaders: requestHeaderOverlay,
960
- headers: responseHeaders,
961
- params: match.params,
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
- applyRequestHeaderOverlay(requestHeaderOverlay);
989
- } catch (error) {
990
- setMutableCookieContext(false);
991
- if (error instanceof RedirectSignal) {
992
- applyCookieJar(responseHeaders);
993
- if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
994
- responseHeaders.set("X-Timber-Redirect", error.location);
995
- return new Response(null, {
996
- status: 204,
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
- if (error instanceof DenySignal) return new Response(null, { status: error.status });
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
- applyCookieJar(responseHeaders);
1018
- try {
1019
- const renderFn = () => render(req, match, responseHeaders, requestHeaderOverlay, interception);
1020
- const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => serverTiming === "detailed" ? withTiming("render", "RSC + SSR render", renderFn) : renderFn());
1021
- markResponseFlushed();
1022
- return response;
1023
- } catch (error) {
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
- * Fire the user's onRequestError hook with request context.
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 applyCookieJar(headers) {
1079
- for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
1080
- }
1081
- /**
1082
- * Ensure a Response has mutable headers so the pipeline can safely append
1083
- * Set-Cookie and Server-Timing entries.
1084
- *
1085
- * `Response.redirect()` and some platform-level responses return objects
1086
- * with immutable headers. Calling `.set()` or `.append()` on them throws
1087
- * `TypeError: immutable`. This helper detects the immutable case by
1088
- * attempting a no-op write and, on failure, clones into a fresh Response
1089
- * with mutable headers.
1090
- */
1091
- function ensureMutableResponse(response) {
1092
- try {
1093
- response.headers.set("X-Timber-Probe", "1");
1094
- response.headers.delete("X-Timber-Probe");
1095
- return response;
1096
- } catch {
1097
- return new Response(response.body, {
1098
- status: response.status,
1099
- statusText: response.statusText,
1100
- headers: new Headers(response.headers)
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
- * Collect all CSS files needed for a matched route's segment chain.
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 collectRouteCss(segments, manifest) {
1113
- const seen = /* @__PURE__ */ new Set();
1114
- const result = [];
1115
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
1116
- if (!file) continue;
1117
- const cssFiles = manifest.css[file.filePath];
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
- return result;
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
- * Collect all font entries needed for a matched route's segment chain.
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 collectRouteFonts(segments, manifest) {
1133
- const seen = /* @__PURE__ */ new Set();
1134
- const result = [];
1135
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
1136
- if (!file) continue;
1137
- const fonts = manifest.fonts[file.filePath];
1138
- if (!fonts) continue;
1139
- for (const entry of fonts) if (!seen.has(entry.href)) {
1140
- seen.add(entry.href);
1141
- result.push(entry);
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
- * Collect modulepreload URLs for a matched route's segment chain.
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 collectRouteModulepreloads(segments, manifest) {
1153
- const seen = /* @__PURE__ */ new Set();
1154
- const result = [];
1155
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
1156
- if (!file) continue;
1157
- const preloads = manifest.modulepreload[file.filePath];
1158
- if (!preloads) continue;
1159
- for (const url of preloads) if (!seen.has(url)) {
1160
- seen.add(url);
1161
- result.push(url);
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
- * Format a single EarlyHint as a Link header value.
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 formatLinkHeader(hint) {
1194
- let value = `<${hint.href}>; rel=${hint.rel}`;
1195
- if (hint.as !== void 0) value += `; as=${hint.as}`;
1196
- if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
1197
- if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
1198
- return value;
1199
- }
1200
- /**
1201
- * Collect all Link header strings for a matched route's segment chain.
1202
- *
1203
- * Walks the build manifest to emit hints for:
1204
- * - CSS stylesheets (rel=preload; as=style)
1205
- * - Font assets (rel=preload; as=font; crossorigin)
1206
- * - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
1207
- *
1208
- * Also emits global CSS from the `_global` manifest key. Route files
1209
- * are server components that don't appear in the client bundle, so
1210
- * per-route CSS keying doesn't work with the RSC plugin. The `_global`
1211
- * key contains all CSS assets from the client build — fine for early
1212
- * hints since they're just prefetch signals.
1213
- *
1214
- * Returns formatted Link header strings, deduplicated, root → leaf order.
1215
- * Returns an empty array in dev mode (manifest is empty).
1216
- */
1217
- function collectEarlyHintHeaders(segments, manifest, options) {
1218
- const result = [];
1219
- const seen = /* @__PURE__ */ new Set();
1220
- const add = (header) => {
1221
- if (!seen.has(header)) {
1222
- seen.add(header);
1223
- result.push(header);
1224
- }
1225
- };
1226
- for (const url of collectRouteCss(segments, manifest)) add(formatLinkHeader({
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
- * Send collected Link headers as a 103 Early Hints response.
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 sendEarlyHints103(links) {
1287
- if (!links.length) return;
1288
- const sender = earlyHintsSenderAls.getStore();
1289
- if (!sender) return;
1290
- try {
1291
- sender(links);
1292
- } catch {}
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/tree-builder.ts
1125
+ //#region src/server/metadata-render.ts
1296
1126
  /**
1297
- * Build the unified element tree from a matched segment chain.
1127
+ * Convert resolved metadata into an array of head element descriptors.
1298
1128
  *
1299
- * Construction is bottom-up:
1300
- * 1. Start with the page component (leaf segment)
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
- * Parallel slots are resolved at each layout level and composed as named props.
1132
+ * The framework's MetadataResolver component consumes these descriptors
1133
+ * and renders them into the <head>.
1307
1134
  */
1308
- async function buildElementTree(config) {
1309
- const { segments, params, searchParams, loadModule, createElement, errorBoundaryComponent } = config;
1310
- if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
1311
- const leaf = segments[segments.length - 1];
1312
- if (leaf.route && !leaf.page) return {
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
- for (let i = segments.length - 1; i >= 0; i--) {
1323
- const segment = segments[i];
1324
- element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
1325
- if (segment.access) {
1326
- const accessFn = (await loadModule(segment.access)).default;
1327
- element = createElement("timber:access-gate", {
1328
- accessFn,
1329
- params,
1330
- searchParams,
1331
- segmentName: segment.segmentName,
1332
- children: element
1333
- });
1334
- }
1335
- if (segment.layout) {
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
- element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
1372
- if (slotNode.access) {
1373
- const accessFn = (await loadModule(slotNode.access)).default;
1374
- const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default;
1375
- element = createElement("timber:slot-access-gate", {
1376
- accessFn,
1377
- params,
1378
- searchParams,
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
- return element;
1391
- }
1392
- /**
1393
- * Wrap an element with error boundaries from a segment's status-code files.
1394
- *
1395
- * Wrapping order (innermost to outermost):
1396
- * 1. Specific status files (503.tsx, 429.tsx, etc.)
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
- for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
1416
- const Component = (await loadModule(file)).default;
1417
- if (Component) element = createElement(errorBoundaryComponent, {
1418
- fallbackComponent: Component,
1419
- status: key === "4xx" ? 400 : 500,
1420
- children: element
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 (segment.error) {
1425
- const ErrorComponent = (await loadModule(segment.error)).default;
1426
- if (ErrorComponent) element = createElement(errorBoundaryComponent, {
1427
- fallbackComponent: ErrorComponent,
1428
- children: element
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
- return element;
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, searchParams, segmentName, verdict, children } = props;
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, searchParams, segmentName, children);
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, searchParams, segmentName, children) {
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, searchParams, deniedFallback, defaultFallback, children } = props;
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 deniedFallback ?? defaultFallback ?? null;
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 deniedFallback ?? defaultFallback ?? null;
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/status-code-resolver.ts
1484
+ //#region src/server/route-element-builder.ts
1524
1485
  /**
1525
- * Maps legacy file convention names to their corresponding HTTP status codes.
1526
- * Only used in the 4xx component fallback chain.
1486
+ * Thrown when a defineSegmentParams codec's parse() fails.
1487
+ * The pipeline catches this and responds with 404.
1527
1488
  */
1528
- var LEGACY_FILE_TO_STATUS = {
1529
- "not-found": 404,
1530
- "forbidden": 403,
1531
- "unauthorized": 401
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
- * Resolve the status-code file to render for a given HTTP status code.
1498
+ * Version Skew Detection graceful recovery when stale clients hit new deployments.
1535
1499
  *
1536
- * Walks the segment chain from leaf to root following the fallback chain
1537
- * defined in design/10-error-handling.md. Returns null if no file is found
1538
- * (caller should render the framework default).
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
- * @param status - The HTTP status code (4xx or 5xx).
1541
- * @param segments - The matched segment chain from root (index 0) to leaf (last).
1542
- * @param format - The response format family ('component' or 'json'). Defaults to 'component'.
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
- function resolveStatusFile(status, segments, format = "component") {
1545
- if (status >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
1546
- if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
1547
- return null;
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
- * 4xx component fallback chain (three separate passes):
1551
- * Pass 1 status files (leaf root): {status}.tsx 4xx.tsx
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 resolve4xx(status, segments) {
1556
- const statusStr = String(status);
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
- * 4xx JSON fallback chain (single pass):
1598
- * Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
1599
- * No legacy compat, no error.tsx JSON chain terminates at category catch-all.
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
- function resolve4xxJson(status, segments) {
1602
- const statusStr = String(status);
1603
- for (let i = segments.length - 1; i >= 0; i--) {
1604
- const segment = segments[i];
1605
- if (!segment.jsonStatusFiles) continue;
1606
- const exact = segment.jsonStatusFiles.get(statusStr);
1607
- if (exact) return {
1608
- file: exact,
1609
- status,
1610
- kind: "exact",
1611
- segmentIndex: i
1612
- };
1613
- const category = segment.jsonStatusFiles.get("4xx");
1614
- if (category) return {
1615
- file: category,
1616
- status,
1617
- kind: "category",
1618
- segmentIndex: i
1619
- };
1620
- }
1621
- return null;
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
- * 5xx component fallback chain (single pass, per-segment):
1625
- * At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
1609
+ * Serialize a sitemap array to XML.
1610
+ * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
1626
1611
  */
1627
- function resolve5xx(status, segments) {
1628
- const statusStr = String(status);
1629
- for (let i = segments.length - 1; i >= 0; i--) {
1630
- const segment = segments[i];
1631
- if (segment.statusFiles) {
1632
- const exact = segment.statusFiles.get(statusStr);
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 (segment.error) return {
1648
- file: segment.error,
1649
- status,
1650
- kind: "error",
1651
- segmentIndex: i
1652
- };
1653
- }
1654
- return null;
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1655
1628
  }
1629
+ //#endregion
1630
+ //#region src/server/pipeline-interception.ts
1656
1631
  /**
1657
- * 5xx JSON fallback chain (single pass):
1658
- * At each segment (leaf → root): {status}.json → 5xx.json
1659
- * No error.tsx equivalent JSON chain terminates at category catch-all.
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 resolve5xxJson(status, segments) {
1662
- const statusStr = String(status);
1663
- for (let i = segments.length - 1; i >= 0; i--) {
1664
- const segment = segments[i];
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
- * Resolve the denial file for a parallel route slot.
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
- * @param slotNode - The segment node for the slot (segmentType === 'slot').
1649
+ * Supports [param] (single segment) and [...param] (one or more segments).
1650
+ * Static segments must match exactly.
1690
1651
  */
1691
- function resolveSlotDenied(slotNode) {
1692
- const slotName = slotNode.segmentName.replace(/^@/, "");
1693
- if (slotNode.denied) return {
1694
- file: slotNode.denied,
1695
- slotName,
1696
- kind: "denied"
1697
- };
1698
- if (slotNode.default) return {
1699
- file: slotNode.default,
1700
- slotName,
1701
- kind: "default"
1702
- };
1703
- return null;
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/flush.ts
1670
+ //#region src/server/pipeline.ts
1707
1671
  /**
1708
- * Flush controller for timber.js rendering.
1672
+ * Request pipeline — the central dispatch for all timber.js requests.
1709
1673
  *
1710
- * Holds the response until `onShellReady` fires, then commits the HTTP status
1711
- * code and flushes the shell. Render-phase signals (deny, redirect, unhandled
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
- * See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
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
- * Execute a render and hold the response until the shell is ready.
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
- * Render-phase signals caught before flush:
1727
- * - `DenySignal` HTTP 4xx with appropriate status code
1728
- * - `RedirectSignal` HTTP 3xx with Location header
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
- * @param renderFn - Function that starts the React render.
1733
- * @param options - Flush configuration.
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 flushResponse(renderFn, options = {}) {
1737
- const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
1738
- let renderResult;
1739
- try {
1740
- renderResult = await renderFn();
1741
- } catch (error) {
1742
- return handleSignal(error, responseHeaders);
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
- * Handle a render-phase signal and produce the correct HTTP response.
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 handleSignal(error, responseHeaders) {
1764
- if (error instanceof RedirectSignal) {
1765
- responseHeaders.set("Location", error.location);
1766
- return {
1767
- response: new Response(null, {
1768
- status: error.status,
1769
- headers: responseHeaders
1770
- }),
1771
- status: error.status,
1772
- isRedirect: true,
1773
- isDenial: false
1774
- };
1775
- }
1776
- if (error instanceof DenySignal) return {
1777
- response: new Response(null, {
1778
- status: error.status,
1779
- headers: responseHeaders
1780
- }),
1781
- status: error.status,
1782
- isRedirect: false,
1783
- isDenial: true
1784
- };
1785
- if (error instanceof RenderError) return {
1786
- response: new Response(null, {
1787
- status: error.status,
1788
- headers: responseHeaders
1789
- }),
1790
- status: error.status,
1791
- isRedirect: false,
1792
- isDenial: false
1793
- };
1794
- console.error("[timber] Unhandled render-phase error:", error);
1795
- return {
1796
- response: new Response(null, {
1797
- status: 500,
1798
- headers: responseHeaders
1799
- }),
1800
- status: 500,
1801
- isRedirect: false,
1802
- isDenial: false
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
- * Validate the Origin header against the request's Host.
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 validateCsrf(req, config) {
1824
- if (SAFE_METHODS.has(req.method)) return { ok: true };
1825
- if (config.csrf === false) return { ok: true };
1826
- const origin = req.headers.get("Origin");
1827
- if (!origin) return {
1828
- ok: false,
1829
- status: 403
1830
- };
1831
- if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
1832
- ok: false,
1833
- status: 403
1834
- };
1835
- const host = req.headers.get("Host");
1836
- if (!host) return {
1837
- ok: false,
1838
- status: 403
1839
- };
1840
- let originHost;
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
- originHost = new URL(origin).host;
1999
+ response.headers.set("X-Timber-Probe", "1");
2000
+ response.headers.delete("X-Timber-Probe");
2001
+ return response;
1843
2002
  } catch {
1844
- return {
1845
- ok: false,
1846
- status: 403
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/body-limits.ts
1856
- var KB = 1024;
1857
- var MB = 1024 * KB;
1858
- var GB = 1024 * MB;
1859
- var DEFAULT_LIMITS = {
1860
- actionBodySize: 1 * MB,
1861
- uploadBodySize: 10 * MB,
1862
- maxFields: 100
1863
- };
1864
- var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
1865
- /** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
1866
- function parseBodySize(size) {
1867
- const match = SIZE_PATTERN.exec(size.trim());
1868
- if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
1869
- const value = Number.parseFloat(match[1]);
1870
- const unit = (match[2] ?? "").toLowerCase();
1871
- switch (unit) {
1872
- case "kb": return Math.floor(value * KB);
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
- /** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
1880
- function enforceBodyLimits(req, kind, config) {
1881
- const contentLength = req.headers.get("Content-Length");
1882
- if (!contentLength) return {
1883
- ok: false,
1884
- status: 411
1885
- };
1886
- const bodySize = Number.parseInt(contentLength, 10);
1887
- if (Number.isNaN(bodySize)) return {
1888
- ok: false,
1889
- status: 411
1890
- };
1891
- return bodySize <= resolveLimit(kind, config) ? { ok: true } : {
1892
- ok: false,
1893
- status: 413
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
- * Resolve the byte limit for a given body kind, using config overrides or defaults.
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 resolveLimit(kind, config) {
1900
- const userLimits = config.limits;
1901
- if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
1902
- return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
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/metadata-social.ts
2073
+ //#region src/server/early-hints.ts
1906
2074
  /**
1907
- * Render Open Graph metadata into head element descriptors.
2075
+ * 103 Early Hints utilities.
1908
2076
  *
1909
- * Handles og:title, og:description, og:image (with dimensions/alt),
1910
- * og:video, og:audio, og:article:author, and other OG properties.
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
- function renderOpenGraph(og, elements) {
1913
- const simpleProps = [
1914
- ["og:title", og.title],
1915
- ["og:description", og.description],
1916
- ["og:url", og.url],
1917
- ["og:site_name", og.siteName],
1918
- ["og:locale", og.locale],
1919
- ["og:type", og.type],
1920
- ["og:article:published_time", og.publishedTime],
1921
- ["og:article:modified_time", og.modifiedTime]
1922
- ];
1923
- for (const [property, content] of simpleProps) if (content) elements.push({
1924
- tag: "meta",
1925
- attrs: {
1926
- property,
1927
- content
1928
- }
1929
- });
1930
- if (og.images) if (typeof og.images === "string") elements.push({
1931
- tag: "meta",
1932
- attrs: {
1933
- property: "og:image",
1934
- content: og.images
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
- if (og.videos) for (const video of og.videos) elements.push({
1971
- tag: "meta",
1972
- attrs: {
1973
- property: "og:video",
1974
- content: video.url
1975
- }
1976
- });
1977
- if (og.audio) for (const audio of og.audio) elements.push({
1978
- tag: "meta",
1979
- attrs: {
1980
- property: "og:audio",
1981
- content: audio.url
1982
- }
1983
- });
1984
- if (og.authors) for (const author of og.authors) elements.push({
1985
- tag: "meta",
1986
- attrs: {
1987
- property: "og:article:author",
1988
- content: author
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
- * Render Twitter Card metadata into head element descriptors.
2170
+ * Per-request 103 Early Hints sender ALS bridge for platform adapters.
1994
2171
  *
1995
- * Handles twitter:card, twitter:site, twitter:title, twitter:image,
1996
- * twitter:player, and twitter:app (per-platform name/id/url).
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
- function renderTwitter(tw, elements) {
1999
- const simpleProps = [
2000
- ["twitter:card", tw.card],
2001
- ["twitter:site", tw.site],
2002
- ["twitter:site:id", tw.siteId],
2003
- ["twitter:title", tw.title],
2004
- ["twitter:description", tw.description],
2005
- ["twitter:creator", tw.creator],
2006
- ["twitter:creator:id", tw.creatorId]
2007
- ];
2008
- for (const [name, content] of simpleProps) if (content) elements.push({
2009
- tag: "meta",
2010
- attrs: {
2011
- name,
2012
- content
2013
- }
2014
- });
2015
- if (tw.images) if (typeof tw.images === "string") elements.push({
2016
- tag: "meta",
2017
- attrs: {
2018
- name: "twitter:image",
2019
- content: tw.images
2020
- }
2021
- });
2022
- else {
2023
- const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
2024
- for (const img of imgList) {
2025
- const url = typeof img === "string" ? img : img.url;
2026
- elements.push({
2027
- tag: "meta",
2028
- attrs: {
2029
- name: "twitter:image",
2030
- content: url
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
- if (tw.players) for (const player of tw.players) {
2036
- elements.push({
2037
- tag: "meta",
2038
- attrs: {
2039
- name: "twitter:player",
2040
- content: player.playerUrl
2041
- }
2042
- });
2043
- if (player.width) elements.push({
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
- * Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
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 renderIcons(icons, elements) {
2108
- if (icons.icon) {
2109
- if (typeof icons.icon === "string") elements.push({
2110
- tag: "link",
2111
- attrs: {
2112
- rel: "icon",
2113
- href: icons.icon
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
- if (icons.shortcut) {
2130
- const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
2131
- for (const url of urls) elements.push({
2132
- tag: "link",
2133
- attrs: {
2134
- rel: "shortcut icon",
2135
- href: url
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
- if (icons.apple) {
2140
- if (typeof icons.apple === "string") elements.push({
2141
- tag: "link",
2142
- attrs: {
2143
- rel: "apple-touch-icon",
2144
- href: icons.apple
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 (icons.other) for (const icon of icons.other) {
2160
- const attrs = {
2161
- rel: icon.rel,
2162
- href: icon.url
2163
- };
2164
- if (icon.sizes) attrs.sizes = icon.sizes;
2165
- if (icon.type) attrs.type = icon.type;
2166
- elements.push({
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
- * Render alternate link elements (canonical, hreflang, media, types).
2371
+ * Maps legacy file convention names to their corresponding HTTP status codes.
2372
+ * Only used in the 4xx component fallback chain.
2174
2373
  */
2175
- function renderAlternates(alternates, elements) {
2176
- if (alternates.canonical) elements.push({
2177
- tag: "link",
2178
- attrs: {
2179
- rel: "canonical",
2180
- href: alternates.canonical
2181
- }
2182
- });
2183
- if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
2184
- tag: "link",
2185
- attrs: {
2186
- rel: "alternate",
2187
- hreflang: lang,
2188
- href
2189
- }
2190
- });
2191
- if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
2192
- tag: "link",
2193
- attrs: {
2194
- rel: "alternate",
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
- * Render site verification meta tags (Google, Yahoo, Yandex, custom).
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 renderVerification(verification, elements) {
2212
- const verificationProps = [
2213
- ["google-site-verification", verification.google],
2214
- ["y_key", verification.yahoo],
2215
- ["yandex-verification", verification.yandex]
2216
- ];
2217
- for (const [name, content] of verificationProps) if (content) elements.push({
2218
- tag: "meta",
2219
- attrs: {
2220
- name,
2221
- content
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
- * Render Apple Web App meta tags and startup image links.
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 renderAppleWebApp(appleWebApp, elements) {
2239
- if (appleWebApp.capable) elements.push({
2240
- tag: "meta",
2241
- attrs: {
2242
- name: "apple-mobile-web-app-capable",
2243
- content: "yes"
2244
- }
2245
- });
2246
- if (appleWebApp.title) elements.push({
2247
- tag: "meta",
2248
- attrs: {
2249
- name: "apple-mobile-web-app-title",
2250
- content: appleWebApp.title
2251
- }
2252
- });
2253
- if (appleWebApp.statusBarStyle) elements.push({
2254
- tag: "meta",
2255
- attrs: {
2256
- name: "apple-mobile-web-app-status-bar-style",
2257
- content: appleWebApp.statusBarStyle
2258
- }
2259
- });
2260
- if (appleWebApp.startupImage) {
2261
- const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
2262
- for (const img of images) {
2263
- const attrs = {
2264
- rel: "apple-touch-startup-image",
2265
- href: typeof img === "string" ? img : img.url
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
- * Render App Links (al:*) meta tags for deep linking across platforms.
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 renderAppLinks(appLinks, elements) {
2279
- const platformEntries = [
2280
- ["ios", appLinks.ios],
2281
- ["android", appLinks.android],
2282
- ["windows", appLinks.windows],
2283
- ["windows_phone", appLinks.windowsPhone],
2284
- ["windows_universal", appLinks.windowsUniversal]
2285
- ];
2286
- for (const [platform, entries] of platformEntries) {
2287
- if (!entries) continue;
2288
- for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
2289
- tag: "meta",
2290
- attrs: {
2291
- property: `al:${platform}:${key}`,
2292
- content: String(value)
2293
- }
2294
- });
2295
- }
2296
- if (appLinks.web) {
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
- * Render Apple iTunes smart banner meta tag.
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
- * Each descriptor has a `tag` ('title', 'meta', 'link') and either
2334
- * `content` (for <title>) or `attrs` (for <meta>/<link>).
2532
+ * Slot denial is graceful degradation no HTTP status on the wire.
2533
+ * Fallback chain: denied.tsx default.tsx null.
2335
2534
  *
2336
- * The framework's MetadataResolver component consumes these descriptors
2337
- * and renders them into the <head>.
2535
+ * @param slotNode - The segment node for the slot (segmentType === 'slot').
2338
2536
  */
2339
- function renderMetadataToElements(metadata) {
2340
- const elements = [];
2341
- if (typeof metadata.title === "string") elements.push({
2342
- tag: "title",
2343
- content: metadata.title
2344
- });
2345
- const simpleMetaProps = [
2346
- ["description", metadata.description],
2347
- ["generator", metadata.generator],
2348
- ["application-name", metadata.applicationName],
2349
- ["referrer", metadata.referrer],
2350
- ["category", metadata.category],
2351
- ["creator", metadata.creator],
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/metadata.ts
2552
+ //#region src/server/flush.ts
2460
2553
  /**
2461
- * Resolve a title value with an optional template.
2554
+ * Flush controller for timber.js rendering.
2462
2555
  *
2463
- * - string apply template if present
2464
- * - { absolute: '...' } use as-is, skip template
2465
- * - { default: '...' } use as fallback (no template applied)
2466
- * - undefined → undefined
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
- * Resolve metadata from a segment chain.
2563
+ * Execute a render and hold the response until the shell is ready.
2476
2564
  *
2477
- * Processes entries from root layout to page (in segment order).
2478
- * The merge algorithm:
2479
- * 1. Shallow-merge all keys except title (later wins)
2480
- * 2. Track the most recent title template
2481
- * 3. Resolve the final title using the template
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
- * In error state, the page entry is dropped and noindex is injected.
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
- * See design/16-metadata.md §"Merge Algorithm"
2578
+ * @param renderFn - Function that starts the React render.
2579
+ * @param options - Flush configuration.
2580
+ * @returns The committed HTTP Response.
2486
2581
  */
2487
- function resolveMetadata(entries, options = {}) {
2488
- const { errorState = false } = options;
2489
- const merged = {};
2490
- let titleTemplate;
2491
- let lastDefault;
2492
- let rawTitle;
2493
- for (const { metadata, isPage } of entries) {
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
- if (errorState) {
2506
- rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
2507
- titleTemplate = void 0;
2590
+ try {
2591
+ await renderResult.shellReady;
2592
+ } catch (error) {
2593
+ return handleSignal(error, responseHeaders);
2508
2594
  }
2509
- const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
2510
- if (resolvedTitle !== void 0) merged.title = resolvedTitle;
2511
- if (errorState) merged.robots = "noindex";
2512
- return merged;
2513
- }
2514
- /**
2515
- * Check if a string is an absolute URL.
2516
- */
2517
- function isAbsoluteUrl(url) {
2518
- return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
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
- * Resolve a relative URL against a base URL.
2607
+ * Handle a render-phase signal and produce the correct HTTP response.
2522
2608
  */
2523
- function resolveUrl(url, base) {
2524
- if (isAbsoluteUrl(url)) return url;
2525
- return new URL(url, base).toString();
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
- * Resolve relative URLs in metadata fields against metadataBase.
2660
+ * Validate the Origin header against the request's Host.
2529
2661
  *
2530
- * Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
2531
- * If metadataBase is not set, returns the metadata unchanged.
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 resolveMetadataUrls(metadata) {
2534
- const base = metadata.metadataBase;
2535
- if (!base) return metadata;
2536
- const result = { ...metadata };
2537
- if (result.openGraph) {
2538
- result.openGraph = { ...result.openGraph };
2539
- if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
2540
- else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
2541
- ...img,
2542
- url: resolveUrl(img.url, base)
2543
- }));
2544
- else if (result.openGraph.images) result.openGraph.images = {
2545
- ...result.openGraph.images,
2546
- url: resolveUrl(result.openGraph.images.url, base)
2547
- };
2548
- if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
2549
- }
2550
- if (result.twitter) {
2551
- result.twitter = { ...result.twitter };
2552
- if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
2553
- else if (Array.isArray(result.twitter.images)) {
2554
- const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
2555
- ...img,
2556
- url: resolveUrl(img.url, base)
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
- if (result.alternates) {
2566
- result.alternates = { ...result.alternates };
2567
- if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
2568
- if (result.alternates.languages) {
2569
- const langs = {};
2570
- for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
2571
- result.alternates.languages = langs;
2572
- }
2573
- }
2574
- if (result.icons) {
2575
- result.icons = { ...result.icons };
2576
- if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
2577
- else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
2578
- ...i,
2579
- url: resolveUrl(i.url, base)
2580
- }));
2581
- if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
2582
- else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
2583
- ...i,
2584
- url: resolveUrl(i.url, base)
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
- return result;
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, searchParams, sendEarlyHints103, setCookieSecrets, setLogger, setMutableCookieContext, setParsedSearchParams, setViteServer, spanId, traceId, validateCsrf, validated, waitUntil, warnCacheRequestProps, warnDenyAfterFlush, warnDenyInSuspense, warnDynamicApiInStaticBuild, warnRedirectInAccess, warnRedirectInSlotAccess, warnRedirectInSuspense, warnSlowSlotWithoutSuspense, warnStaticRequestApi, warnSuspenseWrappingChildren, withSpan };
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