@timber-js/app 0.2.0-alpha.4 → 0.2.0-alpha.41

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 (336) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
  3. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
  4. package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
  5. package/dist/_chunks/debug-ECi_61pb.js +108 -0
  6. package/dist/_chunks/debug-ECi_61pb.js.map +1 -0
  7. package/dist/_chunks/define-cookie-BmKbSyp0.js +93 -0
  8. package/dist/_chunks/define-cookie-BmKbSyp0.js.map +1 -0
  9. package/dist/_chunks/error-boundary-BAN3751q.js +211 -0
  10. package/dist/_chunks/error-boundary-BAN3751q.js.map +1 -0
  11. package/dist/_chunks/{format-CwdaB0_2.js → format-cX7wzEp2.js} +2 -2
  12. package/dist/_chunks/{format-CwdaB0_2.js.map → format-cX7wzEp2.js.map} +1 -1
  13. package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
  14. package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
  15. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
  16. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  17. package/dist/_chunks/{request-context-CZJi4CuK.js → request-context-BxYIJM24.js} +93 -69
  18. package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
  19. package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
  20. package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
  21. package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
  22. package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
  23. package/dist/_chunks/{tracing-Cwn7697K.js → tracing-CuXiCP5p.js} +17 -3
  24. package/dist/_chunks/{tracing-Cwn7697K.js.map → tracing-CuXiCP5p.js.map} +1 -1
  25. package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
  26. package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
  27. package/dist/_chunks/wrappers-C6J0nNji.js +331 -0
  28. package/dist/_chunks/wrappers-C6J0nNji.js.map +1 -0
  29. package/dist/adapters/compress-module.d.ts.map +1 -1
  30. package/dist/adapters/nitro.d.ts +17 -1
  31. package/dist/adapters/nitro.d.ts.map +1 -1
  32. package/dist/adapters/nitro.js +56 -13
  33. package/dist/adapters/nitro.js.map +1 -1
  34. package/dist/cache/fast-hash.d.ts +22 -0
  35. package/dist/cache/fast-hash.d.ts.map +1 -0
  36. package/dist/cache/index.d.ts +5 -2
  37. package/dist/cache/index.d.ts.map +1 -1
  38. package/dist/cache/index.js +88 -18
  39. package/dist/cache/index.js.map +1 -1
  40. package/dist/cache/register-cached-function.d.ts.map +1 -1
  41. package/dist/cache/singleflight.d.ts +18 -1
  42. package/dist/cache/singleflight.d.ts.map +1 -1
  43. package/dist/cache/timber-cache.d.ts.map +1 -1
  44. package/dist/client/error-boundary.d.ts +10 -1
  45. package/dist/client/error-boundary.d.ts.map +1 -1
  46. package/dist/client/error-boundary.js +1 -125
  47. package/dist/client/index.d.ts +3 -2
  48. package/dist/client/index.d.ts.map +1 -1
  49. package/dist/client/index.js +213 -93
  50. package/dist/client/index.js.map +1 -1
  51. package/dist/client/link.d.ts +22 -8
  52. package/dist/client/link.d.ts.map +1 -1
  53. package/dist/client/navigation-context.d.ts +2 -2
  54. package/dist/client/router.d.ts +25 -3
  55. package/dist/client/router.d.ts.map +1 -1
  56. package/dist/client/rsc-fetch.d.ts +23 -2
  57. package/dist/client/rsc-fetch.d.ts.map +1 -1
  58. package/dist/client/segment-cache.d.ts +1 -1
  59. package/dist/client/segment-cache.d.ts.map +1 -1
  60. package/dist/client/segment-context.d.ts +1 -1
  61. package/dist/client/segment-context.d.ts.map +1 -1
  62. package/dist/client/segment-merger.d.ts.map +1 -1
  63. package/dist/client/stale-reload.d.ts +15 -0
  64. package/dist/client/stale-reload.d.ts.map +1 -1
  65. package/dist/client/top-loader.d.ts +1 -1
  66. package/dist/client/top-loader.d.ts.map +1 -1
  67. package/dist/client/transition-root.d.ts +1 -1
  68. package/dist/client/transition-root.d.ts.map +1 -1
  69. package/dist/client/use-params.d.ts +2 -2
  70. package/dist/client/use-params.d.ts.map +1 -1
  71. package/dist/client/use-query-states.d.ts +1 -1
  72. package/dist/codec.d.ts +21 -0
  73. package/dist/codec.d.ts.map +1 -0
  74. package/dist/cookies/define-cookie.d.ts +33 -12
  75. package/dist/cookies/define-cookie.d.ts.map +1 -1
  76. package/dist/cookies/index.js +1 -83
  77. package/dist/fonts/css.d.ts +1 -0
  78. package/dist/fonts/css.d.ts.map +1 -1
  79. package/dist/fonts/local.d.ts +4 -2
  80. package/dist/fonts/local.d.ts.map +1 -1
  81. package/dist/index.d.ts +112 -35
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +635 -233
  84. package/dist/index.js.map +1 -1
  85. package/dist/params/define.d.ts +76 -0
  86. package/dist/params/define.d.ts.map +1 -0
  87. package/dist/params/index.d.ts +8 -0
  88. package/dist/params/index.d.ts.map +1 -0
  89. package/dist/params/index.js +104 -0
  90. package/dist/params/index.js.map +1 -0
  91. package/dist/plugins/adapter-build.d.ts.map +1 -1
  92. package/dist/plugins/build-manifest.d.ts.map +1 -1
  93. package/dist/plugins/client-chunks.d.ts +32 -0
  94. package/dist/plugins/client-chunks.d.ts.map +1 -0
  95. package/dist/plugins/dev-error-overlay.d.ts +26 -1
  96. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  97. package/dist/plugins/entries.d.ts +7 -0
  98. package/dist/plugins/entries.d.ts.map +1 -1
  99. package/dist/plugins/fonts.d.ts +9 -1
  100. package/dist/plugins/fonts.d.ts.map +1 -1
  101. package/dist/plugins/mdx.d.ts +6 -0
  102. package/dist/plugins/mdx.d.ts.map +1 -1
  103. package/dist/plugins/routing.d.ts.map +1 -1
  104. package/dist/plugins/server-bundle.d.ts.map +1 -1
  105. package/dist/plugins/static-build.d.ts.map +1 -1
  106. package/dist/routing/codegen.d.ts +2 -2
  107. package/dist/routing/codegen.d.ts.map +1 -1
  108. package/dist/routing/index.js +1 -1
  109. package/dist/routing/scanner.d.ts.map +1 -1
  110. package/dist/routing/status-file-lint.d.ts +2 -1
  111. package/dist/routing/status-file-lint.d.ts.map +1 -1
  112. package/dist/routing/types.d.ts +6 -4
  113. package/dist/routing/types.d.ts.map +1 -1
  114. package/dist/rsc-runtime/rsc.d.ts +1 -1
  115. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  116. package/dist/rsc-runtime/ssr.d.ts +12 -0
  117. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  118. package/dist/search-params/codecs.d.ts +1 -1
  119. package/dist/search-params/define.d.ts +153 -0
  120. package/dist/search-params/define.d.ts.map +1 -0
  121. package/dist/search-params/index.d.ts +4 -5
  122. package/dist/search-params/index.d.ts.map +1 -1
  123. package/dist/search-params/index.js +3 -474
  124. package/dist/search-params/registry.d.ts +1 -1
  125. package/dist/search-params/wrappers.d.ts +53 -0
  126. package/dist/search-params/wrappers.d.ts.map +1 -0
  127. package/dist/server/access-gate.d.ts +4 -0
  128. package/dist/server/access-gate.d.ts.map +1 -1
  129. package/dist/server/action-client.d.ts.map +1 -1
  130. package/dist/server/action-encryption.d.ts +76 -0
  131. package/dist/server/action-encryption.d.ts.map +1 -0
  132. package/dist/server/action-handler.d.ts.map +1 -1
  133. package/dist/server/als-registry.d.ts +18 -4
  134. package/dist/server/als-registry.d.ts.map +1 -1
  135. package/dist/server/build-manifest.d.ts +2 -2
  136. package/dist/server/debug.d.ts +46 -15
  137. package/dist/server/debug.d.ts.map +1 -1
  138. package/dist/server/default-logger.d.ts +22 -0
  139. package/dist/server/default-logger.d.ts.map +1 -0
  140. package/dist/server/deny-renderer.d.ts.map +1 -1
  141. package/dist/server/early-hints.d.ts +13 -5
  142. package/dist/server/early-hints.d.ts.map +1 -1
  143. package/dist/server/error-boundary-wrapper.d.ts +4 -0
  144. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  145. package/dist/server/flight-injection-state.d.ts +78 -0
  146. package/dist/server/flight-injection-state.d.ts.map +1 -0
  147. package/dist/server/flight-scripts.d.ts +39 -0
  148. package/dist/server/flight-scripts.d.ts.map +1 -0
  149. package/dist/server/flush.d.ts.map +1 -1
  150. package/dist/server/form-data.d.ts +29 -0
  151. package/dist/server/form-data.d.ts.map +1 -1
  152. package/dist/server/html-injectors.d.ts +5 -11
  153. package/dist/server/html-injectors.d.ts.map +1 -1
  154. package/dist/server/index.d.ts +4 -2
  155. package/dist/server/index.d.ts.map +1 -1
  156. package/dist/server/index.js +1975 -1649
  157. package/dist/server/index.js.map +1 -1
  158. package/dist/server/logger.d.ts +24 -7
  159. package/dist/server/logger.d.ts.map +1 -1
  160. package/dist/server/node-stream-transforms.d.ts +77 -0
  161. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  162. package/dist/server/pipeline.d.ts +7 -4
  163. package/dist/server/pipeline.d.ts.map +1 -1
  164. package/dist/server/primitives.d.ts +30 -3
  165. package/dist/server/primitives.d.ts.map +1 -1
  166. package/dist/server/render-timeout.d.ts +51 -0
  167. package/dist/server/render-timeout.d.ts.map +1 -0
  168. package/dist/server/request-context.d.ts +65 -38
  169. package/dist/server/request-context.d.ts.map +1 -1
  170. package/dist/server/route-element-builder.d.ts +7 -0
  171. package/dist/server/route-element-builder.d.ts.map +1 -1
  172. package/dist/server/route-handler.d.ts.map +1 -1
  173. package/dist/server/route-matcher.d.ts +2 -2
  174. package/dist/server/route-matcher.d.ts.map +1 -1
  175. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/helpers.d.ts +46 -3
  177. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  178. package/dist/server/rsc-entry/index.d.ts +6 -1
  179. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  180. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  181. package/dist/server/rsc-entry/rsc-stream.d.ts +9 -0
  182. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  183. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  184. package/dist/server/slot-resolver.d.ts +1 -1
  185. package/dist/server/slot-resolver.d.ts.map +1 -1
  186. package/dist/server/ssr-entry.d.ts +22 -0
  187. package/dist/server/ssr-entry.d.ts.map +1 -1
  188. package/dist/server/ssr-render.d.ts +39 -21
  189. package/dist/server/ssr-render.d.ts.map +1 -1
  190. package/dist/server/tracing.d.ts +10 -0
  191. package/dist/server/tracing.d.ts.map +1 -1
  192. package/dist/server/tree-builder.d.ts +19 -12
  193. package/dist/server/tree-builder.d.ts.map +1 -1
  194. package/dist/server/types.d.ts +1 -3
  195. package/dist/server/types.d.ts.map +1 -1
  196. package/dist/server/version-skew.d.ts +61 -0
  197. package/dist/server/version-skew.d.ts.map +1 -0
  198. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  199. package/dist/shared/merge-search-params.d.ts +22 -0
  200. package/dist/shared/merge-search-params.d.ts.map +1 -0
  201. package/dist/shims/navigation-client.d.ts +1 -1
  202. package/dist/shims/navigation-client.d.ts.map +1 -1
  203. package/dist/shims/navigation.d.ts +1 -1
  204. package/dist/shims/navigation.d.ts.map +1 -1
  205. package/dist/utils/state-machine.d.ts +80 -0
  206. package/dist/utils/state-machine.d.ts.map +1 -0
  207. package/package.json +17 -14
  208. package/src/adapters/compress-module.ts +24 -4
  209. package/src/adapters/nitro.ts +58 -9
  210. package/src/cache/fast-hash.ts +34 -0
  211. package/src/cache/index.ts +5 -2
  212. package/src/cache/register-cached-function.ts +7 -3
  213. package/src/cache/singleflight.ts +62 -4
  214. package/src/cache/timber-cache.ts +34 -26
  215. package/src/cli.ts +0 -0
  216. package/src/client/browser-entry.ts +94 -90
  217. package/src/client/error-boundary.tsx +18 -1
  218. package/src/client/index.ts +10 -1
  219. package/src/client/link.tsx +78 -19
  220. package/src/client/navigation-context.ts +2 -2
  221. package/src/client/router.ts +105 -60
  222. package/src/client/rsc-fetch.ts +63 -2
  223. package/src/client/segment-cache.ts +1 -1
  224. package/src/client/segment-context.ts +6 -1
  225. package/src/client/segment-merger.ts +2 -8
  226. package/src/client/stale-reload.ts +32 -6
  227. package/src/client/top-loader.tsx +10 -9
  228. package/src/client/transition-root.tsx +7 -1
  229. package/src/client/use-params.ts +3 -3
  230. package/src/client/use-query-states.ts +1 -1
  231. package/src/codec.ts +21 -0
  232. package/src/cookies/define-cookie.ts +69 -18
  233. package/src/fonts/css.ts +2 -1
  234. package/src/fonts/local.ts +7 -3
  235. package/src/index.ts +280 -85
  236. package/src/params/define.ts +260 -0
  237. package/src/params/index.ts +28 -0
  238. package/src/plugins/adapter-build.ts +6 -0
  239. package/src/plugins/build-manifest.ts +11 -0
  240. package/src/plugins/client-chunks.ts +65 -0
  241. package/src/plugins/dev-error-overlay.ts +70 -1
  242. package/src/plugins/dev-server.ts +38 -4
  243. package/src/plugins/entries.ts +12 -11
  244. package/src/plugins/fonts.ts +171 -19
  245. package/src/plugins/mdx.ts +9 -5
  246. package/src/plugins/routing.ts +40 -14
  247. package/src/plugins/server-bundle.ts +32 -1
  248. package/src/plugins/shims.ts +1 -1
  249. package/src/plugins/static-build.ts +8 -4
  250. package/src/routing/codegen.ts +109 -88
  251. package/src/routing/scanner.ts +55 -6
  252. package/src/routing/status-file-lint.ts +2 -1
  253. package/src/routing/types.ts +7 -4
  254. package/src/rsc-runtime/rsc.ts +2 -0
  255. package/src/rsc-runtime/ssr.ts +50 -0
  256. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  257. package/src/search-params/codecs.ts +1 -1
  258. package/src/search-params/define.ts +504 -0
  259. package/src/search-params/index.ts +12 -18
  260. package/src/search-params/registry.ts +1 -1
  261. package/src/search-params/wrappers.ts +85 -0
  262. package/src/server/access-gate.tsx +40 -9
  263. package/src/server/action-client.ts +14 -5
  264. package/src/server/action-encryption.ts +144 -0
  265. package/src/server/action-handler.ts +19 -2
  266. package/src/server/als-registry.ts +18 -4
  267. package/src/server/build-manifest.ts +4 -4
  268. package/src/server/compress.ts +25 -7
  269. package/src/server/debug.ts +55 -17
  270. package/src/server/default-logger.ts +98 -0
  271. package/src/server/deny-renderer.ts +2 -1
  272. package/src/server/early-hints.ts +36 -15
  273. package/src/server/error-boundary-wrapper.ts +57 -14
  274. package/src/server/flight-injection-state.ts +152 -0
  275. package/src/server/flight-scripts.ts +59 -0
  276. package/src/server/flush.ts +2 -1
  277. package/src/server/form-data.ts +76 -0
  278. package/src/server/html-injectors.ts +103 -66
  279. package/src/server/index.ts +9 -4
  280. package/src/server/logger.ts +38 -35
  281. package/src/server/node-stream-transforms.ts +381 -0
  282. package/src/server/pipeline.ts +131 -39
  283. package/src/server/primitives.ts +47 -5
  284. package/src/server/render-timeout.ts +108 -0
  285. package/src/server/request-context.ts +112 -119
  286. package/src/server/route-element-builder.ts +106 -114
  287. package/src/server/route-handler.ts +2 -1
  288. package/src/server/route-matcher.ts +2 -2
  289. package/src/server/rsc-entry/error-renderer.ts +5 -3
  290. package/src/server/rsc-entry/helpers.ts +122 -3
  291. package/src/server/rsc-entry/index.ts +125 -49
  292. package/src/server/rsc-entry/rsc-payload.ts +52 -12
  293. package/src/server/rsc-entry/rsc-stream.ts +33 -8
  294. package/src/server/rsc-entry/ssr-renderer.ts +40 -13
  295. package/src/server/slot-resolver.ts +199 -210
  296. package/src/server/ssr-entry.ts +168 -22
  297. package/src/server/ssr-render.ts +289 -67
  298. package/src/server/tracing.ts +23 -0
  299. package/src/server/tree-builder.ts +91 -57
  300. package/src/server/types.ts +1 -3
  301. package/src/server/version-skew.ts +104 -0
  302. package/src/server/waituntil-bridge.ts +4 -1
  303. package/src/shared/merge-search-params.ts +48 -0
  304. package/src/shims/navigation-client.ts +1 -1
  305. package/src/shims/navigation.ts +1 -1
  306. package/src/utils/state-machine.ts +111 -0
  307. package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
  308. package/dist/_chunks/debug-B4WUeqJ-.js +0 -75
  309. package/dist/_chunks/debug-B4WUeqJ-.js.map +0 -1
  310. package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
  311. package/dist/_chunks/request-context-CZJi4CuK.js.map +0 -1
  312. package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
  313. package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
  314. package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
  315. package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
  316. package/dist/client/error-boundary.js.map +0 -1
  317. package/dist/cookies/index.js.map +0 -1
  318. package/dist/plugins/dynamic-transform.d.ts +0 -72
  319. package/dist/plugins/dynamic-transform.d.ts.map +0 -1
  320. package/dist/search-params/analyze.d.ts +0 -54
  321. package/dist/search-params/analyze.d.ts.map +0 -1
  322. package/dist/search-params/builtin-codecs.d.ts +0 -105
  323. package/dist/search-params/builtin-codecs.d.ts.map +0 -1
  324. package/dist/search-params/create.d.ts +0 -106
  325. package/dist/search-params/create.d.ts.map +0 -1
  326. package/dist/search-params/index.js.map +0 -1
  327. package/dist/server/prerender.d.ts +0 -77
  328. package/dist/server/prerender.d.ts.map +0 -1
  329. package/dist/server/response-cache.d.ts +0 -53
  330. package/dist/server/response-cache.d.ts.map +0 -1
  331. package/src/plugins/dynamic-transform.ts +0 -161
  332. package/src/search-params/analyze.ts +0 -192
  333. package/src/search-params/builtin-codecs.ts +0 -228
  334. package/src/search-params/create.ts +0 -321
  335. package/src/server/prerender.ts +0 -139
  336. package/src/server/response-cache.ts +0 -277
@@ -1,10 +1,13 @@
1
- import { t as isDebug } from "../_chunks/debug-B4WUeqJ-.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-CwdaB0_2.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-CZJi4CuK.js";
6
- import { a as replaceTraceId, c as spanId, i as getTraceStore, l as traceId, n as generateTraceId, o as runWithTraceId, r as getOtelTraceId, s as setSpanAttribute, t as addSpanEvent, u as withSpan } from "../_chunks/tracing-Cwn7697K.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 headers, c as rawSegmentParams, d as setMutableCookieContext, f as setSegmentParams, i as getSetCookieHeaders, n as cookies, o as markResponseFlushed, r as getRequestSearchString, s as rawSearchParams, t as applyRequestHeaderOverlay, u as runWithRequestContext } from "../_chunks/request-context-BxYIJM24.js";
6
+ import { r as mergePreservedSearchParams } from "../_chunks/segment-context-C6byCyZU.js";
7
+ 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-CuXiCP5p.js";
8
+ import "../_chunks/error-boundary-BAN3751q.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.
@@ -170,12 +173,26 @@ var ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
170
173
  * Use `redirectExternal()` for external redirects with an allow-list.
171
174
  *
172
175
  * @param path - Relative path (e.g. '/login', 'settings', '/login?returnTo=/dash')
173
- * @param status - HTTP redirect status code (3xx). Defaults to 302.
176
+ * @param statusOrOptions - HTTP status code (3xx, default 302) or options object.
177
+ *
178
+ * @example
179
+ * // Simple redirect
180
+ * redirect('/login');
181
+ *
182
+ * // With status code
183
+ * redirect('/login', 301);
184
+ *
185
+ * // With preserved search params
186
+ * redirect(`/docs/${version}/${slug}`, { preserveSearchParams: ['foo'] });
174
187
  */
175
- function redirect(path, status = 302) {
188
+ function redirect(path, statusOrOptions) {
189
+ const status = typeof statusOrOptions === "number" ? statusOrOptions : statusOrOptions?.status ?? 302;
190
+ const preserveSearchParams = typeof statusOrOptions === "object" ? statusOrOptions.preserveSearchParams : void 0;
176
191
  if (status < 300 || status > 399) throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
177
192
  if (ABSOLUTE_URL_RE.test(path)) throw new Error(`redirect() only accepts relative URLs. Got absolute URL: "${path}". Use redirectExternal(url, allowList) for external redirects.`);
178
- throw new RedirectSignal(path, status);
193
+ let resolvedPath = path;
194
+ if (preserveSearchParams) resolvedPath = mergePreservedSearchParams(path, getRequestSearchString(), preserveSearchParams);
195
+ throw new RedirectSignal(resolvedPath, status);
179
196
  }
180
197
  /**
181
198
  * Permanent redirect to a relative path. Shorthand for `redirect(path, 308)`.
@@ -184,9 +201,13 @@ function redirect(path, status = 302) {
184
201
  * will replay POST requests to the new location. This matches Next.js behavior.
185
202
  *
186
203
  * @param path - Relative path (e.g. '/new-page', '/dashboard')
204
+ * @param options - Optional redirect options (e.g. preserveSearchParams).
187
205
  */
188
- function permanentRedirect(path) {
189
- redirect(path, 308);
206
+ function permanentRedirect(path, options) {
207
+ redirect(path, {
208
+ status: 308,
209
+ ...options
210
+ });
190
211
  }
191
212
  /**
192
213
  * Redirect to an external URL. The hostname must be in the provided allow-list.
@@ -547,27 +568,104 @@ function extractUserFrames(stack) {
547
568
  return userFrames;
548
569
  }
549
570
  //#endregion
571
+ //#region src/server/default-logger.ts
572
+ /**
573
+ * DefaultLogger — human-readable stderr logging when no custom logger is configured.
574
+ *
575
+ * Ships as the fallback so production deployments always have error visibility,
576
+ * even without an `instrumentation.ts` logger export. Output is one line per
577
+ * event, designed for `fly logs`, `kubectl logs`, Cloudflare dashboard tails, etc.
578
+ *
579
+ * Format:
580
+ * [timber] ERROR message key=value key=value trace_id=4bf92f35
581
+ * [timber] WARN message key=value key=value trace_id=4bf92f35
582
+ * [timber] INFO message method=GET path=/dashboard status=200 durationMs=43 trace_id=4bf92f35
583
+ *
584
+ * Behavior:
585
+ * - Suppressed entirely in dev mode (dev logging handles all output)
586
+ * - `debug` suppressed unless TIMBER_DEBUG is set
587
+ * - Replaced entirely when a custom logger is set via `setLogger()`
588
+ *
589
+ * See design/17-logging.md §"DefaultLogger"
590
+ */
591
+ /**
592
+ * Format data fields as `key=value` pairs for human-readable output.
593
+ * - `error` key is serialized via formatSsrError for stack trace cleanup
594
+ * - `trace_id` is truncated to 8 chars for readability (full ID in OTEL)
595
+ * - Other values are stringified inline
596
+ */
597
+ function formatDataFields(data) {
598
+ if (!data) return "";
599
+ const parts = [];
600
+ let traceId;
601
+ for (const [key, value] of Object.entries(data)) {
602
+ if (key === "trace_id") {
603
+ traceId = typeof value === "string" ? value : String(value);
604
+ continue;
605
+ }
606
+ if (key === "error") {
607
+ parts.push(`error=${formatSsrError(value)}`);
608
+ continue;
609
+ }
610
+ if (value === void 0 || value === null) continue;
611
+ parts.push(`${key}=${value}`);
612
+ }
613
+ if (traceId) parts.push(`trace_id=${traceId.slice(0, 8)}`);
614
+ return parts.length > 0 ? " " + parts.join(" ") : "";
615
+ }
616
+ /** Pad level string to fixed width for alignment. */
617
+ function padLevel(level) {
618
+ return level.padEnd(5);
619
+ }
620
+ function createDefaultLogger() {
621
+ return {
622
+ error(msg, data) {
623
+ if (isDevMode()) return;
624
+ const fields = formatDataFields(data);
625
+ process.stderr.write(`[timber] ${padLevel("ERROR")} ${msg}${fields}\n`);
626
+ },
627
+ warn(msg, data) {
628
+ if (isDevMode()) return;
629
+ const fields = formatDataFields(data);
630
+ process.stderr.write(`[timber] ${padLevel("WARN")} ${msg}${fields}\n`);
631
+ },
632
+ info(msg, data) {
633
+ if (isDevMode()) return;
634
+ if (!isDebug()) return;
635
+ const fields = formatDataFields(data);
636
+ process.stderr.write(`[timber] ${padLevel("INFO")} ${msg}${fields}\n`);
637
+ },
638
+ debug(msg, data) {
639
+ if (isDevMode()) return;
640
+ if (!isDebug()) return;
641
+ const fields = formatDataFields(data);
642
+ process.stderr.write(`[timber] ${padLevel("DEBUG")} ${msg}${fields}\n`);
643
+ }
644
+ };
645
+ }
646
+ //#endregion
550
647
  //#region src/server/logger.ts
551
648
  /**
552
649
  * Logger — structured logging with environment-aware formatting.
553
650
  *
554
- * timber.js does not ship a logger. Users export any object with
555
- * info/warn/error/debug methods from instrumentation.ts and the framework
556
- * picks it up. Silent if no logger export is present.
651
+ * timber.js ships a DefaultLogger that writes human-readable lines to stderr
652
+ * in production. Users can export a custom logger from instrumentation.ts to
653
+ * replace it with pino, winston, or any TimberLogger-compatible object.
557
654
  *
558
655
  * See design/17-logging.md §"Production Logging"
559
656
  */
560
- var _logger = null;
657
+ var _logger = createDefaultLogger();
561
658
  /**
562
659
  * Set the user-provided logger. Called by the instrumentation loader
563
- * when it finds a `logger` export in instrumentation.ts.
660
+ * when it finds a `logger` export in instrumentation.ts. Replaces
661
+ * the DefaultLogger entirely.
564
662
  */
565
663
  function setLogger(logger) {
566
664
  _logger = logger;
567
665
  }
568
666
  /**
569
- * Get the current logger, or null if none configured.
570
- * Framework-internal used at framework event points to emit structured logs.
667
+ * Get the current logger. Always non-null returns DefaultLogger when
668
+ * no custom logger is configured.
571
669
  */
572
670
  function getLogger() {
573
671
  return _logger;
@@ -587,50 +685,51 @@ function withTraceContext(data) {
587
685
  }
588
686
  /** Log a completed request. Level: info. */
589
687
  function logRequestCompleted(data) {
590
- _logger?.info("request completed", withTraceContext(data));
688
+ _logger.info("request completed", withTraceContext(data));
591
689
  }
592
690
  /** Log request received. Level: debug. */
593
691
  function logRequestReceived(data) {
594
- _logger?.debug("request received", withTraceContext(data));
692
+ _logger.debug("request received", withTraceContext(data));
595
693
  }
596
694
  /** Log a slow request warning. Level: warn. */
597
695
  function logSlowRequest(data) {
598
- _logger?.warn("slow request exceeded threshold", withTraceContext(data));
696
+ _logger.warn("slow request exceeded threshold", withTraceContext(data));
599
697
  }
600
698
  /** Log middleware short-circuit. Level: debug. */
601
699
  function logMiddlewareShortCircuit(data) {
602
- _logger?.debug("middleware short-circuited", withTraceContext(data));
700
+ _logger.debug("middleware short-circuited", withTraceContext(data));
603
701
  }
604
702
  /** Log unhandled error in middleware phase. Level: error. */
605
703
  function logMiddlewareError(data) {
606
- if (_logger) _logger.error("unhandled error in middleware phase", withTraceContext(data));
607
- else if (isDebug()) console.error("[timber] middleware error", data.error);
704
+ _logger.error("unhandled error in middleware phase", withTraceContext(data));
608
705
  }
609
706
  /** Log unhandled render-phase error. Level: error. */
610
707
  function logRenderError(data) {
611
- if (_logger) _logger.error("unhandled render-phase error", withTraceContext(data));
612
- else if (isDebug()) console.error("[timber] render error:", formatSsrError(data.error));
708
+ _logger.error("unhandled render-phase error", withTraceContext(data));
613
709
  }
614
710
  /** Log proxy.ts uncaught error. Level: error. */
615
711
  function logProxyError(data) {
616
- if (_logger) _logger.error("proxy.ts threw uncaught error", withTraceContext(data));
617
- else if (isDebug()) console.error("[timber] proxy error", data.error);
712
+ _logger.error("proxy.ts threw uncaught error", withTraceContext(data));
713
+ }
714
+ /** Log unhandled error in route handler. Level: error. */
715
+ function logRouteError(data) {
716
+ _logger.error("unhandled route handler error", withTraceContext(data));
618
717
  }
619
718
  /** Log waitUntil() adapter missing (once at startup). Level: warn. */
620
719
  function logWaitUntilUnsupported() {
621
- _logger?.warn("adapter does not support waitUntil()");
720
+ _logger.warn("adapter does not support waitUntil()");
622
721
  }
623
722
  /** Log waitUntil() promise rejection. Level: warn. */
624
723
  function logWaitUntilRejected(data) {
625
- _logger?.warn("waitUntil() promise rejected", withTraceContext(data));
724
+ _logger.warn("waitUntil() promise rejected", withTraceContext(data));
626
725
  }
627
726
  /** Log staleWhileRevalidate refetch failure. Level: warn. */
628
727
  function logSwrRefetchFailed(data) {
629
- _logger?.warn("staleWhileRevalidate refetch failed", withTraceContext(data));
728
+ _logger.warn("staleWhileRevalidate refetch failed", withTraceContext(data));
630
729
  }
631
730
  /** Log cache miss. Level: debug. */
632
731
  function logCacheMiss(data) {
633
- _logger?.debug("timber.cache MISS", withTraceContext(data));
732
+ _logger.debug("timber.cache MISS", withTraceContext(data));
634
733
  }
635
734
  //#endregion
636
735
  //#region src/server/instrumentation.ts
@@ -695,733 +794,689 @@ function hasOnRequestError() {
695
794
  return _onRequestError !== null;
696
795
  }
697
796
  //#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
- ]);
797
+ //#region src/server/metadata-social.ts
718
798
  /**
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.
799
+ * Render Open Graph metadata into head element descriptors.
724
800
  *
725
- * See design/16-metadata.md §"Metadata Routes"
801
+ * Handles og:title, og:description, og:image (with dimensions/alt),
802
+ * og:video, og:audio, og:article:author, and other OG properties.
726
803
  */
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
804
+ function renderOpenGraph(og, elements) {
805
+ const simpleProps = [
806
+ ["og:title", og.title],
807
+ ["og:description", og.description],
808
+ ["og:url", og.url],
809
+ ["og:site_name", og.siteName],
810
+ ["og:locale", og.locale],
811
+ ["og:type", og.type],
812
+ ["og:article:published_time", og.publishedTime],
813
+ ["og:article:modified_time", og.modifiedTime]
814
+ ];
815
+ for (const [property, content] of simpleProps) if (content) elements.push({
816
+ tag: "meta",
817
+ attrs: {
818
+ property,
819
+ content
820
+ }
738
821
  });
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>`;
822
+ if (og.images) if (typeof og.images === "string") elements.push({
823
+ tag: "meta",
824
+ attrs: {
825
+ property: "og:image",
826
+ content: og.images
827
+ }
828
+ });
829
+ else {
830
+ const imgList = Array.isArray(og.images) ? og.images : [og.images];
831
+ for (const img of imgList) {
832
+ elements.push({
833
+ tag: "meta",
834
+ attrs: {
835
+ property: "og:image",
836
+ content: img.url
837
+ }
838
+ });
839
+ if (img.width) elements.push({
840
+ tag: "meta",
841
+ attrs: {
842
+ property: "og:image:width",
843
+ content: String(img.width)
844
+ }
845
+ });
846
+ if (img.height) elements.push({
847
+ tag: "meta",
848
+ attrs: {
849
+ property: "og:image:height",
850
+ content: String(img.height)
851
+ }
852
+ });
853
+ if (img.alt) elements.push({
854
+ tag: "meta",
855
+ attrs: {
856
+ property: "og:image:alt",
857
+ content: img.alt
858
+ }
859
+ });
750
860
  }
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
861
  }
776
- return null;
862
+ if (og.videos) for (const video of og.videos) elements.push({
863
+ tag: "meta",
864
+ attrs: {
865
+ property: "og:video",
866
+ content: video.url
867
+ }
868
+ });
869
+ if (og.audio) for (const audio of og.audio) elements.push({
870
+ tag: "meta",
871
+ attrs: {
872
+ property: "og:audio",
873
+ content: audio.url
874
+ }
875
+ });
876
+ if (og.authors) for (const author of og.authors) elements.push({
877
+ tag: "meta",
878
+ attrs: {
879
+ property: "og:article:author",
880
+ content: author
881
+ }
882
+ });
777
883
  }
778
884
  /**
779
- * Check if a pathname matches a URL pattern with dynamic segments.
885
+ * Render Twitter Card metadata into head element descriptors.
780
886
  *
781
- * Supports [param] (single segment) and [...param] (one or more segments).
782
- * Static segments must match exactly.
887
+ * Handles twitter:card, twitter:site, twitter:title, twitter:image,
888
+ * twitter:player, and twitter:app (per-platform name/id/url).
783
889
  */
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;
890
+ function renderTwitter(tw, elements) {
891
+ const simpleProps = [
892
+ ["twitter:card", tw.card],
893
+ ["twitter:site", tw.site],
894
+ ["twitter:site:id", tw.siteId],
895
+ ["twitter:title", tw.title],
896
+ ["twitter:description", tw.description],
897
+ ["twitter:creator", tw.creator],
898
+ ["twitter:creator:id", tw.creatorId]
899
+ ];
900
+ for (const [name, content] of simpleProps) if (content) elements.push({
901
+ tag: "meta",
902
+ attrs: {
903
+ name,
904
+ content
905
+ }
906
+ });
907
+ if (tw.images) if (typeof tw.images === "string") elements.push({
908
+ tag: "meta",
909
+ attrs: {
910
+ name: "twitter:image",
911
+ content: tw.images
912
+ }
913
+ });
914
+ else {
915
+ const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
916
+ for (const img of imgList) {
917
+ const url = typeof img === "string" ? img : img.url;
918
+ elements.push({
919
+ tag: "meta",
920
+ attrs: {
921
+ name: "twitter:image",
922
+ content: url
923
+ }
924
+ });
795
925
  }
796
- if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
797
- pi++;
798
926
  }
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, enableServerTiming = false, 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 (enableServerTiming) {
848
- const serverTiming = getServerTimingHeader();
849
- if (serverTiming) {
850
- result = ensureMutableResponse(result);
851
- result.headers.set("Server-Timing", serverTiming);
852
- }
853
- } else {
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 enableServerTiming ? runWithTimingCollector(runRequest) : runRequest();
881
- });
927
+ if (tw.players) for (const player of tw.players) {
928
+ elements.push({
929
+ tag: "meta",
930
+ attrs: {
931
+ name: "twitter:player",
932
+ content: player.playerUrl
933
+ }
882
934
  });
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", {}, () => enableServerTiming ? 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 });
935
+ if (player.width) elements.push({
936
+ tag: "meta",
937
+ attrs: {
938
+ name: "twitter:player:width",
939
+ content: String(player.width)
928
940
  }
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
- }
941
+ });
942
+ if (player.height) elements.push({
943
+ tag: "meta",
944
+ attrs: {
945
+ name: "twitter:player:height",
946
+ content: String(player.height)
941
947
  }
942
- }
943
- if (!match) {
944
- if (config.renderNoMatch) {
945
- const responseHeaders = new Headers();
946
- return config.renderNoMatch(req, responseHeaders);
948
+ });
949
+ if (player.streamUrl) elements.push({
950
+ tag: "meta",
951
+ attrs: {
952
+ name: "twitter:player:stream",
953
+ content: player.streamUrl
947
954
  }
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", {}, () => enableServerTiming ? 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;
955
+ });
956
+ }
957
+ if (tw.app) {
958
+ const platforms = [
959
+ ["iPhone", "iphone"],
960
+ ["iPad", "ipad"],
961
+ ["googlePlay", "googleplay"]
962
+ ];
963
+ if (tw.app.name) {
964
+ for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
965
+ tag: "meta",
966
+ attrs: {
967
+ name: `twitter:app:name:${tag}`,
968
+ content: tw.app.name
987
969
  }
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
- });
970
+ });
971
+ }
972
+ for (const [key, tag] of platforms) {
973
+ const id = tw.app.id?.[key];
974
+ if (id) elements.push({
975
+ tag: "meta",
976
+ attrs: {
977
+ name: `twitter:app:id:${tag}`,
978
+ content: id
1005
979
  }
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
- }
980
+ });
1016
981
  }
1017
- applyCookieJar(responseHeaders);
1018
- try {
1019
- const renderFn = () => render(req, match, responseHeaders, requestHeaderOverlay, interception);
1020
- const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => enableServerTiming ? 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
- responseHeaders.set("Location", error.location);
1027
- return new Response(null, {
1028
- status: error.status,
1029
- headers: responseHeaders
1030
- });
1031
- }
1032
- logRenderError({
1033
- method,
1034
- path,
1035
- error
982
+ for (const [key, tag] of platforms) {
983
+ const url = tw.app.url?.[key];
984
+ if (url) elements.push({
985
+ tag: "meta",
986
+ attrs: {
987
+ name: `twitter:app:url:${tag}`,
988
+ content: url
989
+ }
1036
990
  });
1037
- await fireOnRequestError(error, req, "render");
1038
- if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
1039
- if (config.renderFallbackError) try {
1040
- return await config.renderFallbackError(error, req, responseHeaders);
1041
- } catch {}
1042
- return new Response(null, { status: 500 });
1043
991
  }
1044
992
  }
1045
993
  }
994
+ //#endregion
995
+ //#region src/server/metadata-platform.ts
1046
996
  /**
1047
- * Fire the user's onRequestError hook with request context.
1048
- * Extracts request info from the Request object and calls the instrumentation hook.
997
+ * Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
1049
998
  */
1050
- async function fireOnRequestError(error, req, phase) {
1051
- const url = new URL(req.url);
1052
- const headersObj = {};
1053
- req.headers.forEach((v, k) => {
1054
- headersObj[k] = v;
1055
- });
1056
- await callOnRequestError(error, {
1057
- method: req.method,
1058
- path: url.pathname,
1059
- headers: headersObj
1060
- }, {
1061
- phase,
1062
- routePath: url.pathname,
1063
- routeType: "page",
1064
- traceId: traceId()
1065
- });
1066
- }
1067
- /**
1068
- * Apply all Set-Cookie headers from the cookie jar to a Headers object.
1069
- * Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
1070
- */
1071
- function applyCookieJar(headers) {
1072
- for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
1073
- }
1074
- /**
1075
- * Ensure a Response has mutable headers so the pipeline can safely append
1076
- * Set-Cookie and Server-Timing entries.
1077
- *
1078
- * `Response.redirect()` and some platform-level responses return objects
1079
- * with immutable headers. Calling `.set()` or `.append()` on them throws
1080
- * `TypeError: immutable`. This helper detects the immutable case by
1081
- * attempting a no-op write and, on failure, clones into a fresh Response
1082
- * with mutable headers.
1083
- */
1084
- function ensureMutableResponse(response) {
1085
- try {
1086
- response.headers.set("X-Timber-Probe", "1");
1087
- response.headers.delete("X-Timber-Probe");
1088
- return response;
1089
- } catch {
1090
- return new Response(response.body, {
1091
- status: response.status,
1092
- statusText: response.statusText,
1093
- headers: new Headers(response.headers)
999
+ function renderIcons(icons, elements) {
1000
+ if (icons.icon) {
1001
+ if (typeof icons.icon === "string") elements.push({
1002
+ tag: "link",
1003
+ attrs: {
1004
+ rel: "icon",
1005
+ href: icons.icon
1006
+ }
1007
+ });
1008
+ else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
1009
+ const attrs = {
1010
+ rel: "icon",
1011
+ href: icon.url
1012
+ };
1013
+ if (icon.sizes) attrs.sizes = icon.sizes;
1014
+ if (icon.type) attrs.type = icon.type;
1015
+ elements.push({
1016
+ tag: "link",
1017
+ attrs
1018
+ });
1019
+ }
1020
+ }
1021
+ if (icons.shortcut) {
1022
+ const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
1023
+ for (const url of urls) elements.push({
1024
+ tag: "link",
1025
+ attrs: {
1026
+ rel: "shortcut icon",
1027
+ href: url
1028
+ }
1029
+ });
1030
+ }
1031
+ if (icons.apple) {
1032
+ if (typeof icons.apple === "string") elements.push({
1033
+ tag: "link",
1034
+ attrs: {
1035
+ rel: "apple-touch-icon",
1036
+ href: icons.apple
1037
+ }
1038
+ });
1039
+ else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
1040
+ const attrs = {
1041
+ rel: "apple-touch-icon",
1042
+ href: icon.url
1043
+ };
1044
+ if (icon.sizes) attrs.sizes = icon.sizes;
1045
+ elements.push({
1046
+ tag: "link",
1047
+ attrs
1048
+ });
1049
+ }
1050
+ }
1051
+ if (icons.other) for (const icon of icons.other) {
1052
+ const attrs = {
1053
+ rel: icon.rel,
1054
+ href: icon.url
1055
+ };
1056
+ if (icon.sizes) attrs.sizes = icon.sizes;
1057
+ if (icon.type) attrs.type = icon.type;
1058
+ elements.push({
1059
+ tag: "link",
1060
+ attrs
1094
1061
  });
1095
1062
  }
1096
1063
  }
1097
- //#endregion
1098
- //#region src/server/build-manifest.ts
1099
1064
  /**
1100
- * Collect all CSS files needed for a matched route's segment chain.
1101
- *
1102
- * Walks segments root → leaf, collecting CSS for each layout and page.
1103
- * Deduplicates while preserving order (root layout CSS first).
1065
+ * Render alternate link elements (canonical, hreflang, media, types).
1104
1066
  */
1105
- function collectRouteCss(segments, manifest) {
1106
- const seen = /* @__PURE__ */ new Set();
1107
- const result = [];
1108
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
1109
- if (!file) continue;
1110
- const cssFiles = manifest.css[file.filePath];
1111
- if (!cssFiles) continue;
1112
- for (const url of cssFiles) if (!seen.has(url)) {
1113
- seen.add(url);
1114
- result.push(url);
1067
+ function renderAlternates(alternates, elements) {
1068
+ if (alternates.canonical) elements.push({
1069
+ tag: "link",
1070
+ attrs: {
1071
+ rel: "canonical",
1072
+ href: alternates.canonical
1115
1073
  }
1116
- }
1117
- return result;
1074
+ });
1075
+ if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
1076
+ tag: "link",
1077
+ attrs: {
1078
+ rel: "alternate",
1079
+ hreflang: lang,
1080
+ href
1081
+ }
1082
+ });
1083
+ if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
1084
+ tag: "link",
1085
+ attrs: {
1086
+ rel: "alternate",
1087
+ media,
1088
+ href
1089
+ }
1090
+ });
1091
+ if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
1092
+ tag: "link",
1093
+ attrs: {
1094
+ rel: "alternate",
1095
+ type,
1096
+ href
1097
+ }
1098
+ });
1118
1099
  }
1119
1100
  /**
1120
- * Collect all font entries needed for a matched route's segment chain.
1121
- *
1122
- * Walks segments root → leaf, collecting fonts for each layout and page.
1123
- * Deduplicates by href while preserving order.
1101
+ * Render site verification meta tags (Google, Yahoo, Yandex, custom).
1124
1102
  */
1125
- function collectRouteFonts(segments, manifest) {
1126
- const seen = /* @__PURE__ */ new Set();
1127
- const result = [];
1128
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
1129
- if (!file) continue;
1130
- const fonts = manifest.fonts[file.filePath];
1131
- if (!fonts) continue;
1132
- for (const entry of fonts) if (!seen.has(entry.href)) {
1133
- seen.add(entry.href);
1134
- result.push(entry);
1103
+ function renderVerification(verification, elements) {
1104
+ const verificationProps = [
1105
+ ["google-site-verification", verification.google],
1106
+ ["y_key", verification.yahoo],
1107
+ ["yandex-verification", verification.yandex]
1108
+ ];
1109
+ for (const [name, content] of verificationProps) if (content) elements.push({
1110
+ tag: "meta",
1111
+ attrs: {
1112
+ name,
1113
+ content
1135
1114
  }
1115
+ });
1116
+ if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
1117
+ const content = Array.isArray(value) ? value.join(", ") : value;
1118
+ elements.push({
1119
+ tag: "meta",
1120
+ attrs: {
1121
+ name,
1122
+ content
1123
+ }
1124
+ });
1136
1125
  }
1137
- return result;
1138
1126
  }
1139
1127
  /**
1140
- * Collect modulepreload URLs for a matched route's segment chain.
1141
- *
1142
- * Walks segments root → leaf, collecting transitive JS dependencies
1143
- * for each layout and page. Deduplicates across segments.
1128
+ * Render Apple Web App meta tags and startup image links.
1144
1129
  */
1145
- function collectRouteModulepreloads(segments, manifest) {
1146
- const seen = /* @__PURE__ */ new Set();
1147
- const result = [];
1148
- for (const segment of segments) for (const file of [segment.layout, segment.page]) {
1149
- if (!file) continue;
1150
- const preloads = manifest.modulepreload[file.filePath];
1151
- if (!preloads) continue;
1152
- for (const url of preloads) if (!seen.has(url)) {
1153
- seen.add(url);
1154
- result.push(url);
1130
+ function renderAppleWebApp(appleWebApp, elements) {
1131
+ if (appleWebApp.capable) elements.push({
1132
+ tag: "meta",
1133
+ attrs: {
1134
+ name: "apple-mobile-web-app-capable",
1135
+ content: "yes"
1136
+ }
1137
+ });
1138
+ if (appleWebApp.title) elements.push({
1139
+ tag: "meta",
1140
+ attrs: {
1141
+ name: "apple-mobile-web-app-title",
1142
+ content: appleWebApp.title
1143
+ }
1144
+ });
1145
+ if (appleWebApp.statusBarStyle) elements.push({
1146
+ tag: "meta",
1147
+ attrs: {
1148
+ name: "apple-mobile-web-app-status-bar-style",
1149
+ content: appleWebApp.statusBarStyle
1150
+ }
1151
+ });
1152
+ if (appleWebApp.startupImage) {
1153
+ const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
1154
+ for (const img of images) {
1155
+ const attrs = {
1156
+ rel: "apple-touch-startup-image",
1157
+ href: typeof img === "string" ? img : img.url
1158
+ };
1159
+ if (typeof img === "object" && img.media) attrs.media = img.media;
1160
+ elements.push({
1161
+ tag: "link",
1162
+ attrs
1163
+ });
1155
1164
  }
1156
1165
  }
1157
- return result;
1158
1166
  }
1159
- //#endregion
1160
- //#region src/server/early-hints.ts
1161
1167
  /**
1162
- * 103 Early Hints utilities.
1163
- *
1164
- * Early Hints are sent before the final response to let the browser
1165
- * start fetching critical resources (CSS, fonts, JS) while the server
1166
- * is still rendering.
1167
- *
1168
- * The framework collects hints from two sources:
1169
- * 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
1170
- * 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
1171
- *
1172
- * Both are emitted as Link headers. Cloudflare CDN automatically converts
1173
- * Link headers into 103 Early Hints responses.
1174
- *
1175
- * Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
1168
+ * Render App Links (al:*) meta tags for deep linking across platforms.
1176
1169
  */
1177
- /**
1178
- * Format a single EarlyHint as a Link header value.
1179
- *
1180
- * Examples:
1181
- * `</styles/root.css>; rel=preload; as=style`
1182
- * `</fonts/inter.woff2>; rel=preload; as=font; crossorigin=anonymous`
1183
- * `</_timber/client.js>; rel=modulepreload`
1184
- * `<https://fonts.googleapis.com>; rel=preconnect`
1185
- */
1186
- function formatLinkHeader(hint) {
1187
- let value = `<${hint.href}>; rel=${hint.rel}`;
1188
- if (hint.as !== void 0) value += `; as=${hint.as}`;
1189
- if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
1190
- if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
1191
- return value;
1170
+ function renderAppLinks(appLinks, elements) {
1171
+ const platformEntries = [
1172
+ ["ios", appLinks.ios],
1173
+ ["android", appLinks.android],
1174
+ ["windows", appLinks.windows],
1175
+ ["windows_phone", appLinks.windowsPhone],
1176
+ ["windows_universal", appLinks.windowsUniversal]
1177
+ ];
1178
+ for (const [platform, entries] of platformEntries) {
1179
+ if (!entries) continue;
1180
+ for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
1181
+ tag: "meta",
1182
+ attrs: {
1183
+ property: `al:${platform}:${key}`,
1184
+ content: String(value)
1185
+ }
1186
+ });
1187
+ }
1188
+ if (appLinks.web) {
1189
+ if (appLinks.web.url) elements.push({
1190
+ tag: "meta",
1191
+ attrs: {
1192
+ property: "al:web:url",
1193
+ content: appLinks.web.url
1194
+ }
1195
+ });
1196
+ if (appLinks.web.shouldFallback !== void 0) elements.push({
1197
+ tag: "meta",
1198
+ attrs: {
1199
+ property: "al:web:should_fallback",
1200
+ content: appLinks.web.shouldFallback ? "true" : "false"
1201
+ }
1202
+ });
1203
+ }
1192
1204
  }
1193
1205
  /**
1194
- * Collect all Link header strings for a matched route's segment chain.
1195
- *
1196
- * Walks the build manifest to emit hints for:
1197
- * - CSS stylesheets (rel=preload; as=style)
1198
- * - Font assets (rel=preload; as=font; crossorigin)
1199
- * - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
1200
- *
1201
- * Also emits global CSS from the `_global` manifest key. Route files
1202
- * are server components that don't appear in the client bundle, so
1203
- * per-route CSS keying doesn't work with the RSC plugin. The `_global`
1204
- * key contains all CSS assets from the client build — fine for early
1205
- * hints since they're just prefetch signals.
1206
- *
1207
- * Returns formatted Link header strings, deduplicated, root → leaf order.
1208
- * Returns an empty array in dev mode (manifest is empty).
1206
+ * Render Apple iTunes smart banner meta tag.
1209
1207
  */
1210
- function collectEarlyHintHeaders(segments, manifest, options) {
1211
- const result = [];
1212
- const seen = /* @__PURE__ */ new Set();
1213
- const add = (header) => {
1214
- if (!seen.has(header)) {
1215
- seen.add(header);
1216
- result.push(header);
1208
+ function renderItunes(itunes, elements) {
1209
+ const parts = [`app-id=${itunes.appId}`];
1210
+ if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
1211
+ if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
1212
+ elements.push({
1213
+ tag: "meta",
1214
+ attrs: {
1215
+ name: "apple-itunes-app",
1216
+ content: parts.join(", ")
1217
1217
  }
1218
- };
1219
- for (const url of collectRouteCss(segments, manifest)) add(formatLinkHeader({
1220
- href: url,
1221
- rel: "preload",
1222
- as: "style"
1223
- }));
1224
- for (const url of manifest.css["_global"] ?? []) add(formatLinkHeader({
1225
- href: url,
1226
- rel: "preload",
1227
- as: "style"
1228
- }));
1229
- for (const font of collectRouteFonts(segments, manifest)) add(formatLinkHeader({
1230
- href: font.href,
1231
- rel: "preload",
1232
- as: "font",
1233
- crossOrigin: "anonymous"
1234
- }));
1235
- if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(formatLinkHeader({
1236
- href: url,
1237
- rel: "modulepreload"
1238
- }));
1239
- return result;
1240
- }
1241
- //#endregion
1242
- //#region src/server/early-hints-sender.ts
1243
- /**
1244
- * Per-request 103 Early Hints sender — ALS bridge for platform adapters.
1245
- *
1246
- * The pipeline collects Link headers for CSS, fonts, and JS chunks at
1247
- * route-match time. On platforms that support it (Node.js v18.11+, Bun),
1248
- * the adapter can send these as a 103 Early Hints interim response before
1249
- * the final response is ready.
1250
- *
1251
- * This module provides an ALS-based bridge: the generated entry point
1252
- * (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
1253
- * binding a per-request sender function. The pipeline calls
1254
- * `sendEarlyHints103()` to fire the 103 if a sender is available.
1255
- *
1256
- * On platforms where 103 is handled at the CDN level (e.g., Cloudflare
1257
- * converts Link headers into 103 automatically), no sender is installed
1258
- * and `sendEarlyHints103()` is a no-op.
1259
- *
1260
- * Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
1261
- */
1262
- /**
1263
- * Run a function with a per-request early hints sender installed.
1264
- *
1265
- * Called by generated entry points (e.g., Nitro node-server/bun) to
1266
- * bind the platform's writeEarlyHints capability for the request duration.
1267
- */
1268
- function runWithEarlyHintsSender(sender, fn) {
1269
- return earlyHintsSenderAls.run(sender, fn);
1270
- }
1271
- /**
1272
- * Send collected Link headers as a 103 Early Hints response.
1273
- *
1274
- * No-op if no sender is installed for the current request (e.g., on
1275
- * Cloudflare where the CDN handles 103 automatically, or in dev mode).
1276
- *
1277
- * Non-fatal: errors from the sender are caught and silently ignored.
1278
- */
1279
- function sendEarlyHints103(links) {
1280
- if (!links.length) return;
1281
- const sender = earlyHintsSenderAls.getStore();
1282
- if (!sender) return;
1283
- try {
1284
- sender(links);
1285
- } catch {}
1218
+ });
1286
1219
  }
1287
1220
  //#endregion
1288
- //#region src/server/tree-builder.ts
1221
+ //#region src/server/metadata-render.ts
1289
1222
  /**
1290
- * Build the unified element tree from a matched segment chain.
1223
+ * Convert resolved metadata into an array of head element descriptors.
1291
1224
  *
1292
- * Construction is bottom-up:
1293
- * 1. Start with the page component (leaf segment)
1294
- * 2. Wrap in status-code error boundaries (fallback chain)
1295
- * 3. Wrap in AccessGate (if segment has access.ts)
1296
- * 4. Pass as children to the segment's layout
1297
- * 5. Repeat up the segment chain to root
1225
+ * Each descriptor has a `tag` ('title', 'meta', 'link') and either
1226
+ * `content` (for <title>) or `attrs` (for <meta>/<link>).
1298
1227
  *
1299
- * Parallel slots are resolved at each layout level and composed as named props.
1228
+ * The framework's MetadataResolver component consumes these descriptors
1229
+ * and renders them into the <head>.
1300
1230
  */
1301
- async function buildElementTree(config) {
1302
- const { segments, params, searchParams, loadModule, createElement, errorBoundaryComponent } = config;
1303
- if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
1304
- const leaf = segments[segments.length - 1];
1305
- if (leaf.route && !leaf.page) return {
1306
- tree: null,
1307
- isApiRoute: true
1308
- };
1309
- const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
1310
- 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.`);
1311
- let element = createElement(PageComponent, {
1312
- params,
1313
- searchParams
1231
+ function renderMetadataToElements(metadata) {
1232
+ const elements = [];
1233
+ if (typeof metadata.title === "string") elements.push({
1234
+ tag: "title",
1235
+ content: metadata.title
1314
1236
  });
1315
- for (let i = segments.length - 1; i >= 0; i--) {
1316
- const segment = segments[i];
1317
- element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
1318
- if (segment.access) {
1319
- const accessFn = (await loadModule(segment.access)).default;
1320
- element = createElement("timber:access-gate", {
1321
- accessFn,
1322
- params,
1323
- searchParams,
1324
- segmentName: segment.segmentName,
1325
- children: element
1326
- });
1237
+ const simpleMetaProps = [
1238
+ ["description", metadata.description],
1239
+ ["generator", metadata.generator],
1240
+ ["application-name", metadata.applicationName],
1241
+ ["referrer", metadata.referrer],
1242
+ ["category", metadata.category],
1243
+ ["creator", metadata.creator],
1244
+ ["publisher", metadata.publisher]
1245
+ ];
1246
+ for (const [name, content] of simpleMetaProps) if (content) elements.push({
1247
+ tag: "meta",
1248
+ attrs: {
1249
+ name,
1250
+ content
1327
1251
  }
1328
- if (segment.layout) {
1329
- const LayoutComponent = (await loadModule(segment.layout)).default;
1330
- if (LayoutComponent) {
1331
- const slotProps = {};
1332
- if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, params, searchParams, loadModule, createElement, errorBoundaryComponent);
1333
- element = createElement(LayoutComponent, {
1334
- ...slotProps,
1335
- params,
1336
- searchParams,
1337
- children: element
1338
- });
1252
+ });
1253
+ if (metadata.keywords) {
1254
+ const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
1255
+ elements.push({
1256
+ tag: "meta",
1257
+ attrs: {
1258
+ name: "keywords",
1259
+ content
1339
1260
  }
1340
- }
1261
+ });
1341
1262
  }
1342
- return {
1343
- tree: element,
1344
- isApiRoute: false
1345
- };
1346
- }
1347
- /**
1348
- * Build the element tree for a parallel slot.
1349
- *
1350
- * Slots have their own access.ts (SlotAccessGate) and error boundaries.
1351
- * On access denial: denied.tsx → default.tsx → null (graceful degradation).
1352
- */
1353
- async function buildSlotElement(slotNode, params, searchParams, loadModule, createElement, errorBoundaryComponent) {
1354
- const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
1355
- const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
1356
- if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {
1357
- params,
1358
- searchParams
1359
- }) : null;
1360
- let element = createElement(PageComponent, {
1361
- params,
1362
- searchParams
1363
- });
1364
- element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
1365
- if (slotNode.access) {
1366
- const accessFn = (await loadModule(slotNode.access)).default;
1367
- const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default;
1368
- element = createElement("timber:slot-access-gate", {
1369
- accessFn,
1370
- params,
1371
- searchParams,
1372
- deniedFallback: DeniedComponent ? createElement(DeniedComponent, {
1373
- slot: slotNode.segmentName.replace(/^@/, ""),
1374
- dangerouslyPassData: void 0
1375
- }) : null,
1376
- defaultFallback: DefaultComponent ? createElement(DefaultComponent, {
1377
- params,
1378
- searchParams
1379
- }) : null,
1380
- children: element
1263
+ if (metadata.robots) {
1264
+ const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
1265
+ elements.push({
1266
+ tag: "meta",
1267
+ attrs: {
1268
+ name: "robots",
1269
+ content
1270
+ }
1381
1271
  });
1272
+ if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
1273
+ const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
1274
+ elements.push({
1275
+ tag: "meta",
1276
+ attrs: {
1277
+ name: "googlebot",
1278
+ content: gbContent
1279
+ }
1280
+ });
1281
+ }
1382
1282
  }
1383
- return element;
1384
- }
1385
- /**
1386
- * Wrap an element with error boundaries from a segment's status-code files.
1387
- *
1388
- * Wrapping order (innermost to outermost):
1389
- * 1. Specific status files (503.tsx, 429.tsx, etc.)
1390
- * 2. Category catch-alls (4xx.tsx, 5xx.tsx)
1391
- * 3. error.tsx (general error boundary)
1392
- *
1393
- * This creates the fallback chain described in design/10-error-handling.md.
1394
- */
1395
- async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
1396
- if (segment.statusFiles) {
1397
- for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
1398
- const status = parseInt(key, 10);
1399
- if (!isNaN(status)) {
1400
- const Component = (await loadModule(file)).default;
1401
- if (Component) element = createElement(errorBoundaryComponent, {
1402
- fallbackComponent: Component,
1403
- status,
1404
- children: element
1405
- });
1406
- }
1283
+ if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
1284
+ if (metadata.twitter) renderTwitter(metadata.twitter, elements);
1285
+ if (metadata.icons) renderIcons(metadata.icons, elements);
1286
+ if (metadata.manifest) elements.push({
1287
+ tag: "link",
1288
+ attrs: {
1289
+ rel: "manifest",
1290
+ href: metadata.manifest
1407
1291
  }
1408
- for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
1409
- const Component = (await loadModule(file)).default;
1410
- if (Component) element = createElement(errorBoundaryComponent, {
1411
- fallbackComponent: Component,
1412
- status: key === "4xx" ? 400 : 500,
1413
- children: element
1292
+ });
1293
+ if (metadata.alternates) renderAlternates(metadata.alternates, elements);
1294
+ if (metadata.verification) renderVerification(metadata.verification, elements);
1295
+ if (metadata.formatDetection) {
1296
+ const parts = [];
1297
+ if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
1298
+ if (metadata.formatDetection.email === false) parts.push("email=no");
1299
+ if (metadata.formatDetection.address === false) parts.push("address=no");
1300
+ if (parts.length > 0) elements.push({
1301
+ tag: "meta",
1302
+ attrs: {
1303
+ name: "format-detection",
1304
+ content: parts.join(", ")
1305
+ }
1306
+ });
1307
+ }
1308
+ if (metadata.authors) {
1309
+ const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
1310
+ for (const author of authorList) {
1311
+ if (author.name) elements.push({
1312
+ tag: "meta",
1313
+ attrs: {
1314
+ name: "author",
1315
+ content: author.name
1316
+ }
1317
+ });
1318
+ if (author.url) elements.push({
1319
+ tag: "link",
1320
+ attrs: {
1321
+ rel: "author",
1322
+ href: author.url
1323
+ }
1414
1324
  });
1415
1325
  }
1416
1326
  }
1417
- if (segment.error) {
1418
- const ErrorComponent = (await loadModule(segment.error)).default;
1419
- if (ErrorComponent) element = createElement(errorBoundaryComponent, {
1420
- fallbackComponent: ErrorComponent,
1421
- children: element
1327
+ if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
1328
+ if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
1329
+ if (metadata.itunes) renderItunes(metadata.itunes, elements);
1330
+ if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
1331
+ const content = Array.isArray(value) ? value.join(", ") : value;
1332
+ elements.push({
1333
+ tag: "meta",
1334
+ attrs: {
1335
+ name,
1336
+ content
1337
+ }
1422
1338
  });
1423
1339
  }
1424
- return element;
1340
+ return elements;
1341
+ }
1342
+ function renderRobotsObject(robots) {
1343
+ const parts = [];
1344
+ if (robots.index === true) parts.push("index");
1345
+ if (robots.index === false) parts.push("noindex");
1346
+ if (robots.follow === true) parts.push("follow");
1347
+ if (robots.follow === false) parts.push("nofollow");
1348
+ return parts.join(", ");
1349
+ }
1350
+ //#endregion
1351
+ //#region src/server/metadata.ts
1352
+ /**
1353
+ * Resolve a title value with an optional template.
1354
+ *
1355
+ * - string → apply template if present
1356
+ * - { absolute: '...' } → use as-is, skip template
1357
+ * - { default: '...' } → use as fallback (no template applied)
1358
+ * - undefined → undefined
1359
+ */
1360
+ function resolveTitle(title, template) {
1361
+ if (title === void 0 || title === null) return;
1362
+ if (typeof title === "string") return template ? template.replace("%s", title) : title;
1363
+ if (title.absolute !== void 0) return title.absolute;
1364
+ if (title.default !== void 0) return title.default;
1365
+ }
1366
+ /**
1367
+ * Resolve metadata from a segment chain.
1368
+ *
1369
+ * Processes entries from root layout to page (in segment order).
1370
+ * The merge algorithm:
1371
+ * 1. Shallow-merge all keys except title (later wins)
1372
+ * 2. Track the most recent title template
1373
+ * 3. Resolve the final title using the template
1374
+ *
1375
+ * In error state, the page entry is dropped and noindex is injected.
1376
+ *
1377
+ * See design/16-metadata.md §"Merge Algorithm"
1378
+ */
1379
+ function resolveMetadata(entries, options = {}) {
1380
+ const { errorState = false } = options;
1381
+ const merged = {};
1382
+ let titleTemplate;
1383
+ let lastDefault;
1384
+ let rawTitle;
1385
+ for (const { metadata, isPage } of entries) {
1386
+ if (errorState && isPage) continue;
1387
+ if (metadata.title !== void 0 && typeof metadata.title === "object") {
1388
+ if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
1389
+ if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
1390
+ }
1391
+ for (const key of Object.keys(metadata)) {
1392
+ if (key === "title") continue;
1393
+ merged[key] = metadata[key];
1394
+ }
1395
+ if (metadata.title !== void 0) rawTitle = metadata.title;
1396
+ }
1397
+ if (errorState) {
1398
+ rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
1399
+ titleTemplate = void 0;
1400
+ }
1401
+ const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
1402
+ if (resolvedTitle !== void 0) merged.title = resolvedTitle;
1403
+ if (errorState) merged.robots = "noindex";
1404
+ return merged;
1405
+ }
1406
+ /**
1407
+ * Check if a string is an absolute URL.
1408
+ */
1409
+ function isAbsoluteUrl(url) {
1410
+ return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
1411
+ }
1412
+ /**
1413
+ * Resolve a relative URL against a base URL.
1414
+ */
1415
+ function resolveUrl(url, base) {
1416
+ if (isAbsoluteUrl(url)) return url;
1417
+ return new URL(url, base).toString();
1418
+ }
1419
+ /**
1420
+ * Resolve relative URLs in metadata fields against metadataBase.
1421
+ *
1422
+ * Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
1423
+ * If metadataBase is not set, returns the metadata unchanged.
1424
+ */
1425
+ function resolveMetadataUrls(metadata) {
1426
+ const base = metadata.metadataBase;
1427
+ if (!base) return metadata;
1428
+ const result = { ...metadata };
1429
+ if (result.openGraph) {
1430
+ result.openGraph = { ...result.openGraph };
1431
+ if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
1432
+ else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
1433
+ ...img,
1434
+ url: resolveUrl(img.url, base)
1435
+ }));
1436
+ else if (result.openGraph.images) result.openGraph.images = {
1437
+ ...result.openGraph.images,
1438
+ url: resolveUrl(result.openGraph.images.url, base)
1439
+ };
1440
+ if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
1441
+ }
1442
+ if (result.twitter) {
1443
+ result.twitter = { ...result.twitter };
1444
+ if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
1445
+ else if (Array.isArray(result.twitter.images)) {
1446
+ const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
1447
+ ...img,
1448
+ url: resolveUrl(img.url, base)
1449
+ });
1450
+ const allStrings = resolved.every((r) => typeof r === "string");
1451
+ result.twitter.images = allStrings ? resolved : resolved;
1452
+ } else if (result.twitter.images) result.twitter.images = {
1453
+ ...result.twitter.images,
1454
+ url: resolveUrl(result.twitter.images.url, base)
1455
+ };
1456
+ }
1457
+ if (result.alternates) {
1458
+ result.alternates = { ...result.alternates };
1459
+ if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
1460
+ if (result.alternates.languages) {
1461
+ const langs = {};
1462
+ for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
1463
+ result.alternates.languages = langs;
1464
+ }
1465
+ }
1466
+ if (result.icons) {
1467
+ result.icons = { ...result.icons };
1468
+ if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
1469
+ else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
1470
+ ...i,
1471
+ url: resolveUrl(i.url, base)
1472
+ }));
1473
+ if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
1474
+ else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
1475
+ ...i,
1476
+ url: resolveUrl(i.url, base)
1477
+ }));
1478
+ }
1479
+ return result;
1425
1480
  }
1426
1481
  //#endregion
1427
1482
  //#region src/server/access-gate.tsx
@@ -1454,24 +1509,21 @@ async function wrapWithErrorBoundaries(segment, element, loadModule, createEleme
1454
1509
  * gets the same data by calling the same cached functions (React.cache dedup).
1455
1510
  */
1456
1511
  function AccessGate(props) {
1457
- const { accessFn, params, searchParams, segmentName, verdict, children } = props;
1512
+ const { accessFn, segmentName, verdict, children } = props;
1458
1513
  if (verdict !== void 0) {
1459
1514
  if (verdict === "pass") return children;
1460
1515
  throw verdict;
1461
1516
  }
1462
- return accessGateFallback(accessFn, params, searchParams, segmentName, children);
1517
+ return accessGateFallback(accessFn, segmentName, children);
1463
1518
  }
1464
1519
  /**
1465
1520
  * Async fallback for AccessGate when no pre-computed verdict is available.
1466
1521
  * Calls accessFn with OTEL instrumentation.
1467
1522
  */
1468
- async function accessGateFallback(accessFn, params, searchParams, segmentName, children) {
1523
+ async function accessGateFallback(accessFn, segmentName, children) {
1469
1524
  await withSpan("timber.access", { "timber.segment": segmentName ?? "unknown" }, async () => {
1470
1525
  try {
1471
- await accessFn({
1472
- params,
1473
- searchParams
1474
- });
1526
+ await accessFn({ params: await rawSegmentParams() });
1475
1527
  await setSpanAttribute("timber.result", "pass");
1476
1528
  } catch (error) {
1477
1529
  if (error instanceof DenySignal) {
@@ -1491,1093 +1543,1307 @@ async function accessGateFallback(accessFn, params, searchParams, segmentName, c
1491
1543
  * The HTTP status code is unaffected — slot denial is a UI concern, not
1492
1544
  * a protocol concern. The parent layout and sibling slots still render.
1493
1545
  *
1546
+ * DeniedComponent is passed instead of a pre-built element so that
1547
+ * DenySignal.data can be forwarded as the dangerouslyPassData prop
1548
+ * and the slot name can be passed as the slot prop. See TIM-488.
1549
+ *
1494
1550
  * redirect() in slot access.ts is a dev-mode error — redirecting from a
1495
1551
  * slot doesn't make architectural sense.
1496
1552
  */
1497
1553
  async function SlotAccessGate(props) {
1498
- const { accessFn, params, searchParams, deniedFallback, defaultFallback, children } = props;
1554
+ const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
1499
1555
  try {
1500
- await accessFn({
1501
- params,
1502
- searchParams
1503
- });
1556
+ await accessFn({ params: await rawSegmentParams() });
1504
1557
  } catch (error) {
1505
- if (error instanceof DenySignal) return deniedFallback ?? defaultFallback ?? null;
1558
+ if (error instanceof DenySignal) return buildDeniedFallback(DeniedComponent, slotName, error.data, createElement) ?? defaultFallback ?? null;
1506
1559
  if (error instanceof RedirectSignal) {
1507
1560
  if (isDebug()) console.error("[timber] redirect() is not allowed in slot access.ts. Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. If you need to redirect, move the logic to the parent segment's access.ts.");
1508
- return deniedFallback ?? defaultFallback ?? null;
1561
+ return buildDeniedFallback(DeniedComponent, slotName, void 0, createElement) ?? defaultFallback ?? null;
1509
1562
  }
1510
1563
  if (isDebug()) console.warn("[timber] Unhandled error in slot access.ts. Use deny() for access control, not unhandled throws.", error);
1511
1564
  throw error;
1512
1565
  }
1513
1566
  return children;
1514
1567
  }
1568
+ /**
1569
+ * Build the denied fallback element dynamically with DenySignal data.
1570
+ * Returns null if no DeniedComponent is available.
1571
+ */
1572
+ function buildDeniedFallback(DeniedComponent, slotName, data, createElement) {
1573
+ if (!DeniedComponent) return null;
1574
+ return createElement(DeniedComponent, {
1575
+ slot: slotName,
1576
+ dangerouslyPassData: data
1577
+ });
1578
+ }
1515
1579
  //#endregion
1516
- //#region src/server/status-code-resolver.ts
1580
+ //#region src/server/route-element-builder.ts
1517
1581
  /**
1518
- * Maps legacy file convention names to their corresponding HTTP status codes.
1519
- * Only used in the 4xx component fallback chain.
1582
+ * Thrown when a defineSegmentParams codec's parse() fails.
1583
+ * The pipeline catches this and responds with 404.
1520
1584
  */
1521
- var LEGACY_FILE_TO_STATUS = {
1522
- "not-found": 404,
1523
- "forbidden": 403,
1524
- "unauthorized": 401
1585
+ var ParamCoercionError = class extends Error {
1586
+ constructor(message) {
1587
+ super(message);
1588
+ this.name = "ParamCoercionError";
1589
+ }
1525
1590
  };
1591
+ //#endregion
1592
+ //#region src/server/version-skew.ts
1526
1593
  /**
1527
- * Resolve the status-code file to render for a given HTTP status code.
1594
+ * Version Skew Detection graceful recovery when stale clients hit new deployments.
1528
1595
  *
1529
- * Walks the segment chain from leaf to root following the fallback chain
1530
- * defined in design/10-error-handling.md. Returns null if no file is found
1531
- * (caller should render the framework default).
1596
+ * When a new version of the app is deployed, clients with open tabs still have
1597
+ * the old JavaScript bundle. Without version skew handling, these stale clients
1598
+ * will experience:
1532
1599
  *
1533
- * @param status - The HTTP status code (4xx or 5xx).
1534
- * @param segments - The matched segment chain from root (index 0) to leaf (last).
1535
- * @param format - The response format family ('component' or 'json'). Defaults to 'component'.
1600
+ * 1. Server action calls that crash (action IDs are content-hashed)
1601
+ * 2. Chunk load failures (old filenames gone from CDN)
1602
+ * 3. RSC payload mismatches (component references differ between builds)
1603
+ *
1604
+ * This module implements deployment ID comparison:
1605
+ * - A per-build deployment ID is generated at build time (see build-manifest.ts)
1606
+ * - The client sends it via `X-Timber-Deployment-Id` header on every RSC/action request
1607
+ * - The server compares it against the current build's ID
1608
+ * - On mismatch: signal the client to reload (not crash)
1609
+ *
1610
+ * The deployment ID is always-on in production. Dev mode skips the check
1611
+ * (HMR handles code updates without full reloads).
1612
+ *
1613
+ * See design/25-production-deployments.md, TIM-446
1536
1614
  */
1537
- function resolveStatusFile(status, segments, format = "component") {
1538
- if (status >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
1539
- if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
1540
- return null;
1541
- }
1615
+ /** Header sent by the client with every RSC/action request. */
1616
+ var DEPLOYMENT_ID_HEADER = "X-Timber-Deployment-Id";
1617
+ /** Response header that signals the client to do a full page reload. */
1618
+ var RELOAD_HEADER = "X-Timber-Reload";
1542
1619
  /**
1543
- * 4xx component fallback chain (three separate passes):
1544
- * Pass 1 — status files (leaf root): {status}.tsx → 4xx.tsx
1545
- * Pass 2 — legacy compat (leaf → root): not-found.tsx / forbidden.tsx / unauthorized.tsx
1546
- * Pass 3 — error.tsx (leaf → root)
1620
+ * The current build's deployment ID. Set at startup from the manifest init
1621
+ * module (globalThis.__TIMBER_DEPLOYMENT_ID__). Null in dev mode.
1547
1622
  */
1548
- function resolve4xx(status, segments) {
1549
- const statusStr = String(status);
1550
- for (let i = segments.length - 1; i >= 0; i--) {
1551
- const segment = segments[i];
1552
- if (!segment.statusFiles) continue;
1553
- const exact = segment.statusFiles.get(statusStr);
1554
- if (exact) return {
1555
- file: exact,
1556
- status,
1557
- kind: "exact",
1558
- segmentIndex: i
1559
- };
1560
- const category = segment.statusFiles.get("4xx");
1561
- if (category) return {
1562
- file: category,
1563
- status,
1564
- kind: "category",
1565
- segmentIndex: i
1566
- };
1567
- }
1568
- for (let i = segments.length - 1; i >= 0; i--) {
1569
- const segment = segments[i];
1570
- if (!segment.legacyStatusFiles) continue;
1571
- for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
1572
- const file = segment.legacyStatusFiles.get(name);
1573
- if (file) return {
1574
- file,
1575
- status,
1576
- kind: "legacy",
1577
- segmentIndex: i
1578
- };
1579
- }
1580
- }
1581
- for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
1582
- file: segments[i].error,
1583
- status,
1584
- kind: "error",
1585
- segmentIndex: i
1623
+ var currentDeploymentId = null;
1624
+ /**
1625
+ * Check if a request's deployment ID matches the current build.
1626
+ *
1627
+ * Returns `{ ok: true }` when:
1628
+ * - Dev mode (no deployment ID set — HMR handles updates)
1629
+ * - No deployment ID header (initial page load, non-RSC request)
1630
+ * - Deployment IDs match
1631
+ *
1632
+ * Returns `{ ok: false }` when:
1633
+ * - Client sends a deployment ID that differs from the current build
1634
+ */
1635
+ function checkVersionSkew(req) {
1636
+ if (!currentDeploymentId) return {
1637
+ ok: true,
1638
+ clientId: null
1639
+ };
1640
+ const clientId = req.headers.get(DEPLOYMENT_ID_HEADER);
1641
+ if (!clientId) return {
1642
+ ok: true,
1643
+ clientId: null
1644
+ };
1645
+ if (clientId === currentDeploymentId) return {
1646
+ ok: true,
1647
+ clientId
1648
+ };
1649
+ return {
1650
+ ok: false,
1651
+ clientId
1586
1652
  };
1587
- return null;
1588
1653
  }
1589
1654
  /**
1590
- * 4xx JSON fallback chain (single pass):
1591
- * Pass 1 json status files (leaf root): {status}.json 4xx.json
1592
- * No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
1655
+ * Apply version skew reload headers to a response.
1656
+ * Sets X-Timber-Reload: 1 to signal the client to do a full page reload.
1593
1657
  */
1594
- function resolve4xxJson(status, segments) {
1595
- const statusStr = String(status);
1596
- for (let i = segments.length - 1; i >= 0; i--) {
1597
- const segment = segments[i];
1598
- if (!segment.jsonStatusFiles) continue;
1599
- const exact = segment.jsonStatusFiles.get(statusStr);
1600
- if (exact) return {
1601
- file: exact,
1602
- status,
1603
- kind: "exact",
1604
- segmentIndex: i
1605
- };
1606
- const category = segment.jsonStatusFiles.get("4xx");
1607
- if (category) return {
1608
- file: category,
1609
- status,
1610
- kind: "category",
1611
- segmentIndex: i
1612
- };
1613
- }
1614
- return null;
1658
+ function applyReloadHeaders(headers) {
1659
+ headers.set(RELOAD_HEADER, "1");
1615
1660
  }
1661
+ //#endregion
1662
+ //#region src/server/pipeline-metadata.ts
1616
1663
  /**
1617
- * 5xx component fallback chain (single pass, per-segment):
1618
- * At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
1664
+ * Metadata route helpers for the request pipeline.
1665
+ *
1666
+ * Handles serving static metadata files and serializing sitemap responses.
1667
+ * Extracted from pipeline.ts to keep files under 500 lines.
1668
+ *
1669
+ * See design/16-metadata.md §"Metadata Routes"
1619
1670
  */
1620
- function resolve5xx(status, segments) {
1621
- const statusStr = String(status);
1622
- for (let i = segments.length - 1; i >= 0; i--) {
1623
- const segment = segments[i];
1624
- if (segment.statusFiles) {
1625
- const exact = segment.statusFiles.get(statusStr);
1626
- if (exact) return {
1627
- file: exact,
1628
- status,
1629
- kind: "exact",
1630
- segmentIndex: i
1631
- };
1632
- const category = segment.statusFiles.get("5xx");
1633
- if (category) return {
1634
- file: category,
1635
- status,
1636
- kind: "category",
1637
- segmentIndex: i
1638
- };
1671
+ /**
1672
+ * Content types that are text-based and should include charset=utf-8.
1673
+ * Binary formats (images) should not include charset.
1674
+ */
1675
+ var TEXT_CONTENT_TYPES = new Set([
1676
+ "application/xml",
1677
+ "text/plain",
1678
+ "application/json",
1679
+ "application/manifest+json",
1680
+ "image/svg+xml"
1681
+ ]);
1682
+ /**
1683
+ * Serve a static metadata file by reading it from disk.
1684
+ *
1685
+ * Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
1686
+ * are served as-is with the appropriate Content-Type header.
1687
+ * Text files include charset=utf-8; binary files do not.
1688
+ *
1689
+ * See design/16-metadata.md §"Metadata Routes"
1690
+ */
1691
+ async function serveStaticMetadataFile(metaMatch) {
1692
+ const { contentType, file } = metaMatch;
1693
+ const isText = TEXT_CONTENT_TYPES.has(contentType);
1694
+ const body = await readFile(file.filePath);
1695
+ const headers = {
1696
+ "Content-Type": isText ? `${contentType}; charset=utf-8` : contentType,
1697
+ "Content-Length": String(body.byteLength)
1698
+ };
1699
+ return new Response(body, {
1700
+ status: 200,
1701
+ headers
1702
+ });
1703
+ }
1704
+ /**
1705
+ * Serialize a sitemap array to XML.
1706
+ * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
1707
+ */
1708
+ function serializeSitemap(entries) {
1709
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.map((e) => {
1710
+ let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
1711
+ if (e.lastModified) {
1712
+ const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
1713
+ xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
1639
1714
  }
1640
- if (segment.error) return {
1641
- file: segment.error,
1642
- status,
1643
- kind: "error",
1644
- segmentIndex: i
1645
- };
1646
- }
1647
- return null;
1715
+ if (e.changeFrequency) xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
1716
+ if (e.priority !== void 0) xml += `\n <priority>${e.priority}</priority>`;
1717
+ xml += "\n </url>";
1718
+ return xml;
1719
+ }).join("\n")}\n</urlset>`;
1720
+ }
1721
+ /** Escape special XML characters. */
1722
+ function escapeXml(str) {
1723
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1648
1724
  }
1725
+ //#endregion
1726
+ //#region src/server/pipeline-interception.ts
1649
1727
  /**
1650
- * 5xx JSON fallback chain (single pass):
1651
- * At each segment (leaf → root): {status}.json → 5xx.json
1652
- * No error.tsx equivalent JSON chain terminates at category catch-all.
1728
+ * Check if an intercepting route applies for this soft navigation.
1729
+ *
1730
+ * Matches the target pathname against interception rewrites, constrained
1731
+ * by the source URL (X-Timber-URL header — where the user navigates FROM).
1732
+ *
1733
+ * Returns the source pathname to re-match if interception applies, or null.
1653
1734
  */
1654
- function resolve5xxJson(status, segments) {
1655
- const statusStr = String(status);
1656
- for (let i = segments.length - 1; i >= 0; i--) {
1657
- const segment = segments[i];
1658
- if (!segment.jsonStatusFiles) continue;
1659
- const exact = segment.jsonStatusFiles.get(statusStr);
1660
- if (exact) return {
1661
- file: exact,
1662
- status,
1663
- kind: "exact",
1664
- segmentIndex: i
1665
- };
1666
- const category = segment.jsonStatusFiles.get("5xx");
1667
- if (category) return {
1668
- file: category,
1669
- status,
1670
- kind: "category",
1671
- segmentIndex: i
1672
- };
1735
+ function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
1736
+ for (const rewrite of rewrites) {
1737
+ if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
1738
+ if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
1673
1739
  }
1674
1740
  return null;
1675
1741
  }
1676
1742
  /**
1677
- * Resolve the denial file for a parallel route slot.
1678
- *
1679
- * Slot denial is graceful degradation — no HTTP status on the wire.
1680
- * Fallback chain: denied.tsx → default.tsx → null.
1743
+ * Check if a pathname matches a URL pattern with dynamic segments.
1681
1744
  *
1682
- * @param slotNode - The segment node for the slot (segmentType === 'slot').
1745
+ * Supports [param] (single segment) and [...param] (one or more segments).
1746
+ * Static segments must match exactly.
1683
1747
  */
1684
- function resolveSlotDenied(slotNode) {
1685
- const slotName = slotNode.segmentName.replace(/^@/, "");
1686
- if (slotNode.denied) return {
1687
- file: slotNode.denied,
1688
- slotName,
1689
- kind: "denied"
1690
- };
1691
- if (slotNode.default) return {
1692
- file: slotNode.default,
1693
- slotName,
1694
- kind: "default"
1695
- };
1696
- return null;
1748
+ function pathnameMatchesPattern(pathname, pattern) {
1749
+ const pathParts = pathname === "/" ? [] : pathname.slice(1).split("/");
1750
+ const patternParts = pattern === "/" ? [] : pattern.slice(1).split("/");
1751
+ let pi = 0;
1752
+ for (let i = 0; i < patternParts.length; i++) {
1753
+ const segment = patternParts[i];
1754
+ if (segment.startsWith("[...") || segment.startsWith("[[...")) return pi < pathParts.length || segment.startsWith("[[...");
1755
+ if (segment.startsWith("[") && segment.endsWith("]")) {
1756
+ if (pi >= pathParts.length) return false;
1757
+ pi++;
1758
+ continue;
1759
+ }
1760
+ if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
1761
+ pi++;
1762
+ }
1763
+ return pi === pathParts.length;
1697
1764
  }
1698
1765
  //#endregion
1699
- //#region src/server/flush.ts
1766
+ //#region src/server/pipeline.ts
1700
1767
  /**
1701
- * Flush controller for timber.js rendering.
1768
+ * Request pipeline — the central dispatch for all timber.js requests.
1702
1769
  *
1703
- * Holds the response until `onShellReady` fires, then commits the HTTP status
1704
- * code and flushes the shell. Render-phase signals (deny, redirect, unhandled
1705
- * throws) caught before flush produce correct HTTP status codes.
1770
+ * Pipeline stages (in order):
1771
+ * proxy.ts canonicalize route match 103 Early Hints → middleware.ts → render
1706
1772
  *
1707
- * See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
1773
+ * Each stage is a pure function or returns a Response to short-circuit.
1774
+ * Each request gets a trace ID, structured logging, and OTEL spans.
1775
+ *
1776
+ * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow",
1777
+ * and design/17-logging.md §"Production Logging"
1708
1778
  */
1709
1779
  /**
1710
- * Execute a render and hold the response until the shell is ready.
1711
- *
1712
- * The flush controller:
1713
- * 1. Calls the render function to start renderToReadableStream
1714
- * 2. Waits for shellReady (onShellReady)
1715
- * 3. If a render-phase signal was thrown (deny, redirect, error), produces
1716
- * the correct HTTP status code
1717
- * 4. If the shell rendered successfully, commits the status and streams
1780
+ * Run segment param coercion on the matched route's segments.
1718
1781
  *
1719
- * Render-phase signals caught before flush:
1720
- * - `DenySignal` HTTP 4xx with appropriate status code
1721
- * - `RedirectSignal` HTTP 3xx with Location header
1722
- * - `RenderError` → HTTP status from error (default 500)
1723
- * - Unhandled error → HTTP 500
1782
+ * Loads params.ts modules from segments that have them, extracts the
1783
+ * segmentParams definition, and coerces raw string params through codecs.
1784
+ * Throws ParamCoercionError if any codec fails (→ 404).
1724
1785
  *
1725
- * @param renderFn - Function that starts the React render.
1726
- * @param options - Flush configuration.
1727
- * @returns The committed HTTP Response.
1728
- */
1729
- async function flushResponse(renderFn, options = {}) {
1730
- const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
1731
- let renderResult;
1732
- try {
1733
- renderResult = await renderFn();
1734
- } catch (error) {
1735
- return handleSignal(error, responseHeaders);
1736
- }
1737
- try {
1738
- await renderResult.shellReady;
1739
- } catch (error) {
1740
- return handleSignal(error, responseHeaders);
1741
- }
1742
- responseHeaders.set("Content-Type", "text/html; charset=utf-8");
1743
- return {
1744
- response: new Response(renderResult.stream, {
1745
- status: defaultStatus,
1746
- headers: responseHeaders
1747
- }),
1748
- status: defaultStatus,
1749
- isRedirect: false,
1750
- isDenial: false
1751
- };
1752
- }
1753
- /**
1754
- * Handle a render-phase signal and produce the correct HTTP response.
1786
+ * This runs BEFORE middleware, so ctx.segmentParams is already typed.
1787
+ * See design/07-routing.md §"Where Coercion Runs"
1755
1788
  */
1756
- function handleSignal(error, responseHeaders) {
1757
- if (error instanceof RedirectSignal) {
1758
- responseHeaders.set("Location", error.location);
1759
- return {
1760
- response: new Response(null, {
1761
- status: error.status,
1762
- headers: responseHeaders
1763
- }),
1764
- status: error.status,
1765
- isRedirect: true,
1766
- isDenial: false
1767
- };
1789
+ async function coerceSegmentParams(match) {
1790
+ const segments = match.segments;
1791
+ for (const segment of segments) {
1792
+ if (!segment.params) continue;
1793
+ const segmentParamsDef = (await segment.params.load()).segmentParams;
1794
+ if (!segmentParamsDef || typeof segmentParamsDef.parse !== "function") continue;
1795
+ try {
1796
+ const coerced = segmentParamsDef.parse(match.params);
1797
+ Object.assign(match.params, coerced);
1798
+ } catch (err) {
1799
+ throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
1800
+ }
1768
1801
  }
1769
- if (error instanceof DenySignal) return {
1770
- response: new Response(null, {
1771
- status: error.status,
1772
- headers: responseHeaders
1773
- }),
1774
- status: error.status,
1775
- isRedirect: false,
1776
- isDenial: true
1777
- };
1778
- if (error instanceof RenderError) return {
1779
- response: new Response(null, {
1780
- status: error.status,
1781
- headers: responseHeaders
1782
- }),
1783
- status: error.status,
1784
- isRedirect: false,
1785
- isDenial: false
1786
- };
1787
- console.error("[timber] Unhandled render-phase error:", error);
1788
- return {
1789
- response: new Response(null, {
1790
- status: 500,
1791
- headers: responseHeaders
1792
- }),
1793
- status: 500,
1794
- isRedirect: false,
1795
- isDenial: false
1796
- };
1797
1802
  }
1798
- //#endregion
1799
- //#region src/server/csrf.ts
1800
- /** HTTP methods that are considered safe (no mutation). */
1801
- var SAFE_METHODS = new Set([
1802
- "GET",
1803
- "HEAD",
1804
- "OPTIONS"
1805
- ]);
1806
1803
  /**
1807
- * Validate the Origin header against the request's Host.
1808
- *
1809
- * For mutation methods (POST, PUT, PATCH, DELETE):
1810
- * - If `csrf: false`, skip validation.
1811
- * - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
1812
- * - Otherwise, Origin's host must match the request's Host header.
1804
+ * Create the request handler from a pipeline configuration.
1813
1805
  *
1814
- * Safe methods (GET, HEAD, OPTIONS) always pass.
1806
+ * Returns a function that processes an incoming Request through all pipeline stages
1807
+ * and produces a Response. This is the top-level entry point for the server.
1815
1808
  */
1816
- function validateCsrf(req, config) {
1817
- if (SAFE_METHODS.has(req.method)) return { ok: true };
1818
- if (config.csrf === false) return { ok: true };
1819
- const origin = req.headers.get("Origin");
1820
- if (!origin) return {
1821
- ok: false,
1822
- status: 403
1823
- };
1824
- if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
1825
- ok: false,
1826
- status: 403
1827
- };
1828
- const host = req.headers.get("Host");
1829
- if (!host) return {
1830
- ok: false,
1831
- status: 403
1809
+ function createPipeline(config) {
1810
+ const { proxy, matchRoute, render, earlyHints, stripTrailingSlash = true, slowRequestMs = 3e3, serverTiming = "total", onPipelineError } = config;
1811
+ let activeRequests = 0;
1812
+ return async (req) => {
1813
+ const url = new URL(req.url);
1814
+ const method = req.method;
1815
+ const path = url.pathname;
1816
+ const startTime = performance.now();
1817
+ activeRequests++;
1818
+ return runWithTraceId(generateTraceId(), async () => {
1819
+ return runWithRequestContext(req, async () => {
1820
+ const runRequest = async () => {
1821
+ logRequestReceived({
1822
+ method,
1823
+ path
1824
+ });
1825
+ const response = await withSpan("http.server.request", {
1826
+ "http.request.method": method,
1827
+ "url.path": path
1828
+ }, async () => {
1829
+ const otelIds = await getOtelTraceId();
1830
+ if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
1831
+ let result;
1832
+ if (proxy || config.proxyLoader) result = await runProxyPhase(req, method, path);
1833
+ else result = await handleRequest(req, method, path);
1834
+ await setSpanAttribute("http.response.status_code", result.status);
1835
+ if (serverTiming === "detailed") {
1836
+ const timingHeader = getServerTimingHeader();
1837
+ if (timingHeader) {
1838
+ result = ensureMutableResponse(result);
1839
+ result.headers.set("Server-Timing", timingHeader);
1840
+ }
1841
+ } else if (serverTiming === "total") {
1842
+ const totalMs = Math.round(performance.now() - startTime);
1843
+ result = ensureMutableResponse(result);
1844
+ result.headers.set("Server-Timing", `total;dur=${totalMs}`);
1845
+ }
1846
+ return result;
1847
+ });
1848
+ const durationMs = Math.round(performance.now() - startTime);
1849
+ const status = response.status;
1850
+ const concurrency = activeRequests;
1851
+ activeRequests--;
1852
+ logRequestCompleted({
1853
+ method,
1854
+ path,
1855
+ status,
1856
+ durationMs,
1857
+ concurrency
1858
+ });
1859
+ if (slowRequestMs > 0 && durationMs > slowRequestMs) logSlowRequest({
1860
+ method,
1861
+ path,
1862
+ durationMs,
1863
+ threshold: slowRequestMs,
1864
+ concurrency
1865
+ });
1866
+ return response;
1867
+ };
1868
+ return serverTiming === "detailed" ? runWithTimingCollector(runRequest) : runRequest();
1869
+ });
1870
+ });
1832
1871
  };
1833
- let originHost;
1834
- try {
1835
- originHost = new URL(origin).host;
1836
- } catch {
1837
- return {
1838
- ok: false,
1839
- status: 403
1840
- };
1872
+ async function runProxyPhase(req, method, path) {
1873
+ try {
1874
+ let proxyExport;
1875
+ if (config.proxyLoader) proxyExport = (await config.proxyLoader()).default;
1876
+ else proxyExport = config.proxy;
1877
+ const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
1878
+ return await withSpan("timber.proxy", {}, () => serverTiming === "detailed" ? withTiming("proxy", "proxy.ts", proxyFn) : proxyFn());
1879
+ } catch (error) {
1880
+ logProxyError({ error });
1881
+ await fireOnRequestError(error, req, "proxy");
1882
+ if (onPipelineError && error instanceof Error) onPipelineError(error, "proxy");
1883
+ return new Response(null, { status: 500 });
1884
+ }
1841
1885
  }
1842
- return originHost === host ? { ok: true } : {
1843
- ok: false,
1844
- status: 403
1845
- };
1846
- }
1847
- //#endregion
1848
- //#region src/server/body-limits.ts
1849
- var KB = 1024;
1850
- var MB = 1024 * KB;
1851
- var GB = 1024 * MB;
1852
- var DEFAULT_LIMITS = {
1853
- actionBodySize: 1 * MB,
1854
- uploadBodySize: 10 * MB,
1855
- maxFields: 100
1856
- };
1857
- var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
1858
- /** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
1859
- function parseBodySize(size) {
1860
- const match = SIZE_PATTERN.exec(size.trim());
1861
- if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
1862
- const value = Number.parseFloat(match[1]);
1863
- const unit = (match[2] ?? "").toLowerCase();
1864
- switch (unit) {
1865
- case "kb": return Math.floor(value * KB);
1866
- case "mb": return Math.floor(value * MB);
1867
- case "gb": return Math.floor(value * GB);
1868
- case "": return Math.floor(value);
1869
- default: throw new Error(`Unknown size unit: "${unit}"`);
1886
+ /**
1887
+ * Build a redirect Response from a RedirectSignal.
1888
+ *
1889
+ * For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
1890
+ * so the client router can perform a soft SPA redirect. A raw 302 would be
1891
+ * turned into an opaque redirect by fetch({redirect:'manual'}), crashing
1892
+ * createFromFetch. See design/19-client-navigation.md.
1893
+ */
1894
+ function buildRedirectResponse(signal, req, headers) {
1895
+ if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
1896
+ headers.set("X-Timber-Redirect", signal.location);
1897
+ return new Response(null, {
1898
+ status: 204,
1899
+ headers
1900
+ });
1901
+ }
1902
+ headers.set("Location", signal.location);
1903
+ return new Response(null, {
1904
+ status: signal.status,
1905
+ headers
1906
+ });
1870
1907
  }
1871
- }
1872
- /** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
1873
- function enforceBodyLimits(req, kind, config) {
1874
- const contentLength = req.headers.get("Content-Length");
1875
- if (!contentLength) return {
1876
- ok: false,
1877
- status: 411
1878
- };
1879
- const bodySize = Number.parseInt(contentLength, 10);
1880
- if (Number.isNaN(bodySize)) return {
1881
- ok: false,
1882
- status: 411
1883
- };
1884
- return bodySize <= resolveLimit(kind, config) ? { ok: true } : {
1885
- ok: false,
1886
- status: 413
1887
- };
1888
- }
1889
- /**
1890
- * Resolve the byte limit for a given body kind, using config overrides or defaults.
1891
- */
1892
- function resolveLimit(kind, config) {
1893
- const userLimits = config.limits;
1894
- if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
1895
- return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
1896
- }
1897
- //#endregion
1898
- //#region src/server/metadata-social.ts
1899
- /**
1900
- * Render Open Graph metadata into head element descriptors.
1901
- *
1902
- * Handles og:title, og:description, og:image (with dimensions/alt),
1903
- * og:video, og:audio, og:article:author, and other OG properties.
1904
- */
1905
- function renderOpenGraph(og, elements) {
1906
- const simpleProps = [
1907
- ["og:title", og.title],
1908
- ["og:description", og.description],
1909
- ["og:url", og.url],
1910
- ["og:site_name", og.siteName],
1911
- ["og:locale", og.locale],
1912
- ["og:type", og.type],
1913
- ["og:article:published_time", og.publishedTime],
1914
- ["og:article:modified_time", og.modifiedTime]
1915
- ];
1916
- for (const [property, content] of simpleProps) if (content) elements.push({
1917
- tag: "meta",
1918
- attrs: {
1919
- property,
1920
- content
1908
+ async function handleRequest(req, method, path) {
1909
+ const result = canonicalize(new URL(req.url).pathname, stripTrailingSlash);
1910
+ if (!result.ok) return new Response(null, { status: result.status });
1911
+ const canonicalPathname = result.pathname;
1912
+ if (config.matchMetadataRoute) {
1913
+ const metaMatch = config.matchMetadataRoute(canonicalPathname);
1914
+ if (metaMatch) try {
1915
+ if (metaMatch.isStatic) return await serveStaticMetadataFile(metaMatch);
1916
+ const mod = await metaMatch.file.load();
1917
+ if (typeof mod.default !== "function") return new Response("Metadata route must export a default function", { status: 500 });
1918
+ const handlerResult = await mod.default();
1919
+ if (handlerResult instanceof Response) return handlerResult;
1920
+ const contentType = metaMatch.contentType;
1921
+ let body;
1922
+ if (typeof handlerResult === "string") body = handlerResult;
1923
+ else if (contentType === "application/xml") body = serializeSitemap(handlerResult);
1924
+ else if (contentType === "application/manifest+json") body = JSON.stringify(handlerResult, null, 2);
1925
+ else body = typeof handlerResult === "string" ? handlerResult : String(handlerResult);
1926
+ return new Response(body, {
1927
+ status: 200,
1928
+ headers: { "Content-Type": `${contentType}; charset=utf-8` }
1929
+ });
1930
+ } catch (error) {
1931
+ logRenderError({
1932
+ method,
1933
+ path,
1934
+ error
1935
+ });
1936
+ if (onPipelineError && error instanceof Error) onPipelineError(error, "metadata-route");
1937
+ return new Response(null, { status: 500 });
1938
+ }
1921
1939
  }
1922
- });
1923
- if (og.images) if (typeof og.images === "string") elements.push({
1924
- tag: "meta",
1925
- attrs: {
1926
- property: "og:image",
1927
- content: og.images
1940
+ if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
1941
+ if (!checkVersionSkew(req).ok) {
1942
+ const reloadHeaders = new Headers();
1943
+ applyReloadHeaders(reloadHeaders);
1944
+ return new Response(null, {
1945
+ status: 204,
1946
+ headers: reloadHeaders
1947
+ });
1948
+ }
1928
1949
  }
1929
- });
1930
- else {
1931
- const imgList = Array.isArray(og.images) ? og.images : [og.images];
1932
- for (const img of imgList) {
1933
- elements.push({
1934
- tag: "meta",
1935
- attrs: {
1936
- property: "og:image",
1937
- content: img.url
1950
+ let match = matchRoute(canonicalPathname);
1951
+ let interception;
1952
+ const sourceUrl = req.headers.get("X-Timber-URL");
1953
+ if (sourceUrl && config.interceptionRewrites?.length) {
1954
+ const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
1955
+ if (intercepted) {
1956
+ const sourceMatch = matchRoute(intercepted.sourcePathname);
1957
+ if (sourceMatch) {
1958
+ match = sourceMatch;
1959
+ interception = { targetPathname: canonicalPathname };
1938
1960
  }
1939
- });
1940
- if (img.width) elements.push({
1941
- tag: "meta",
1942
- attrs: {
1943
- property: "og:image:width",
1944
- content: String(img.width)
1961
+ }
1962
+ }
1963
+ if (!match) {
1964
+ if (config.renderNoMatch) {
1965
+ const responseHeaders = new Headers();
1966
+ return config.renderNoMatch(req, responseHeaders);
1967
+ }
1968
+ return new Response(null, { status: 404 });
1969
+ }
1970
+ const responseHeaders = new Headers();
1971
+ const requestHeaderOverlay = new Headers();
1972
+ responseHeaders.set("Cache-Control", "private, no-cache, no-store, max-age=0, must-revalidate");
1973
+ if (earlyHints) try {
1974
+ await earlyHints(match, req, responseHeaders);
1975
+ } catch {}
1976
+ try {
1977
+ await coerceSegmentParams(match);
1978
+ } catch (error) {
1979
+ if (error instanceof ParamCoercionError) return new Response(null, { status: 404 });
1980
+ throw error;
1981
+ }
1982
+ setSegmentParams(match.params);
1983
+ if (match.middleware) {
1984
+ const ctx = {
1985
+ req,
1986
+ requestHeaders: requestHeaderOverlay,
1987
+ headers: responseHeaders,
1988
+ segmentParams: match.params,
1989
+ earlyHints: (hints) => {
1990
+ for (const hint of hints) {
1991
+ let value;
1992
+ if (hint.as !== void 0) value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
1993
+ else value = `<${hint.href}>; rel=${hint.rel}`;
1994
+ if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
1995
+ if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
1996
+ responseHeaders.append("Link", value);
1997
+ }
1945
1998
  }
1946
- });
1947
- if (img.height) elements.push({
1948
- tag: "meta",
1949
- attrs: {
1950
- property: "og:image:height",
1951
- content: String(img.height)
1999
+ };
2000
+ try {
2001
+ setMutableCookieContext(true);
2002
+ const middlewareFn = () => runMiddleware(match.middleware, ctx);
2003
+ const middlewareResponse = await withSpan("timber.middleware", {}, () => serverTiming === "detailed" ? withTiming("mw", "middleware.ts", middlewareFn) : middlewareFn());
2004
+ setMutableCookieContext(false);
2005
+ if (middlewareResponse) {
2006
+ const finalResponse = ensureMutableResponse(middlewareResponse);
2007
+ applyCookieJar(finalResponse.headers);
2008
+ logMiddlewareShortCircuit({
2009
+ method,
2010
+ path,
2011
+ status: finalResponse.status
2012
+ });
2013
+ return finalResponse;
1952
2014
  }
1953
- });
1954
- if (img.alt) elements.push({
1955
- tag: "meta",
1956
- attrs: {
1957
- property: "og:image:alt",
1958
- content: img.alt
2015
+ applyRequestHeaderOverlay(requestHeaderOverlay);
2016
+ } catch (error) {
2017
+ setMutableCookieContext(false);
2018
+ if (error instanceof RedirectSignal) {
2019
+ applyCookieJar(responseHeaders);
2020
+ return buildRedirectResponse(error, req, responseHeaders);
1959
2021
  }
2022
+ if (error instanceof DenySignal) return new Response(null, { status: error.status });
2023
+ logMiddlewareError({
2024
+ method,
2025
+ path,
2026
+ error
2027
+ });
2028
+ await fireOnRequestError(error, req, "handler");
2029
+ if (onPipelineError && error instanceof Error) onPipelineError(error, "middleware");
2030
+ return new Response(null, { status: 500 });
2031
+ }
2032
+ }
2033
+ applyCookieJar(responseHeaders);
2034
+ try {
2035
+ const renderFn = () => render(req, match, responseHeaders, requestHeaderOverlay, interception);
2036
+ const response = await withSpan("timber.render", { "http.route": canonicalPathname }, () => serverTiming === "detailed" ? withTiming("render", "RSC + SSR render", renderFn) : renderFn());
2037
+ markResponseFlushed();
2038
+ return response;
2039
+ } catch (error) {
2040
+ if (error instanceof DenySignal) return new Response(null, { status: error.status });
2041
+ if (error instanceof RedirectSignal) return buildRedirectResponse(error, req, responseHeaders);
2042
+ logRenderError({
2043
+ method,
2044
+ path,
2045
+ error
1960
2046
  });
2047
+ await fireOnRequestError(error, req, "render");
2048
+ if (onPipelineError && error instanceof Error) onPipelineError(error, "render");
2049
+ if (config.renderFallbackError) try {
2050
+ return await config.renderFallbackError(error, req, responseHeaders);
2051
+ } catch {}
2052
+ return new Response(null, { status: 500 });
1961
2053
  }
1962
2054
  }
1963
- if (og.videos) for (const video of og.videos) elements.push({
1964
- tag: "meta",
1965
- attrs: {
1966
- property: "og:video",
1967
- content: video.url
1968
- }
1969
- });
1970
- if (og.audio) for (const audio of og.audio) elements.push({
1971
- tag: "meta",
1972
- attrs: {
1973
- property: "og:audio",
1974
- content: audio.url
1975
- }
2055
+ }
2056
+ /**
2057
+ * Fire the user's onRequestError hook with request context.
2058
+ * Extracts request info from the Request object and calls the instrumentation hook.
2059
+ */
2060
+ async function fireOnRequestError(error, req, phase) {
2061
+ const url = new URL(req.url);
2062
+ const headersObj = {};
2063
+ req.headers.forEach((v, k) => {
2064
+ headersObj[k] = v;
1976
2065
  });
1977
- if (og.authors) for (const author of og.authors) elements.push({
1978
- tag: "meta",
1979
- attrs: {
1980
- property: "og:article:author",
1981
- content: author
1982
- }
2066
+ await callOnRequestError(error, {
2067
+ method: req.method,
2068
+ path: url.pathname,
2069
+ headers: headersObj
2070
+ }, {
2071
+ phase,
2072
+ routePath: url.pathname,
2073
+ routeType: "page",
2074
+ traceId: traceId()
1983
2075
  });
1984
2076
  }
1985
2077
  /**
1986
- * Render Twitter Card metadata into head element descriptors.
2078
+ * Apply all Set-Cookie headers from the cookie jar to a Headers object.
2079
+ * Each cookie gets its own Set-Cookie header per RFC 6265 §4.1.
2080
+ */
2081
+ function applyCookieJar(headers) {
2082
+ for (const value of getSetCookieHeaders()) headers.append("Set-Cookie", value);
2083
+ }
2084
+ /**
2085
+ * Ensure a Response has mutable headers so the pipeline can safely append
2086
+ * Set-Cookie and Server-Timing entries.
1987
2087
  *
1988
- * Handles twitter:card, twitter:site, twitter:title, twitter:image,
1989
- * twitter:player, and twitter:app (per-platform name/id/url).
2088
+ * `Response.redirect()` and some platform-level responses return objects
2089
+ * with immutable headers. Calling `.set()` or `.append()` on them throws
2090
+ * `TypeError: immutable`. This helper detects the immutable case by
2091
+ * attempting a no-op write and, on failure, clones into a fresh Response
2092
+ * with mutable headers.
1990
2093
  */
1991
- function renderTwitter(tw, elements) {
1992
- const simpleProps = [
1993
- ["twitter:card", tw.card],
1994
- ["twitter:site", tw.site],
1995
- ["twitter:site:id", tw.siteId],
1996
- ["twitter:title", tw.title],
1997
- ["twitter:description", tw.description],
1998
- ["twitter:creator", tw.creator],
1999
- ["twitter:creator:id", tw.creatorId]
2000
- ];
2001
- for (const [name, content] of simpleProps) if (content) elements.push({
2002
- tag: "meta",
2003
- attrs: {
2004
- name,
2005
- content
2006
- }
2007
- });
2008
- if (tw.images) if (typeof tw.images === "string") elements.push({
2009
- tag: "meta",
2010
- attrs: {
2011
- name: "twitter:image",
2012
- content: tw.images
2013
- }
2014
- });
2015
- else {
2016
- const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
2017
- for (const img of imgList) {
2018
- const url = typeof img === "string" ? img : img.url;
2019
- elements.push({
2020
- tag: "meta",
2021
- attrs: {
2022
- name: "twitter:image",
2023
- content: url
2024
- }
2025
- });
2026
- }
2027
- }
2028
- if (tw.players) for (const player of tw.players) {
2029
- elements.push({
2030
- tag: "meta",
2031
- attrs: {
2032
- name: "twitter:player",
2033
- content: player.playerUrl
2034
- }
2035
- });
2036
- if (player.width) elements.push({
2037
- tag: "meta",
2038
- attrs: {
2039
- name: "twitter:player:width",
2040
- content: String(player.width)
2041
- }
2042
- });
2043
- if (player.height) elements.push({
2044
- tag: "meta",
2045
- attrs: {
2046
- name: "twitter:player:height",
2047
- content: String(player.height)
2048
- }
2049
- });
2050
- if (player.streamUrl) elements.push({
2051
- tag: "meta",
2052
- attrs: {
2053
- name: "twitter:player:stream",
2054
- content: player.streamUrl
2055
- }
2094
+ function ensureMutableResponse(response) {
2095
+ try {
2096
+ response.headers.set("X-Timber-Probe", "1");
2097
+ response.headers.delete("X-Timber-Probe");
2098
+ return response;
2099
+ } catch {
2100
+ return new Response(response.body, {
2101
+ status: response.status,
2102
+ statusText: response.statusText,
2103
+ headers: new Headers(response.headers)
2056
2104
  });
2057
2105
  }
2058
- if (tw.app) {
2059
- const platforms = [
2060
- ["iPhone", "iphone"],
2061
- ["iPad", "ipad"],
2062
- ["googlePlay", "googleplay"]
2063
- ];
2064
- if (tw.app.name) {
2065
- for (const [key, tag] of platforms) if (tw.app.id?.[key]) elements.push({
2066
- tag: "meta",
2067
- attrs: {
2068
- name: `twitter:app:name:${tag}`,
2069
- content: tw.app.name
2070
- }
2071
- });
2072
- }
2073
- for (const [key, tag] of platforms) {
2074
- const id = tw.app.id?.[key];
2075
- if (id) elements.push({
2076
- tag: "meta",
2077
- attrs: {
2078
- name: `twitter:app:id:${tag}`,
2079
- content: id
2080
- }
2081
- });
2106
+ }
2107
+ //#endregion
2108
+ //#region src/server/build-manifest.ts
2109
+ /**
2110
+ * Collect all CSS files needed for a matched route's segment chain.
2111
+ *
2112
+ * Walks segments root → leaf, collecting CSS for each layout and page.
2113
+ * Deduplicates while preserving order (root layout CSS first).
2114
+ */
2115
+ function collectRouteCss(segments, manifest) {
2116
+ const seen = /* @__PURE__ */ new Set();
2117
+ const result = [];
2118
+ for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2119
+ if (!file) continue;
2120
+ const cssFiles = manifest.css[file.filePath];
2121
+ if (!cssFiles) continue;
2122
+ for (const url of cssFiles) if (!seen.has(url)) {
2123
+ seen.add(url);
2124
+ result.push(url);
2082
2125
  }
2083
- for (const [key, tag] of platforms) {
2084
- const url = tw.app.url?.[key];
2085
- if (url) elements.push({
2086
- tag: "meta",
2087
- attrs: {
2088
- name: `twitter:app:url:${tag}`,
2089
- content: url
2090
- }
2091
- });
2126
+ }
2127
+ return result;
2128
+ }
2129
+ /**
2130
+ * Collect all font entries needed for a matched route's segment chain.
2131
+ *
2132
+ * Walks segments root → leaf, collecting fonts for each layout and page.
2133
+ * Deduplicates by href while preserving order.
2134
+ */
2135
+ function collectRouteFonts(segments, manifest) {
2136
+ const seen = /* @__PURE__ */ new Set();
2137
+ const result = [];
2138
+ for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2139
+ if (!file) continue;
2140
+ const fonts = manifest.fonts[file.filePath];
2141
+ if (!fonts) continue;
2142
+ for (const entry of fonts) if (!seen.has(entry.href)) {
2143
+ seen.add(entry.href);
2144
+ result.push(entry);
2092
2145
  }
2093
2146
  }
2147
+ return result;
2094
2148
  }
2095
- //#endregion
2096
- //#region src/server/metadata-platform.ts
2097
2149
  /**
2098
- * Render icon link elements (favicon, shortcut, apple-touch-icon, custom).
2150
+ * Collect modulepreload URLs for a matched route's segment chain.
2151
+ *
2152
+ * Walks segments root → leaf, collecting transitive JS dependencies
2153
+ * for each layout and page. Deduplicates across segments.
2099
2154
  */
2100
- function renderIcons(icons, elements) {
2101
- if (icons.icon) {
2102
- if (typeof icons.icon === "string") elements.push({
2103
- tag: "link",
2104
- attrs: {
2105
- rel: "icon",
2106
- href: icons.icon
2107
- }
2108
- });
2109
- else if (Array.isArray(icons.icon)) for (const icon of icons.icon) {
2110
- const attrs = {
2111
- rel: "icon",
2112
- href: icon.url
2113
- };
2114
- if (icon.sizes) attrs.sizes = icon.sizes;
2115
- if (icon.type) attrs.type = icon.type;
2116
- elements.push({
2117
- tag: "link",
2118
- attrs
2119
- });
2120
- }
2121
- }
2122
- if (icons.shortcut) {
2123
- const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
2124
- for (const url of urls) elements.push({
2125
- tag: "link",
2126
- attrs: {
2127
- rel: "shortcut icon",
2128
- href: url
2129
- }
2130
- });
2131
- }
2132
- if (icons.apple) {
2133
- if (typeof icons.apple === "string") elements.push({
2134
- tag: "link",
2135
- attrs: {
2136
- rel: "apple-touch-icon",
2137
- href: icons.apple
2138
- }
2139
- });
2140
- else if (Array.isArray(icons.apple)) for (const icon of icons.apple) {
2141
- const attrs = {
2142
- rel: "apple-touch-icon",
2143
- href: icon.url
2144
- };
2145
- if (icon.sizes) attrs.sizes = icon.sizes;
2146
- elements.push({
2147
- tag: "link",
2148
- attrs
2149
- });
2155
+ function collectRouteModulepreloads(segments, manifest) {
2156
+ const seen = /* @__PURE__ */ new Set();
2157
+ const result = [];
2158
+ for (const segment of segments) for (const file of [segment.layout, segment.page]) {
2159
+ if (!file) continue;
2160
+ const preloads = manifest.modulepreload[file.filePath];
2161
+ if (!preloads) continue;
2162
+ for (const url of preloads) if (!seen.has(url)) {
2163
+ seen.add(url);
2164
+ result.push(url);
2150
2165
  }
2151
2166
  }
2152
- if (icons.other) for (const icon of icons.other) {
2153
- const attrs = {
2154
- rel: icon.rel,
2155
- href: icon.url
2156
- };
2157
- if (icon.sizes) attrs.sizes = icon.sizes;
2158
- if (icon.type) attrs.type = icon.type;
2159
- elements.push({
2160
- tag: "link",
2161
- attrs
2162
- });
2167
+ return result;
2168
+ }
2169
+ //#endregion
2170
+ //#region src/server/early-hints.ts
2171
+ /**
2172
+ * 103 Early Hints utilities.
2173
+ *
2174
+ * Early Hints are sent before the final response to let the browser
2175
+ * start fetching critical resources (CSS, fonts, JS) while the server
2176
+ * is still rendering.
2177
+ *
2178
+ * The framework collects hints from two sources:
2179
+ * 1. Build manifest — CSS, fonts, and JS chunks known at route-match time
2180
+ * 2. ctx.earlyHints() — explicit hints added by middleware or route handlers
2181
+ *
2182
+ * Both are emitted as Link headers. Cloudflare CDN automatically converts
2183
+ * Link headers into 103 Early Hints responses.
2184
+ *
2185
+ * Design docs: 02-rendering-pipeline.md §"Early Hints (103)"
2186
+ */
2187
+ /**
2188
+ * Format a single EarlyHint as a Link header value.
2189
+ *
2190
+ * Attribute order: `as` before `rel` to match Cloudflare CDN's cached
2191
+ * Early Hints format. Cloudflare caches Link headers from 200 responses
2192
+ * and re-emits them as 103 Early Hints on subsequent requests. If our
2193
+ * attribute order differs from Cloudflare's cached copy, the browser
2194
+ * sees two preload headers for the same URL (different attribute order)
2195
+ * and warns "Preload was ignored." Matching the order ensures the
2196
+ * browser deduplicates them correctly.
2197
+ *
2198
+ * Examples:
2199
+ * `</styles/root.css>; as=style; rel=preload`
2200
+ * `</fonts/inter.woff2>; as=font; rel=preload; crossorigin=anonymous`
2201
+ * `</_timber/client.js>; rel=modulepreload`
2202
+ * `<https://fonts.googleapis.com>; rel=preconnect`
2203
+ */
2204
+ function formatLinkHeader(hint) {
2205
+ if (hint.as !== void 0) {
2206
+ let value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
2207
+ if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2208
+ if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2209
+ return value;
2163
2210
  }
2211
+ let value = `<${hint.href}>; rel=${hint.rel}`;
2212
+ if (hint.crossOrigin !== void 0) value += `; crossorigin=${hint.crossOrigin}`;
2213
+ if (hint.fetchPriority !== void 0) value += `; fetchpriority=${hint.fetchPriority}`;
2214
+ return value;
2164
2215
  }
2165
2216
  /**
2166
- * Render alternate link elements (canonical, hreflang, media, types).
2217
+ * Collect all Link header strings for a matched route's segment chain.
2218
+ *
2219
+ * Walks the build manifest to emit hints for:
2220
+ * - CSS stylesheets (as=style; rel=preload)
2221
+ * - Font assets (as=font; rel=preload; crossorigin)
2222
+ * - JS modulepreload hints (rel=modulepreload) — unless skipJs is set
2223
+ *
2224
+ * Also emits global CSS from the `_global` manifest key. Route files
2225
+ * are server components that don't appear in the client bundle, so
2226
+ * per-route CSS keying doesn't work with the RSC plugin. The `_global`
2227
+ * key contains all CSS assets from the client build — fine for early
2228
+ * hints since they're just prefetch signals.
2229
+ *
2230
+ * Returns formatted Link header strings, deduplicated by URL, root → leaf order.
2231
+ * Returns an empty array in dev mode (manifest is empty).
2167
2232
  */
2168
- function renderAlternates(alternates, elements) {
2169
- if (alternates.canonical) elements.push({
2170
- tag: "link",
2171
- attrs: {
2172
- rel: "canonical",
2173
- href: alternates.canonical
2174
- }
2175
- });
2176
- if (alternates.languages) for (const [lang, href] of Object.entries(alternates.languages)) elements.push({
2177
- tag: "link",
2178
- attrs: {
2179
- rel: "alternate",
2180
- hreflang: lang,
2181
- href
2182
- }
2183
- });
2184
- if (alternates.media) for (const [media, href] of Object.entries(alternates.media)) elements.push({
2185
- tag: "link",
2186
- attrs: {
2187
- rel: "alternate",
2188
- media,
2189
- href
2190
- }
2191
- });
2192
- if (alternates.types) for (const [type, href] of Object.entries(alternates.types)) elements.push({
2193
- tag: "link",
2194
- attrs: {
2195
- rel: "alternate",
2196
- type,
2197
- href
2233
+ function collectEarlyHintHeaders(segments, manifest, options) {
2234
+ const result = [];
2235
+ const seenUrls = /* @__PURE__ */ new Set();
2236
+ const add = (url, header) => {
2237
+ if (!seenUrls.has(url)) {
2238
+ seenUrls.add(url);
2239
+ result.push(header);
2198
2240
  }
2199
- });
2241
+ };
2242
+ for (const url of collectRouteCss(segments, manifest)) add(url, formatLinkHeader({
2243
+ href: url,
2244
+ rel: "preload",
2245
+ as: "style"
2246
+ }));
2247
+ for (const url of manifest.css["_global"] ?? []) add(url, formatLinkHeader({
2248
+ href: url,
2249
+ rel: "preload",
2250
+ as: "style"
2251
+ }));
2252
+ for (const font of collectRouteFonts(segments, manifest)) add(font.href, formatLinkHeader({
2253
+ href: font.href,
2254
+ rel: "preload",
2255
+ as: "font",
2256
+ crossOrigin: "anonymous"
2257
+ }));
2258
+ if (!options?.skipJs) for (const url of collectRouteModulepreloads(segments, manifest)) add(url, formatLinkHeader({
2259
+ href: url,
2260
+ rel: "modulepreload"
2261
+ }));
2262
+ return result;
2263
+ }
2264
+ //#endregion
2265
+ //#region src/server/early-hints-sender.ts
2266
+ /**
2267
+ * Per-request 103 Early Hints sender — ALS bridge for platform adapters.
2268
+ *
2269
+ * The pipeline collects Link headers for CSS, fonts, and JS chunks at
2270
+ * route-match time. On platforms that support it (Node.js v18.11+, Bun),
2271
+ * the adapter can send these as a 103 Early Hints interim response before
2272
+ * the final response is ready.
2273
+ *
2274
+ * This module provides an ALS-based bridge: the generated entry point
2275
+ * (e.g., the Nitro entry) wraps the handler with `runWithEarlyHintsSender`,
2276
+ * binding a per-request sender function. The pipeline calls
2277
+ * `sendEarlyHints103()` to fire the 103 if a sender is available.
2278
+ *
2279
+ * On platforms where 103 is handled at the CDN level (e.g., Cloudflare
2280
+ * converts Link headers into 103 automatically), no sender is installed
2281
+ * and `sendEarlyHints103()` is a no-op.
2282
+ *
2283
+ * Design doc: 02-rendering-pipeline.md §"Early Hints (103)"
2284
+ */
2285
+ /**
2286
+ * Run a function with a per-request early hints sender installed.
2287
+ *
2288
+ * Called by generated entry points (e.g., Nitro node-server/bun) to
2289
+ * bind the platform's writeEarlyHints capability for the request duration.
2290
+ */
2291
+ function runWithEarlyHintsSender(sender, fn) {
2292
+ return earlyHintsSenderAls.run(sender, fn);
2200
2293
  }
2201
2294
  /**
2202
- * Render site verification meta tags (Google, Yahoo, Yandex, custom).
2295
+ * Send collected Link headers as a 103 Early Hints response.
2296
+ *
2297
+ * No-op if no sender is installed for the current request (e.g., on
2298
+ * Cloudflare where the CDN handles 103 automatically, or in dev mode).
2299
+ *
2300
+ * Non-fatal: errors from the sender are caught and silently ignored.
2203
2301
  */
2204
- function renderVerification(verification, elements) {
2205
- const verificationProps = [
2206
- ["google-site-verification", verification.google],
2207
- ["y_key", verification.yahoo],
2208
- ["yandex-verification", verification.yandex]
2209
- ];
2210
- for (const [name, content] of verificationProps) if (content) elements.push({
2211
- tag: "meta",
2212
- attrs: {
2213
- name,
2214
- content
2215
- }
2216
- });
2217
- if (verification.other) for (const [name, value] of Object.entries(verification.other)) {
2218
- const content = Array.isArray(value) ? value.join(", ") : value;
2219
- elements.push({
2220
- tag: "meta",
2221
- attrs: {
2222
- name,
2223
- content
2224
- }
2225
- });
2226
- }
2302
+ function sendEarlyHints103(links) {
2303
+ if (!links.length) return;
2304
+ const sender = earlyHintsSenderAls.getStore();
2305
+ if (!sender) return;
2306
+ try {
2307
+ sender(links);
2308
+ } catch {}
2227
2309
  }
2310
+ //#endregion
2311
+ //#region src/server/tree-builder.ts
2228
2312
  /**
2229
- * Render Apple Web App meta tags and startup image links.
2313
+ * Build the unified element tree from a matched segment chain.
2314
+ *
2315
+ * Construction is bottom-up:
2316
+ * 1. Start with the page component (leaf segment)
2317
+ * 2. Wrap in status-code error boundaries (fallback chain)
2318
+ * 3. Wrap in AccessGate (if segment has access.ts)
2319
+ * 4. Pass as children to the segment's layout
2320
+ * 5. Repeat up the segment chain to root
2321
+ *
2322
+ * Parallel slots are resolved at each layout level and composed as named props.
2230
2323
  */
2231
- function renderAppleWebApp(appleWebApp, elements) {
2232
- if (appleWebApp.capable) elements.push({
2233
- tag: "meta",
2234
- attrs: {
2235
- name: "apple-mobile-web-app-capable",
2236
- content: "yes"
2237
- }
2238
- });
2239
- if (appleWebApp.title) elements.push({
2240
- tag: "meta",
2241
- attrs: {
2242
- name: "apple-mobile-web-app-title",
2243
- content: appleWebApp.title
2244
- }
2245
- });
2246
- if (appleWebApp.statusBarStyle) elements.push({
2247
- tag: "meta",
2248
- attrs: {
2249
- name: "apple-mobile-web-app-status-bar-style",
2250
- content: appleWebApp.statusBarStyle
2251
- }
2252
- });
2253
- if (appleWebApp.startupImage) {
2254
- const images = Array.isArray(appleWebApp.startupImage) ? appleWebApp.startupImage : [{ url: appleWebApp.startupImage }];
2255
- for (const img of images) {
2256
- const attrs = {
2257
- rel: "apple-touch-startup-image",
2258
- href: typeof img === "string" ? img : img.url
2259
- };
2260
- if (typeof img === "object" && img.media) attrs.media = img.media;
2261
- elements.push({
2262
- tag: "link",
2263
- attrs
2324
+ async function buildElementTree(config) {
2325
+ const { segments, loadModule, createElement, errorBoundaryComponent } = config;
2326
+ if (segments.length === 0) throw new Error("[timber] buildElementTree: empty segment chain");
2327
+ const leaf = segments[segments.length - 1];
2328
+ if (leaf.route && !leaf.page) return {
2329
+ tree: null,
2330
+ isApiRoute: true
2331
+ };
2332
+ const PageComponent = (leaf.page ? await loadModule(leaf.page) : null)?.default;
2333
+ 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.`);
2334
+ let element = createElement(PageComponent, {});
2335
+ for (let i = segments.length - 1; i >= 0; i--) {
2336
+ const segment = segments[i];
2337
+ element = await wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent);
2338
+ if (segment.access) {
2339
+ const accessFn = (await loadModule(segment.access)).default;
2340
+ element = createElement("timber:access-gate", {
2341
+ accessFn,
2342
+ segmentName: segment.segmentName,
2343
+ children: element
2264
2344
  });
2265
2345
  }
2346
+ if (segment.layout) {
2347
+ const LayoutComponent = (await loadModule(segment.layout)).default;
2348
+ if (LayoutComponent) {
2349
+ const slotProps = {};
2350
+ if (segment.slots.size > 0) for (const [slotName, slotNode] of segment.slots) slotProps[slotName] = await buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent);
2351
+ element = createElement(LayoutComponent, {
2352
+ ...slotProps,
2353
+ children: element
2354
+ });
2355
+ }
2356
+ }
2266
2357
  }
2358
+ return {
2359
+ tree: element,
2360
+ isApiRoute: false
2361
+ };
2267
2362
  }
2268
2363
  /**
2269
- * Render App Links (al:*) meta tags for deep linking across platforms.
2364
+ * Build the element tree for a parallel slot.
2365
+ *
2366
+ * Slots have their own access.ts (SlotAccessGate) and error boundaries.
2367
+ * On access denial: denied.tsx → default.tsx → null (graceful degradation).
2270
2368
  */
2271
- function renderAppLinks(appLinks, elements) {
2272
- const platformEntries = [
2273
- ["ios", appLinks.ios],
2274
- ["android", appLinks.android],
2275
- ["windows", appLinks.windows],
2276
- ["windows_phone", appLinks.windowsPhone],
2277
- ["windows_universal", appLinks.windowsUniversal]
2278
- ];
2279
- for (const [platform, entries] of platformEntries) {
2280
- if (!entries) continue;
2281
- for (const entry of entries) for (const [key, value] of Object.entries(entry)) if (value !== void 0 && value !== null) elements.push({
2282
- tag: "meta",
2283
- attrs: {
2284
- property: `al:${platform}:${key}`,
2285
- content: String(value)
2286
- }
2287
- });
2288
- }
2289
- if (appLinks.web) {
2290
- if (appLinks.web.url) elements.push({
2291
- tag: "meta",
2292
- attrs: {
2293
- property: "al:web:url",
2294
- content: appLinks.web.url
2295
- }
2296
- });
2297
- if (appLinks.web.shouldFallback !== void 0) elements.push({
2298
- tag: "meta",
2299
- attrs: {
2300
- property: "al:web:should_fallback",
2301
- content: appLinks.web.shouldFallback ? "true" : "false"
2302
- }
2369
+ async function buildSlotElement(slotNode, loadModule, createElement, errorBoundaryComponent) {
2370
+ const PageComponent = (slotNode.page ? await loadModule(slotNode.page) : null)?.default;
2371
+ const DefaultComponent = (slotNode.default ? await loadModule(slotNode.default) : null)?.default;
2372
+ if (!PageComponent) return DefaultComponent ? createElement(DefaultComponent, {}) : null;
2373
+ let element = createElement(PageComponent, {});
2374
+ element = await wrapWithErrorBoundaries(slotNode, element, loadModule, createElement, errorBoundaryComponent);
2375
+ if (slotNode.access) {
2376
+ const accessFn = (await loadModule(slotNode.access)).default;
2377
+ const DeniedComponent = (slotNode.denied ? await loadModule(slotNode.denied) : null)?.default ?? null;
2378
+ const defaultFallback = DefaultComponent ? createElement(DefaultComponent, {}) : null;
2379
+ element = createElement("timber:slot-access-gate", {
2380
+ accessFn,
2381
+ DeniedComponent,
2382
+ slotName: slotNode.segmentName.replace(/^@/, ""),
2383
+ createElement,
2384
+ defaultFallback,
2385
+ children: element
2303
2386
  });
2304
2387
  }
2388
+ return element;
2305
2389
  }
2390
+ /** MDX/markdown extensions — these are server components that cannot be passed as function props. */
2391
+ var MDX_EXTENSIONS = new Set(["mdx", "md"]);
2306
2392
  /**
2307
- * Render Apple iTunes smart banner meta tag.
2393
+ * Check if a route file is an MDX/markdown file based on its extension.
2394
+ * MDX components are server components by default and cannot cross the
2395
+ * RSC→client boundary as function props. They must be pre-rendered as
2396
+ * elements and passed as fallbackElement instead of fallbackComponent.
2308
2397
  */
2309
- function renderItunes(itunes, elements) {
2310
- const parts = [`app-id=${itunes.appId}`];
2311
- if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
2312
- if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
2313
- elements.push({
2314
- tag: "meta",
2315
- attrs: {
2316
- name: "apple-itunes-app",
2317
- content: parts.join(", ")
2318
- }
2319
- });
2398
+ function isMdxFile(file) {
2399
+ return MDX_EXTENSIONS.has(file.extension);
2320
2400
  }
2321
- //#endregion
2322
- //#region src/server/metadata-render.ts
2323
2401
  /**
2324
- * Convert resolved metadata into an array of head element descriptors.
2402
+ * Wrap an element with error boundaries from a segment's status-code files.
2325
2403
  *
2326
- * Each descriptor has a `tag` ('title', 'meta', 'link') and either
2327
- * `content` (for <title>) or `attrs` (for <meta>/<link>).
2404
+ * Wrapping order (innermost to outermost):
2405
+ * 1. Specific status files (503.tsx, 429.tsx, etc.)
2406
+ * 2. Category catch-alls (4xx.tsx, 5xx.tsx)
2407
+ * 3. error.tsx (general error boundary)
2328
2408
  *
2329
- * The framework's MetadataResolver component consumes these descriptors
2330
- * and renders them into the <head>.
2409
+ * This creates the fallback chain described in design/10-error-handling.md.
2410
+ *
2411
+ * MDX status files are server components and cannot be passed as function
2412
+ * props to TimberErrorBoundary (a 'use client' component). Instead, they
2413
+ * are pre-rendered as elements and passed as fallbackElement. The error
2414
+ * boundary renders the element directly when an error is caught.
2415
+ * See TIM-503.
2331
2416
  */
2332
- function renderMetadataToElements(metadata) {
2333
- const elements = [];
2334
- if (typeof metadata.title === "string") elements.push({
2335
- tag: "title",
2336
- content: metadata.title
2337
- });
2338
- const simpleMetaProps = [
2339
- ["description", metadata.description],
2340
- ["generator", metadata.generator],
2341
- ["application-name", metadata.applicationName],
2342
- ["referrer", metadata.referrer],
2343
- ["category", metadata.category],
2344
- ["creator", metadata.creator],
2345
- ["publisher", metadata.publisher]
2346
- ];
2347
- for (const [name, content] of simpleMetaProps) if (content) elements.push({
2348
- tag: "meta",
2349
- attrs: {
2350
- name,
2351
- content
2352
- }
2353
- });
2354
- if (metadata.keywords) {
2355
- const content = Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
2356
- elements.push({
2357
- tag: "meta",
2358
- attrs: {
2359
- name: "keywords",
2360
- content
2361
- }
2362
- });
2363
- }
2364
- if (metadata.robots) {
2365
- const content = typeof metadata.robots === "string" ? metadata.robots : renderRobotsObject(metadata.robots);
2366
- elements.push({
2367
- tag: "meta",
2368
- attrs: {
2369
- name: "robots",
2370
- content
2371
- }
2372
- });
2373
- if (typeof metadata.robots === "object" && metadata.robots.googleBot) {
2374
- const gbContent = typeof metadata.robots.googleBot === "string" ? metadata.robots.googleBot : renderRobotsObject(metadata.robots.googleBot);
2375
- elements.push({
2376
- tag: "meta",
2377
- attrs: {
2378
- name: "googlebot",
2379
- content: gbContent
2380
- }
2381
- });
2382
- }
2383
- }
2384
- if (metadata.openGraph) renderOpenGraph(metadata.openGraph, elements);
2385
- if (metadata.twitter) renderTwitter(metadata.twitter, elements);
2386
- if (metadata.icons) renderIcons(metadata.icons, elements);
2387
- if (metadata.manifest) elements.push({
2388
- tag: "link",
2389
- attrs: {
2390
- rel: "manifest",
2391
- href: metadata.manifest
2392
- }
2393
- });
2394
- if (metadata.alternates) renderAlternates(metadata.alternates, elements);
2395
- if (metadata.verification) renderVerification(metadata.verification, elements);
2396
- if (metadata.formatDetection) {
2397
- const parts = [];
2398
- if (metadata.formatDetection.telephone === false) parts.push("telephone=no");
2399
- if (metadata.formatDetection.email === false) parts.push("email=no");
2400
- if (metadata.formatDetection.address === false) parts.push("address=no");
2401
- if (parts.length > 0) elements.push({
2402
- tag: "meta",
2403
- attrs: {
2404
- name: "format-detection",
2405
- content: parts.join(", ")
2417
+ async function wrapWithErrorBoundaries(segment, element, loadModule, createElement, errorBoundaryComponent) {
2418
+ if (segment.statusFiles) {
2419
+ for (const [key, file] of segment.statusFiles) if (key !== "4xx" && key !== "5xx") {
2420
+ const status = parseInt(key, 10);
2421
+ if (!isNaN(status)) {
2422
+ const Component = (await loadModule(file)).default;
2423
+ if (Component) element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
2424
+ fallbackElement: createElement(Component, { status }),
2425
+ status,
2426
+ children: element
2427
+ } : {
2428
+ fallbackComponent: Component,
2429
+ status,
2430
+ children: element
2431
+ });
2406
2432
  }
2407
- });
2408
- }
2409
- if (metadata.authors) {
2410
- const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
2411
- for (const author of authorList) {
2412
- if (author.name) elements.push({
2413
- tag: "meta",
2414
- attrs: {
2415
- name: "author",
2416
- content: author.name
2417
- }
2418
- });
2419
- if (author.url) elements.push({
2420
- tag: "link",
2421
- attrs: {
2422
- rel: "author",
2423
- href: author.url
2424
- }
2425
- });
2426
2433
  }
2427
- }
2428
- if (metadata.appleWebApp) renderAppleWebApp(metadata.appleWebApp, elements);
2429
- if (metadata.appLinks) renderAppLinks(metadata.appLinks, elements);
2430
- if (metadata.itunes) renderItunes(metadata.itunes, elements);
2431
- if (metadata.other) for (const [name, value] of Object.entries(metadata.other)) {
2432
- const content = Array.isArray(value) ? value.join(", ") : value;
2433
- elements.push({
2434
- tag: "meta",
2435
- attrs: {
2436
- name,
2437
- content
2434
+ for (const [key, file] of segment.statusFiles) if (key === "4xx" || key === "5xx") {
2435
+ const Component = (await loadModule(file)).default;
2436
+ if (Component) {
2437
+ const categoryStatus = key === "4xx" ? 400 : 500;
2438
+ element = createElement(errorBoundaryComponent, isMdxFile(file) ? {
2439
+ fallbackElement: createElement(Component, {}),
2440
+ status: categoryStatus,
2441
+ children: element
2442
+ } : {
2443
+ fallbackComponent: Component,
2444
+ status: categoryStatus,
2445
+ children: element
2446
+ });
2438
2447
  }
2448
+ }
2449
+ }
2450
+ if (segment.error) {
2451
+ const ErrorComponent = (await loadModule(segment.error)).default;
2452
+ if (ErrorComponent) element = createElement(errorBoundaryComponent, isMdxFile(segment.error) ? {
2453
+ fallbackElement: createElement(ErrorComponent, {}),
2454
+ children: element
2455
+ } : {
2456
+ fallbackComponent: ErrorComponent,
2457
+ children: element
2439
2458
  });
2440
2459
  }
2441
- return elements;
2442
- }
2443
- function renderRobotsObject(robots) {
2444
- const parts = [];
2445
- if (robots.index === true) parts.push("index");
2446
- if (robots.index === false) parts.push("noindex");
2447
- if (robots.follow === true) parts.push("follow");
2448
- if (robots.follow === false) parts.push("nofollow");
2449
- return parts.join(", ");
2460
+ return element;
2450
2461
  }
2451
2462
  //#endregion
2452
- //#region src/server/metadata.ts
2463
+ //#region src/server/status-code-resolver.ts
2453
2464
  /**
2454
- * Resolve a title value with an optional template.
2455
- *
2456
- * - string → apply template if present
2457
- * - { absolute: '...' } → use as-is, skip template
2458
- * - { default: '...' } → use as fallback (no template applied)
2459
- * - undefined → undefined
2465
+ * Maps legacy file convention names to their corresponding HTTP status codes.
2466
+ * Only used in the 4xx component fallback chain.
2460
2467
  */
2461
- function resolveTitle(title, template) {
2462
- if (title === void 0 || title === null) return;
2463
- if (typeof title === "string") return template ? template.replace("%s", title) : title;
2464
- if (title.absolute !== void 0) return title.absolute;
2465
- if (title.default !== void 0) return title.default;
2466
- }
2468
+ var LEGACY_FILE_TO_STATUS = {
2469
+ "not-found": 404,
2470
+ "forbidden": 403,
2471
+ "unauthorized": 401
2472
+ };
2467
2473
  /**
2468
- * Resolve metadata from a segment chain.
2469
- *
2470
- * Processes entries from root layout to page (in segment order).
2471
- * The merge algorithm:
2472
- * 1. Shallow-merge all keys except title (later wins)
2473
- * 2. Track the most recent title template
2474
- * 3. Resolve the final title using the template
2474
+ * Resolve the status-code file to render for a given HTTP status code.
2475
2475
  *
2476
- * In error state, the page entry is dropped and noindex is injected.
2476
+ * Walks the segment chain from leaf to root following the fallback chain
2477
+ * defined in design/10-error-handling.md. Returns null if no file is found
2478
+ * (caller should render the framework default).
2477
2479
  *
2478
- * See design/16-metadata.md §"Merge Algorithm"
2480
+ * @param status - The HTTP status code (4xx or 5xx).
2481
+ * @param segments - The matched segment chain from root (index 0) to leaf (last).
2482
+ * @param format - The response format family ('component' or 'json'). Defaults to 'component'.
2479
2483
  */
2480
- function resolveMetadata(entries, options = {}) {
2481
- const { errorState = false } = options;
2482
- const merged = {};
2483
- let titleTemplate;
2484
- let lastDefault;
2485
- let rawTitle;
2486
- for (const { metadata, isPage } of entries) {
2487
- if (errorState && isPage) continue;
2488
- if (metadata.title !== void 0 && typeof metadata.title === "object") {
2489
- if (metadata.title.template !== void 0) titleTemplate = metadata.title.template;
2490
- if (metadata.title.default !== void 0) lastDefault = metadata.title.default;
2484
+ function resolveStatusFile(status, segments, format = "component") {
2485
+ if (status >= 400 && status <= 499) return format === "json" ? resolve4xxJson(status, segments) : resolve4xx(status, segments);
2486
+ if (status >= 500 && status <= 599) return format === "json" ? resolve5xxJson(status, segments) : resolve5xx(status, segments);
2487
+ return null;
2488
+ }
2489
+ /**
2490
+ * 4xx component fallback chain (three separate passes):
2491
+ * Pass 1 — status files (leaf root): {status}.tsx → 4xx.tsx
2492
+ * Pass 2 legacy compat (leaf root): not-found.tsx / forbidden.tsx / unauthorized.tsx
2493
+ * Pass 3 — error.tsx (leaf root)
2494
+ */
2495
+ function resolve4xx(status, segments) {
2496
+ const statusStr = String(status);
2497
+ for (let i = segments.length - 1; i >= 0; i--) {
2498
+ const segment = segments[i];
2499
+ if (!segment.statusFiles) continue;
2500
+ const exact = segment.statusFiles.get(statusStr);
2501
+ if (exact) return {
2502
+ file: exact,
2503
+ status,
2504
+ kind: "exact",
2505
+ segmentIndex: i
2506
+ };
2507
+ const category = segment.statusFiles.get("4xx");
2508
+ if (category) return {
2509
+ file: category,
2510
+ status,
2511
+ kind: "category",
2512
+ segmentIndex: i
2513
+ };
2514
+ }
2515
+ for (let i = segments.length - 1; i >= 0; i--) {
2516
+ const segment = segments[i];
2517
+ if (!segment.legacyStatusFiles) continue;
2518
+ for (const [name, legacyStatus] of Object.entries(LEGACY_FILE_TO_STATUS)) if (legacyStatus === status) {
2519
+ const file = segment.legacyStatusFiles.get(name);
2520
+ if (file) return {
2521
+ file,
2522
+ status,
2523
+ kind: "legacy",
2524
+ segmentIndex: i
2525
+ };
2491
2526
  }
2492
- for (const key of Object.keys(metadata)) {
2493
- if (key === "title") continue;
2494
- merged[key] = metadata[key];
2527
+ }
2528
+ for (let i = segments.length - 1; i >= 0; i--) if (segments[i].error) return {
2529
+ file: segments[i].error,
2530
+ status,
2531
+ kind: "error",
2532
+ segmentIndex: i
2533
+ };
2534
+ return null;
2535
+ }
2536
+ /**
2537
+ * 4xx JSON fallback chain (single pass):
2538
+ * Pass 1 — json status files (leaf → root): {status}.json → 4xx.json
2539
+ * No legacy compat, no error.tsx — JSON chain terminates at category catch-all.
2540
+ */
2541
+ function resolve4xxJson(status, segments) {
2542
+ const statusStr = String(status);
2543
+ for (let i = segments.length - 1; i >= 0; i--) {
2544
+ const segment = segments[i];
2545
+ if (!segment.jsonStatusFiles) continue;
2546
+ const exact = segment.jsonStatusFiles.get(statusStr);
2547
+ if (exact) return {
2548
+ file: exact,
2549
+ status,
2550
+ kind: "exact",
2551
+ segmentIndex: i
2552
+ };
2553
+ const category = segment.jsonStatusFiles.get("4xx");
2554
+ if (category) return {
2555
+ file: category,
2556
+ status,
2557
+ kind: "category",
2558
+ segmentIndex: i
2559
+ };
2560
+ }
2561
+ return null;
2562
+ }
2563
+ /**
2564
+ * 5xx component fallback chain (single pass, per-segment):
2565
+ * At each segment (leaf → root): {status}.tsx → 5xx.tsx → error.tsx
2566
+ */
2567
+ function resolve5xx(status, segments) {
2568
+ const statusStr = String(status);
2569
+ for (let i = segments.length - 1; i >= 0; i--) {
2570
+ const segment = segments[i];
2571
+ if (segment.statusFiles) {
2572
+ const exact = segment.statusFiles.get(statusStr);
2573
+ if (exact) return {
2574
+ file: exact,
2575
+ status,
2576
+ kind: "exact",
2577
+ segmentIndex: i
2578
+ };
2579
+ const category = segment.statusFiles.get("5xx");
2580
+ if (category) return {
2581
+ file: category,
2582
+ status,
2583
+ kind: "category",
2584
+ segmentIndex: i
2585
+ };
2495
2586
  }
2496
- if (metadata.title !== void 0) rawTitle = metadata.title;
2587
+ if (segment.error) return {
2588
+ file: segment.error,
2589
+ status,
2590
+ kind: "error",
2591
+ segmentIndex: i
2592
+ };
2497
2593
  }
2498
- if (errorState) {
2499
- rawTitle = lastDefault !== void 0 ? { default: lastDefault } : rawTitle;
2500
- titleTemplate = void 0;
2594
+ return null;
2595
+ }
2596
+ /**
2597
+ * 5xx JSON fallback chain (single pass):
2598
+ * At each segment (leaf → root): {status}.json → 5xx.json
2599
+ * No error.tsx equivalent — JSON chain terminates at category catch-all.
2600
+ */
2601
+ function resolve5xxJson(status, segments) {
2602
+ const statusStr = String(status);
2603
+ for (let i = segments.length - 1; i >= 0; i--) {
2604
+ const segment = segments[i];
2605
+ if (!segment.jsonStatusFiles) continue;
2606
+ const exact = segment.jsonStatusFiles.get(statusStr);
2607
+ if (exact) return {
2608
+ file: exact,
2609
+ status,
2610
+ kind: "exact",
2611
+ segmentIndex: i
2612
+ };
2613
+ const category = segment.jsonStatusFiles.get("5xx");
2614
+ if (category) return {
2615
+ file: category,
2616
+ status,
2617
+ kind: "category",
2618
+ segmentIndex: i
2619
+ };
2501
2620
  }
2502
- const resolvedTitle = resolveTitle(rawTitle, titleTemplate);
2503
- if (resolvedTitle !== void 0) merged.title = resolvedTitle;
2504
- if (errorState) merged.robots = "noindex";
2505
- return merged;
2621
+ return null;
2622
+ }
2623
+ /**
2624
+ * Resolve the denial file for a parallel route slot.
2625
+ *
2626
+ * Slot denial is graceful degradation — no HTTP status on the wire.
2627
+ * Fallback chain: denied.tsx → default.tsx → null.
2628
+ *
2629
+ * @param slotNode - The segment node for the slot (segmentType === 'slot').
2630
+ */
2631
+ function resolveSlotDenied(slotNode) {
2632
+ const slotName = slotNode.segmentName.replace(/^@/, "");
2633
+ if (slotNode.denied) return {
2634
+ file: slotNode.denied,
2635
+ slotName,
2636
+ kind: "denied"
2637
+ };
2638
+ if (slotNode.default) return {
2639
+ file: slotNode.default,
2640
+ slotName,
2641
+ kind: "default"
2642
+ };
2643
+ return null;
2506
2644
  }
2645
+ //#endregion
2646
+ //#region src/server/flush.ts
2647
+ /**
2648
+ * Flush controller for timber.js rendering.
2649
+ *
2650
+ * Holds the response until `onShellReady` fires, then commits the HTTP status
2651
+ * code and flushes the shell. Render-phase signals (deny, redirect, unhandled
2652
+ * throws) caught before flush produce correct HTTP status codes.
2653
+ *
2654
+ * See design/02-rendering-pipeline.md §"The Flush Point" and §"The Hold Window"
2655
+ */
2507
2656
  /**
2508
- * Check if a string is an absolute URL.
2657
+ * Execute a render and hold the response until the shell is ready.
2658
+ *
2659
+ * The flush controller:
2660
+ * 1. Calls the render function to start renderToReadableStream
2661
+ * 2. Waits for shellReady (onShellReady)
2662
+ * 3. If a render-phase signal was thrown (deny, redirect, error), produces
2663
+ * the correct HTTP status code
2664
+ * 4. If the shell rendered successfully, commits the status and streams
2665
+ *
2666
+ * Render-phase signals caught before flush:
2667
+ * - `DenySignal` → HTTP 4xx with appropriate status code
2668
+ * - `RedirectSignal` → HTTP 3xx with Location header
2669
+ * - `RenderError` → HTTP status from error (default 500)
2670
+ * - Unhandled error → HTTP 500
2671
+ *
2672
+ * @param renderFn - Function that starts the React render.
2673
+ * @param options - Flush configuration.
2674
+ * @returns The committed HTTP Response.
2509
2675
  */
2510
- function isAbsoluteUrl(url) {
2511
- return url.startsWith("http://") || url.startsWith("https://") || url.startsWith("//");
2676
+ async function flushResponse(renderFn, options = {}) {
2677
+ const { responseHeaders = new Headers(), defaultStatus = 200 } = options;
2678
+ let renderResult;
2679
+ try {
2680
+ renderResult = await renderFn();
2681
+ } catch (error) {
2682
+ return handleSignal(error, responseHeaders);
2683
+ }
2684
+ try {
2685
+ await renderResult.shellReady;
2686
+ } catch (error) {
2687
+ return handleSignal(error, responseHeaders);
2688
+ }
2689
+ responseHeaders.set("Content-Type", "text/html; charset=utf-8");
2690
+ return {
2691
+ response: new Response(renderResult.stream, {
2692
+ status: defaultStatus,
2693
+ headers: responseHeaders
2694
+ }),
2695
+ status: defaultStatus,
2696
+ isRedirect: false,
2697
+ isDenial: false
2698
+ };
2512
2699
  }
2513
2700
  /**
2514
- * Resolve a relative URL against a base URL.
2701
+ * Handle a render-phase signal and produce the correct HTTP response.
2515
2702
  */
2516
- function resolveUrl(url, base) {
2517
- if (isAbsoluteUrl(url)) return url;
2518
- return new URL(url, base).toString();
2703
+ function handleSignal(error, responseHeaders) {
2704
+ if (error instanceof RedirectSignal) {
2705
+ responseHeaders.set("Location", error.location);
2706
+ return {
2707
+ response: new Response(null, {
2708
+ status: error.status,
2709
+ headers: responseHeaders
2710
+ }),
2711
+ status: error.status,
2712
+ isRedirect: true,
2713
+ isDenial: false
2714
+ };
2715
+ }
2716
+ if (error instanceof DenySignal) return {
2717
+ response: new Response(null, {
2718
+ status: error.status,
2719
+ headers: responseHeaders
2720
+ }),
2721
+ status: error.status,
2722
+ isRedirect: false,
2723
+ isDenial: true
2724
+ };
2725
+ if (error instanceof RenderError) return {
2726
+ response: new Response(null, {
2727
+ status: error.status,
2728
+ headers: responseHeaders
2729
+ }),
2730
+ status: error.status,
2731
+ isRedirect: false,
2732
+ isDenial: false
2733
+ };
2734
+ logRenderError({
2735
+ method: "",
2736
+ path: "",
2737
+ error
2738
+ });
2739
+ return {
2740
+ response: new Response(null, {
2741
+ status: 500,
2742
+ headers: responseHeaders
2743
+ }),
2744
+ status: 500,
2745
+ isRedirect: false,
2746
+ isDenial: false
2747
+ };
2519
2748
  }
2749
+ //#endregion
2750
+ //#region src/server/csrf.ts
2751
+ /** HTTP methods that are considered safe (no mutation). */
2752
+ var SAFE_METHODS = new Set([
2753
+ "GET",
2754
+ "HEAD",
2755
+ "OPTIONS"
2756
+ ]);
2520
2757
  /**
2521
- * Resolve relative URLs in metadata fields against metadataBase.
2758
+ * Validate the Origin header against the request's Host.
2522
2759
  *
2523
- * Returns a new metadata object with URLs resolved. Absolute URLs are not modified.
2524
- * If metadataBase is not set, returns the metadata unchanged.
2760
+ * For mutation methods (POST, PUT, PATCH, DELETE):
2761
+ * - If `csrf: false`, skip validation.
2762
+ * - If `allowedOrigins` is set, Origin must match one exactly (no wildcards).
2763
+ * - Otherwise, Origin's host must match the request's Host header.
2764
+ *
2765
+ * Safe methods (GET, HEAD, OPTIONS) always pass.
2525
2766
  */
2526
- function resolveMetadataUrls(metadata) {
2527
- const base = metadata.metadataBase;
2528
- if (!base) return metadata;
2529
- const result = { ...metadata };
2530
- if (result.openGraph) {
2531
- result.openGraph = { ...result.openGraph };
2532
- if (typeof result.openGraph.images === "string") result.openGraph.images = resolveUrl(result.openGraph.images, base);
2533
- else if (Array.isArray(result.openGraph.images)) result.openGraph.images = result.openGraph.images.map((img) => ({
2534
- ...img,
2535
- url: resolveUrl(img.url, base)
2536
- }));
2537
- else if (result.openGraph.images) result.openGraph.images = {
2538
- ...result.openGraph.images,
2539
- url: resolveUrl(result.openGraph.images.url, base)
2540
- };
2541
- if (result.openGraph.url && !isAbsoluteUrl(result.openGraph.url)) result.openGraph.url = resolveUrl(result.openGraph.url, base);
2542
- }
2543
- if (result.twitter) {
2544
- result.twitter = { ...result.twitter };
2545
- if (typeof result.twitter.images === "string") result.twitter.images = resolveUrl(result.twitter.images, base);
2546
- else if (Array.isArray(result.twitter.images)) {
2547
- const resolved = result.twitter.images.map((img) => typeof img === "string" ? resolveUrl(img, base) : {
2548
- ...img,
2549
- url: resolveUrl(img.url, base)
2550
- });
2551
- const allStrings = resolved.every((r) => typeof r === "string");
2552
- result.twitter.images = allStrings ? resolved : resolved;
2553
- } else if (result.twitter.images) result.twitter.images = {
2554
- ...result.twitter.images,
2555
- url: resolveUrl(result.twitter.images.url, base)
2767
+ function validateCsrf(req, config) {
2768
+ if (SAFE_METHODS.has(req.method)) return { ok: true };
2769
+ if (config.csrf === false) return { ok: true };
2770
+ const origin = req.headers.get("Origin");
2771
+ if (!origin) return {
2772
+ ok: false,
2773
+ status: 403
2774
+ };
2775
+ if (config.allowedOrigins) return config.allowedOrigins.includes(origin) ? { ok: true } : {
2776
+ ok: false,
2777
+ status: 403
2778
+ };
2779
+ const host = req.headers.get("Host");
2780
+ if (!host) return {
2781
+ ok: false,
2782
+ status: 403
2783
+ };
2784
+ let originHost;
2785
+ try {
2786
+ originHost = new URL(origin).host;
2787
+ } catch {
2788
+ return {
2789
+ ok: false,
2790
+ status: 403
2556
2791
  };
2557
2792
  }
2558
- if (result.alternates) {
2559
- result.alternates = { ...result.alternates };
2560
- if (result.alternates.canonical && !isAbsoluteUrl(result.alternates.canonical)) result.alternates.canonical = resolveUrl(result.alternates.canonical, base);
2561
- if (result.alternates.languages) {
2562
- const langs = {};
2563
- for (const [lang, url] of Object.entries(result.alternates.languages)) langs[lang] = isAbsoluteUrl(url) ? url : resolveUrl(url, base);
2564
- result.alternates.languages = langs;
2565
- }
2566
- }
2567
- if (result.icons) {
2568
- result.icons = { ...result.icons };
2569
- if (typeof result.icons.icon === "string") result.icons.icon = resolveUrl(result.icons.icon, base);
2570
- else if (Array.isArray(result.icons.icon)) result.icons.icon = result.icons.icon.map((i) => ({
2571
- ...i,
2572
- url: resolveUrl(i.url, base)
2573
- }));
2574
- if (typeof result.icons.apple === "string") result.icons.apple = resolveUrl(result.icons.apple, base);
2575
- else if (Array.isArray(result.icons.apple)) result.icons.apple = result.icons.apple.map((i) => ({
2576
- ...i,
2577
- url: resolveUrl(i.url, base)
2578
- }));
2793
+ return originHost === host ? { ok: true } : {
2794
+ ok: false,
2795
+ status: 403
2796
+ };
2797
+ }
2798
+ //#endregion
2799
+ //#region src/server/body-limits.ts
2800
+ var KB = 1024;
2801
+ var MB = 1024 * KB;
2802
+ var GB = 1024 * MB;
2803
+ var DEFAULT_LIMITS = {
2804
+ actionBodySize: 1 * MB,
2805
+ uploadBodySize: 10 * MB,
2806
+ maxFields: 100
2807
+ };
2808
+ var SIZE_PATTERN = /^(\d+(?:\.\d+)?)\s*(kb|mb|gb)?$/i;
2809
+ /** Parse a human-readable size string ("1mb", "512kb", "1024") into bytes. */
2810
+ function parseBodySize(size) {
2811
+ const match = SIZE_PATTERN.exec(size.trim());
2812
+ if (!match) throw new Error(`Invalid body size format: "${size}". Expected format like "1mb", "512kb", or "1024".`);
2813
+ const value = Number.parseFloat(match[1]);
2814
+ const unit = (match[2] ?? "").toLowerCase();
2815
+ switch (unit) {
2816
+ case "kb": return Math.floor(value * KB);
2817
+ case "mb": return Math.floor(value * MB);
2818
+ case "gb": return Math.floor(value * GB);
2819
+ case "": return Math.floor(value);
2820
+ default: throw new Error(`Unknown size unit: "${unit}"`);
2579
2821
  }
2580
- return result;
2822
+ }
2823
+ /** Check whether a request body exceeds the configured size limit (stateless, no ALS). */
2824
+ function enforceBodyLimits(req, kind, config) {
2825
+ const contentLength = req.headers.get("Content-Length");
2826
+ if (!contentLength) return {
2827
+ ok: false,
2828
+ status: 411
2829
+ };
2830
+ const bodySize = Number.parseInt(contentLength, 10);
2831
+ if (Number.isNaN(bodySize)) return {
2832
+ ok: false,
2833
+ status: 411
2834
+ };
2835
+ return bodySize <= resolveLimit(kind, config) ? { ok: true } : {
2836
+ ok: false,
2837
+ status: 413
2838
+ };
2839
+ }
2840
+ /**
2841
+ * Resolve the byte limit for a given body kind, using config overrides or defaults.
2842
+ */
2843
+ function resolveLimit(kind, config) {
2844
+ const userLimits = config.limits;
2845
+ if (kind === "action") return userLimits?.actionBodySize ? parseBodySize(userLimits.actionBodySize) : DEFAULT_LIMITS.actionBodySize;
2846
+ return userLimits?.uploadBodySize ? parseBodySize(userLimits.uploadBodySize) : DEFAULT_LIMITS.uploadBodySize;
2581
2847
  }
2582
2848
  //#endregion
2583
2849
  //#region src/server/form-data.ts
@@ -2690,6 +2956,35 @@ var coerce = {
2690
2956
  } catch {
2691
2957
  return;
2692
2958
  }
2959
+ },
2960
+ date(value) {
2961
+ if (value === void 0 || value === null || value === "") return void 0;
2962
+ if (value instanceof Date) return value;
2963
+ if (typeof value !== "string") return void 0;
2964
+ const date = new Date(value);
2965
+ if (Number.isNaN(date.getTime())) return void 0;
2966
+ const ymdMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
2967
+ if (ymdMatch) {
2968
+ const inputYear = Number(ymdMatch[1]);
2969
+ const inputMonth = Number(ymdMatch[2]);
2970
+ const inputDay = Number(ymdMatch[3]);
2971
+ const isUTC = value.length === 10 || value.endsWith("Z");
2972
+ const parsedYear = isUTC ? date.getUTCFullYear() : date.getFullYear();
2973
+ const parsedMonth = isUTC ? date.getUTCMonth() + 1 : date.getMonth() + 1;
2974
+ const parsedDay = isUTC ? date.getUTCDate() : date.getDate();
2975
+ if (inputYear !== parsedYear || inputMonth !== parsedMonth || inputDay !== parsedDay) return;
2976
+ }
2977
+ return date;
2978
+ },
2979
+ file(options) {
2980
+ return (value) => {
2981
+ if (value === void 0 || value === null || value === "") return void 0;
2982
+ if (!(value instanceof File)) return void 0;
2983
+ if (value.size === 0 && value.name === "") return void 0;
2984
+ if (options?.maxSize !== void 0 && value.size > options.maxSize) return;
2985
+ if (options?.accept !== void 0 && !options.accept.includes(value.type)) return;
2986
+ return value;
2987
+ };
2693
2988
  }
2694
2989
  };
2695
2990
  //#endregion
@@ -2792,7 +3087,7 @@ function handleActionError(error) {
2792
3087
  } };
2793
3088
  return { serverError: {
2794
3089
  code: "INTERNAL_ERROR",
2795
- ...isDebug() && error instanceof Error ? { data: { message: error.message } } : {}
3090
+ ...isDevMode() && error instanceof Error ? { data: { message: error.message } } : {}
2796
3091
  } };
2797
3092
  }
2798
3093
  /**
@@ -2822,6 +3117,7 @@ function createActionClient(config = {}) {
2822
3117
  const ctx = await runActionMiddleware(config.middleware);
2823
3118
  let rawInput;
2824
3119
  if (args.length === 2 && args[1] instanceof FormData) rawInput = schema ? parseFormData(args[1]) : args[1];
3120
+ else if (args.length === 1 && args[0] instanceof FormData) rawInput = schema ? parseFormData(args[0]) : args[0];
2825
3121
  else rawInput = args[0];
2826
3122
  if (config.fileSizeLimit !== void 0 && rawInput && typeof rawInput === "object") {
2827
3123
  const fileSizeErrors = validateFileSizes(rawInput, config.fileSizeLimit);
@@ -3162,7 +3458,11 @@ async function runHandler(handler, ctx) {
3162
3458
  try {
3163
3459
  return mergeResponseHeaders(await handler(ctx), ctx.headers);
3164
3460
  } catch (error) {
3165
- console.error("[timber] Uncaught error in route.ts handler:", error);
3461
+ logRouteError({
3462
+ method: ctx.req.method,
3463
+ path: new URL(ctx.req.url).pathname,
3464
+ error
3465
+ });
3166
3466
  return new Response(null, { status: 500 });
3167
3467
  }
3168
3468
  }
@@ -3187,6 +3487,32 @@ function mergeResponseHeaders(res, ctxHeaders) {
3187
3487
  });
3188
3488
  }
3189
3489
  //#endregion
3190
- 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 };
3490
+ //#region src/server/render-timeout.ts
3491
+ /**
3492
+ * Render timeout utilities for SSR streaming pipeline.
3493
+ *
3494
+ * Provides a RenderTimeoutError class and a helper to create
3495
+ * timeout-guarded AbortSignals. Used to defend against hung RSC
3496
+ * streams and infinite SSR renders.
3497
+ *
3498
+ * Design doc: 02-rendering-pipeline.md §"Streaming Constraints"
3499
+ */
3500
+ /**
3501
+ * Error thrown when an SSR render or RSC stream read exceeds the
3502
+ * configured timeout. Callers can check `instanceof RenderTimeoutError`
3503
+ * to distinguish timeout from other errors and return a 504 or close
3504
+ * the connection cleanly.
3505
+ */
3506
+ var RenderTimeoutError = class extends Error {
3507
+ timeoutMs;
3508
+ constructor(timeoutMs, context) {
3509
+ const message = context ? `Render timeout after ${timeoutMs}ms: ${context}` : `Render timeout after ${timeoutMs}ms`;
3510
+ super(message);
3511
+ this.name = "RenderTimeoutError";
3512
+ this.timeoutMs = timeoutMs;
3513
+ }
3514
+ };
3515
+ //#endregion
3516
+ export { AccessGate, ActionError, DEFAULT_LIMITS, DenySignal, METADATA_ROUTE_CONVENTIONS, RedirectSignal, RedirectType, RenderError, RenderTimeoutError, 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, rawSegmentParams, redirect, redirectExternal, renderMetadataToElements, replaceTraceId, resolveAllowedMethods, resolveMetadata, resolveMetadataUrls, resolveSlotDenied, resolveStatusFile, resolveTitle, revalidatePath, revalidateTag, runMiddleware, runProxy, runWithEarlyHintsSender, runWithRequestContext, runWithTraceId, sendEarlyHints103, setLogger, setMutableCookieContext, setSegmentParams, setViteServer, spanId, traceId, validateCsrf, validated, waitUntil, warnCacheRequestProps, warnDenyAfterFlush, warnDenyInSuspense, warnDynamicApiInStaticBuild, warnRedirectInAccess, warnRedirectInSlotAccess, warnRedirectInSuspense, warnSlowSlotWithoutSuspense, warnStaticRequestApi, warnSuspenseWrappingChildren, withSpan };
3191
3517
 
3192
3518
  //# sourceMappingURL=index.js.map