@timber-js/app 0.2.0-alpha.7 → 0.2.0-alpha.71

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