@timber-js/app 0.2.0-alpha.6 → 0.2.0-alpha.61

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