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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (336) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
  3. package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
  4. package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
  5. package/dist/_chunks/debug-ECi_61pb.js +108 -0
  6. package/dist/_chunks/debug-ECi_61pb.js.map +1 -0
  7. package/dist/_chunks/define-cookie-BmKbSyp0.js +93 -0
  8. package/dist/_chunks/define-cookie-BmKbSyp0.js.map +1 -0
  9. package/dist/_chunks/error-boundary-BAN3751q.js +211 -0
  10. package/dist/_chunks/error-boundary-BAN3751q.js.map +1 -0
  11. package/dist/_chunks/{format-CwdaB0_2.js → format-cX7wzEp2.js} +2 -2
  12. package/dist/_chunks/{format-CwdaB0_2.js.map → format-cX7wzEp2.js.map} +1 -1
  13. package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
  14. package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
  15. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
  16. package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
  17. package/dist/_chunks/{request-context-CZJi4CuK.js → request-context-BxYIJM24.js} +93 -69
  18. package/dist/_chunks/request-context-BxYIJM24.js.map +1 -0
  19. package/dist/_chunks/segment-context-C6byCyZU.js +69 -0
  20. package/dist/_chunks/segment-context-C6byCyZU.js.map +1 -0
  21. package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
  22. package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
  23. package/dist/_chunks/{tracing-Cwn7697K.js → tracing-CuXiCP5p.js} +17 -3
  24. package/dist/_chunks/{tracing-Cwn7697K.js.map → tracing-CuXiCP5p.js.map} +1 -1
  25. package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
  26. package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
  27. package/dist/_chunks/wrappers-C6J0nNji.js +331 -0
  28. package/dist/_chunks/wrappers-C6J0nNji.js.map +1 -0
  29. package/dist/adapters/compress-module.d.ts.map +1 -1
  30. package/dist/adapters/nitro.d.ts +17 -1
  31. package/dist/adapters/nitro.d.ts.map +1 -1
  32. package/dist/adapters/nitro.js +56 -13
  33. package/dist/adapters/nitro.js.map +1 -1
  34. package/dist/cache/fast-hash.d.ts +22 -0
  35. package/dist/cache/fast-hash.d.ts.map +1 -0
  36. package/dist/cache/index.d.ts +5 -2
  37. package/dist/cache/index.d.ts.map +1 -1
  38. package/dist/cache/index.js +88 -18
  39. package/dist/cache/index.js.map +1 -1
  40. package/dist/cache/register-cached-function.d.ts.map +1 -1
  41. package/dist/cache/singleflight.d.ts +18 -1
  42. package/dist/cache/singleflight.d.ts.map +1 -1
  43. package/dist/cache/timber-cache.d.ts.map +1 -1
  44. package/dist/client/error-boundary.d.ts +10 -1
  45. package/dist/client/error-boundary.d.ts.map +1 -1
  46. package/dist/client/error-boundary.js +1 -125
  47. package/dist/client/index.d.ts +3 -2
  48. package/dist/client/index.d.ts.map +1 -1
  49. package/dist/client/index.js +213 -93
  50. package/dist/client/index.js.map +1 -1
  51. package/dist/client/link.d.ts +22 -8
  52. package/dist/client/link.d.ts.map +1 -1
  53. package/dist/client/navigation-context.d.ts +2 -2
  54. package/dist/client/router.d.ts +25 -3
  55. package/dist/client/router.d.ts.map +1 -1
  56. package/dist/client/rsc-fetch.d.ts +23 -2
  57. package/dist/client/rsc-fetch.d.ts.map +1 -1
  58. package/dist/client/segment-cache.d.ts +1 -1
  59. package/dist/client/segment-cache.d.ts.map +1 -1
  60. package/dist/client/segment-context.d.ts +1 -1
  61. package/dist/client/segment-context.d.ts.map +1 -1
  62. package/dist/client/segment-merger.d.ts.map +1 -1
  63. package/dist/client/stale-reload.d.ts +15 -0
  64. package/dist/client/stale-reload.d.ts.map +1 -1
  65. package/dist/client/top-loader.d.ts +1 -1
  66. package/dist/client/top-loader.d.ts.map +1 -1
  67. package/dist/client/transition-root.d.ts +1 -1
  68. package/dist/client/transition-root.d.ts.map +1 -1
  69. package/dist/client/use-params.d.ts +2 -2
  70. package/dist/client/use-params.d.ts.map +1 -1
  71. package/dist/client/use-query-states.d.ts +1 -1
  72. package/dist/codec.d.ts +21 -0
  73. package/dist/codec.d.ts.map +1 -0
  74. package/dist/cookies/define-cookie.d.ts +33 -12
  75. package/dist/cookies/define-cookie.d.ts.map +1 -1
  76. package/dist/cookies/index.js +1 -83
  77. package/dist/fonts/css.d.ts +1 -0
  78. package/dist/fonts/css.d.ts.map +1 -1
  79. package/dist/fonts/local.d.ts +4 -2
  80. package/dist/fonts/local.d.ts.map +1 -1
  81. package/dist/index.d.ts +112 -35
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +635 -233
  84. package/dist/index.js.map +1 -1
  85. package/dist/params/define.d.ts +76 -0
  86. package/dist/params/define.d.ts.map +1 -0
  87. package/dist/params/index.d.ts +8 -0
  88. package/dist/params/index.d.ts.map +1 -0
  89. package/dist/params/index.js +104 -0
  90. package/dist/params/index.js.map +1 -0
  91. package/dist/plugins/adapter-build.d.ts.map +1 -1
  92. package/dist/plugins/build-manifest.d.ts.map +1 -1
  93. package/dist/plugins/client-chunks.d.ts +32 -0
  94. package/dist/plugins/client-chunks.d.ts.map +1 -0
  95. package/dist/plugins/dev-error-overlay.d.ts +26 -1
  96. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  97. package/dist/plugins/entries.d.ts +7 -0
  98. package/dist/plugins/entries.d.ts.map +1 -1
  99. package/dist/plugins/fonts.d.ts +9 -1
  100. package/dist/plugins/fonts.d.ts.map +1 -1
  101. package/dist/plugins/mdx.d.ts +6 -0
  102. package/dist/plugins/mdx.d.ts.map +1 -1
  103. package/dist/plugins/routing.d.ts.map +1 -1
  104. package/dist/plugins/server-bundle.d.ts.map +1 -1
  105. package/dist/plugins/static-build.d.ts.map +1 -1
  106. package/dist/routing/codegen.d.ts +2 -2
  107. package/dist/routing/codegen.d.ts.map +1 -1
  108. package/dist/routing/index.js +1 -1
  109. package/dist/routing/scanner.d.ts.map +1 -1
  110. package/dist/routing/status-file-lint.d.ts +2 -1
  111. package/dist/routing/status-file-lint.d.ts.map +1 -1
  112. package/dist/routing/types.d.ts +6 -4
  113. package/dist/routing/types.d.ts.map +1 -1
  114. package/dist/rsc-runtime/rsc.d.ts +1 -1
  115. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  116. package/dist/rsc-runtime/ssr.d.ts +12 -0
  117. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  118. package/dist/search-params/codecs.d.ts +1 -1
  119. package/dist/search-params/define.d.ts +153 -0
  120. package/dist/search-params/define.d.ts.map +1 -0
  121. package/dist/search-params/index.d.ts +4 -5
  122. package/dist/search-params/index.d.ts.map +1 -1
  123. package/dist/search-params/index.js +3 -474
  124. package/dist/search-params/registry.d.ts +1 -1
  125. package/dist/search-params/wrappers.d.ts +53 -0
  126. package/dist/search-params/wrappers.d.ts.map +1 -0
  127. package/dist/server/access-gate.d.ts +4 -0
  128. package/dist/server/access-gate.d.ts.map +1 -1
  129. package/dist/server/action-client.d.ts.map +1 -1
  130. package/dist/server/action-encryption.d.ts +76 -0
  131. package/dist/server/action-encryption.d.ts.map +1 -0
  132. package/dist/server/action-handler.d.ts.map +1 -1
  133. package/dist/server/als-registry.d.ts +18 -4
  134. package/dist/server/als-registry.d.ts.map +1 -1
  135. package/dist/server/build-manifest.d.ts +2 -2
  136. package/dist/server/debug.d.ts +46 -15
  137. package/dist/server/debug.d.ts.map +1 -1
  138. package/dist/server/default-logger.d.ts +22 -0
  139. package/dist/server/default-logger.d.ts.map +1 -0
  140. package/dist/server/deny-renderer.d.ts.map +1 -1
  141. package/dist/server/early-hints.d.ts +13 -5
  142. package/dist/server/early-hints.d.ts.map +1 -1
  143. package/dist/server/error-boundary-wrapper.d.ts +4 -0
  144. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  145. package/dist/server/flight-injection-state.d.ts +78 -0
  146. package/dist/server/flight-injection-state.d.ts.map +1 -0
  147. package/dist/server/flight-scripts.d.ts +39 -0
  148. package/dist/server/flight-scripts.d.ts.map +1 -0
  149. package/dist/server/flush.d.ts.map +1 -1
  150. package/dist/server/form-data.d.ts +29 -0
  151. package/dist/server/form-data.d.ts.map +1 -1
  152. package/dist/server/html-injectors.d.ts +5 -11
  153. package/dist/server/html-injectors.d.ts.map +1 -1
  154. package/dist/server/index.d.ts +4 -2
  155. package/dist/server/index.d.ts.map +1 -1
  156. package/dist/server/index.js +1975 -1649
  157. package/dist/server/index.js.map +1 -1
  158. package/dist/server/logger.d.ts +24 -7
  159. package/dist/server/logger.d.ts.map +1 -1
  160. package/dist/server/node-stream-transforms.d.ts +77 -0
  161. package/dist/server/node-stream-transforms.d.ts.map +1 -0
  162. package/dist/server/pipeline.d.ts +7 -4
  163. package/dist/server/pipeline.d.ts.map +1 -1
  164. package/dist/server/primitives.d.ts +30 -3
  165. package/dist/server/primitives.d.ts.map +1 -1
  166. package/dist/server/render-timeout.d.ts +51 -0
  167. package/dist/server/render-timeout.d.ts.map +1 -0
  168. package/dist/server/request-context.d.ts +65 -38
  169. package/dist/server/request-context.d.ts.map +1 -1
  170. package/dist/server/route-element-builder.d.ts +7 -0
  171. package/dist/server/route-element-builder.d.ts.map +1 -1
  172. package/dist/server/route-handler.d.ts.map +1 -1
  173. package/dist/server/route-matcher.d.ts +2 -2
  174. package/dist/server/route-matcher.d.ts.map +1 -1
  175. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/helpers.d.ts +46 -3
  177. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  178. package/dist/server/rsc-entry/index.d.ts +6 -1
  179. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  180. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  181. package/dist/server/rsc-entry/rsc-stream.d.ts +9 -0
  182. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  183. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  184. package/dist/server/slot-resolver.d.ts +1 -1
  185. package/dist/server/slot-resolver.d.ts.map +1 -1
  186. package/dist/server/ssr-entry.d.ts +22 -0
  187. package/dist/server/ssr-entry.d.ts.map +1 -1
  188. package/dist/server/ssr-render.d.ts +39 -21
  189. package/dist/server/ssr-render.d.ts.map +1 -1
  190. package/dist/server/tracing.d.ts +10 -0
  191. package/dist/server/tracing.d.ts.map +1 -1
  192. package/dist/server/tree-builder.d.ts +19 -12
  193. package/dist/server/tree-builder.d.ts.map +1 -1
  194. package/dist/server/types.d.ts +1 -3
  195. package/dist/server/types.d.ts.map +1 -1
  196. package/dist/server/version-skew.d.ts +61 -0
  197. package/dist/server/version-skew.d.ts.map +1 -0
  198. package/dist/server/waituntil-bridge.d.ts.map +1 -1
  199. package/dist/shared/merge-search-params.d.ts +22 -0
  200. package/dist/shared/merge-search-params.d.ts.map +1 -0
  201. package/dist/shims/navigation-client.d.ts +1 -1
  202. package/dist/shims/navigation-client.d.ts.map +1 -1
  203. package/dist/shims/navigation.d.ts +1 -1
  204. package/dist/shims/navigation.d.ts.map +1 -1
  205. package/dist/utils/state-machine.d.ts +80 -0
  206. package/dist/utils/state-machine.d.ts.map +1 -0
  207. package/package.json +17 -14
  208. package/src/adapters/compress-module.ts +24 -4
  209. package/src/adapters/nitro.ts +58 -9
  210. package/src/cache/fast-hash.ts +34 -0
  211. package/src/cache/index.ts +5 -2
  212. package/src/cache/register-cached-function.ts +7 -3
  213. package/src/cache/singleflight.ts +62 -4
  214. package/src/cache/timber-cache.ts +34 -26
  215. package/src/cli.ts +0 -0
  216. package/src/client/browser-entry.ts +94 -90
  217. package/src/client/error-boundary.tsx +18 -1
  218. package/src/client/index.ts +10 -1
  219. package/src/client/link.tsx +78 -19
  220. package/src/client/navigation-context.ts +2 -2
  221. package/src/client/router.ts +105 -60
  222. package/src/client/rsc-fetch.ts +63 -2
  223. package/src/client/segment-cache.ts +1 -1
  224. package/src/client/segment-context.ts +6 -1
  225. package/src/client/segment-merger.ts +2 -8
  226. package/src/client/stale-reload.ts +32 -6
  227. package/src/client/top-loader.tsx +10 -9
  228. package/src/client/transition-root.tsx +7 -1
  229. package/src/client/use-params.ts +3 -3
  230. package/src/client/use-query-states.ts +1 -1
  231. package/src/codec.ts +21 -0
  232. package/src/cookies/define-cookie.ts +69 -18
  233. package/src/fonts/css.ts +2 -1
  234. package/src/fonts/local.ts +7 -3
  235. package/src/index.ts +280 -85
  236. package/src/params/define.ts +260 -0
  237. package/src/params/index.ts +28 -0
  238. package/src/plugins/adapter-build.ts +6 -0
  239. package/src/plugins/build-manifest.ts +11 -0
  240. package/src/plugins/client-chunks.ts +65 -0
  241. package/src/plugins/dev-error-overlay.ts +70 -1
  242. package/src/plugins/dev-server.ts +38 -4
  243. package/src/plugins/entries.ts +12 -11
  244. package/src/plugins/fonts.ts +171 -19
  245. package/src/plugins/mdx.ts +9 -5
  246. package/src/plugins/routing.ts +40 -14
  247. package/src/plugins/server-bundle.ts +32 -1
  248. package/src/plugins/shims.ts +1 -1
  249. package/src/plugins/static-build.ts +8 -4
  250. package/src/routing/codegen.ts +109 -88
  251. package/src/routing/scanner.ts +55 -6
  252. package/src/routing/status-file-lint.ts +2 -1
  253. package/src/routing/types.ts +7 -4
  254. package/src/rsc-runtime/rsc.ts +2 -0
  255. package/src/rsc-runtime/ssr.ts +50 -0
  256. package/src/rsc-runtime/vendor-types.d.ts +7 -0
  257. package/src/search-params/codecs.ts +1 -1
  258. package/src/search-params/define.ts +504 -0
  259. package/src/search-params/index.ts +12 -18
  260. package/src/search-params/registry.ts +1 -1
  261. package/src/search-params/wrappers.ts +85 -0
  262. package/src/server/access-gate.tsx +40 -9
  263. package/src/server/action-client.ts +14 -5
  264. package/src/server/action-encryption.ts +144 -0
  265. package/src/server/action-handler.ts +19 -2
  266. package/src/server/als-registry.ts +18 -4
  267. package/src/server/build-manifest.ts +4 -4
  268. package/src/server/compress.ts +25 -7
  269. package/src/server/debug.ts +55 -17
  270. package/src/server/default-logger.ts +98 -0
  271. package/src/server/deny-renderer.ts +2 -1
  272. package/src/server/early-hints.ts +36 -15
  273. package/src/server/error-boundary-wrapper.ts +57 -14
  274. package/src/server/flight-injection-state.ts +152 -0
  275. package/src/server/flight-scripts.ts +59 -0
  276. package/src/server/flush.ts +2 -1
  277. package/src/server/form-data.ts +76 -0
  278. package/src/server/html-injectors.ts +103 -66
  279. package/src/server/index.ts +9 -4
  280. package/src/server/logger.ts +38 -35
  281. package/src/server/node-stream-transforms.ts +381 -0
  282. package/src/server/pipeline.ts +131 -39
  283. package/src/server/primitives.ts +47 -5
  284. package/src/server/render-timeout.ts +108 -0
  285. package/src/server/request-context.ts +112 -119
  286. package/src/server/route-element-builder.ts +106 -114
  287. package/src/server/route-handler.ts +2 -1
  288. package/src/server/route-matcher.ts +2 -2
  289. package/src/server/rsc-entry/error-renderer.ts +5 -3
  290. package/src/server/rsc-entry/helpers.ts +122 -3
  291. package/src/server/rsc-entry/index.ts +125 -49
  292. package/src/server/rsc-entry/rsc-payload.ts +52 -12
  293. package/src/server/rsc-entry/rsc-stream.ts +33 -8
  294. package/src/server/rsc-entry/ssr-renderer.ts +40 -13
  295. package/src/server/slot-resolver.ts +199 -210
  296. package/src/server/ssr-entry.ts +169 -17
  297. package/src/server/ssr-render.ts +266 -67
  298. package/src/server/tracing.ts +23 -0
  299. package/src/server/tree-builder.ts +91 -57
  300. package/src/server/types.ts +1 -3
  301. package/src/server/version-skew.ts +104 -0
  302. package/src/server/waituntil-bridge.ts +4 -1
  303. package/src/shared/merge-search-params.ts +48 -0
  304. package/src/shims/navigation-client.ts +1 -1
  305. package/src/shims/navigation.ts +1 -1
  306. package/src/utils/state-machine.ts +111 -0
  307. package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
  308. package/dist/_chunks/debug-B4WUeqJ-.js +0 -75
  309. package/dist/_chunks/debug-B4WUeqJ-.js.map +0 -1
  310. package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
  311. package/dist/_chunks/request-context-CZJi4CuK.js.map +0 -1
  312. package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
  313. package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
  314. package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
  315. package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
  316. package/dist/client/error-boundary.js.map +0 -1
  317. package/dist/cookies/index.js.map +0 -1
  318. package/dist/plugins/dynamic-transform.d.ts +0 -72
  319. package/dist/plugins/dynamic-transform.d.ts.map +0 -1
  320. package/dist/search-params/analyze.d.ts +0 -54
  321. package/dist/search-params/analyze.d.ts.map +0 -1
  322. package/dist/search-params/builtin-codecs.d.ts +0 -105
  323. package/dist/search-params/builtin-codecs.d.ts.map +0 -1
  324. package/dist/search-params/create.d.ts +0 -106
  325. package/dist/search-params/create.d.ts.map +0 -1
  326. package/dist/search-params/index.js.map +0 -1
  327. package/dist/server/prerender.d.ts +0 -77
  328. package/dist/server/prerender.d.ts.map +0 -1
  329. package/dist/server/response-cache.d.ts +0 -53
  330. package/dist/server/response-cache.d.ts.map +0 -1
  331. package/src/plugins/dynamic-transform.ts +0 -161
  332. package/src/search-params/analyze.ts +0 -192
  333. package/src/search-params/builtin-codecs.ts +0 -228
  334. package/src/search-params/create.ts +0 -321
  335. package/src/server/prerender.ts +0 -139
  336. package/src/server/response-cache.ts +0 -277
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Node.js native stream transforms for SSR HTML post-processing.
3
+ *
4
+ * These are Node.js Transform stream equivalents of the Web Stream
5
+ * transforms in html-injectors.ts. Used on Node.js/Bun where native
6
+ * streams (C++ backed) are faster than Web Streams (JS reimplementation).
7
+ *
8
+ * The transforms are pure string operations on HTML chunks — the same
9
+ * logic as the Web Stream versions, just wrapped in Node.js Transform
10
+ * instead of Web TransformStream.
11
+ *
12
+ * Architecture:
13
+ * renderToPipeableStream → pipe(errorHandler) → pipe(headInjector)
14
+ * → pipe(flightInjector) → Readable.toWeb() → Response
15
+ *
16
+ * All chunks stay in C++ Node.js stream buffers until the final
17
+ * Readable.toWeb() conversion for the Response body.
18
+ */
19
+
20
+ import { Transform } from 'node:stream';
21
+ import { createGzip, constants } from 'node:zlib';
22
+
23
+ import { createMachine } from '../utils/state-machine.js';
24
+ import { flightChunkScript } from './flight-scripts.js';
25
+ import {
26
+ flightInjectionTransitions,
27
+ isSuffixStripped,
28
+ isHtmlDone,
29
+ isPullDone,
30
+ type FlightInjectionState,
31
+ type FlightInjectionEvent,
32
+ } from './flight-injection-state.js';
33
+ import { withTimeout, RenderTimeoutError } from './render-timeout.js';
34
+ import { logStreamingError } from './logger.js';
35
+
36
+ // ─── Head Injection ──────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Node.js Transform that injects HTML content before </head>.
40
+ *
41
+ * Equivalent to injectHead() in html-injectors.ts. Streams chunks
42
+ * through immediately, keeping only a small trailing buffer to handle
43
+ * </head> split across chunk boundaries.
44
+ */
45
+ export function createNodeHeadInjector(headHtml: string): Transform {
46
+ if (!headHtml) {
47
+ return new Transform({
48
+ transform(chunk, _enc, cb) {
49
+ cb(null, chunk);
50
+ },
51
+ });
52
+ }
53
+
54
+ const target = '</head>';
55
+ const tailLen = target.length - 1;
56
+ let injected = false;
57
+ let tail = '';
58
+
59
+ return new Transform({
60
+ transform(chunk: Buffer, _encoding, callback) {
61
+ if (injected) {
62
+ callback(null, chunk);
63
+ return;
64
+ }
65
+
66
+ const text = tail + chunk.toString('utf-8');
67
+ const tagIndex = text.indexOf(target);
68
+
69
+ if (tagIndex !== -1) {
70
+ const before = text.slice(0, tagIndex);
71
+ const after = text.slice(tagIndex);
72
+ this.push(Buffer.from(before + headHtml + after, 'utf-8'));
73
+ injected = true;
74
+ tail = '';
75
+ callback();
76
+ } else {
77
+ const safeEnd = Math.max(0, text.length - tailLen);
78
+ if (safeEnd > 0) {
79
+ this.push(Buffer.from(text.slice(0, safeEnd), 'utf-8'));
80
+ }
81
+ tail = text.slice(safeEnd);
82
+ callback();
83
+ }
84
+ },
85
+ flush(callback) {
86
+ if (!injected && tail) {
87
+ this.push(Buffer.from(tail, 'utf-8'));
88
+ }
89
+ callback();
90
+ },
91
+ });
92
+ }
93
+
94
+ // ─── RSC Flight Injection ────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Node.js Transform that merges RSC script tags into the HTML stream.
98
+ *
99
+ * Equivalent to injectRscPayload() in html-injectors.ts. Combines
100
+ * createInlinedRscStream + createFlightInjectionTransform into a single
101
+ * Node.js Transform.
102
+ *
103
+ * 1. Strips `</body></html>` from the shell so all subsequent content
104
+ * is at `<body>` level.
105
+ * 2. Reads RSC chunks from the provided ReadableStream and injects them
106
+ * as `<script>` tags after HTML chunks.
107
+ * 3. Re-emits `</body></html>` at the very end.
108
+ *
109
+ * The RSC stream is a Web ReadableStream (from the tee'd RSC Flight
110
+ * stream). We read from it using the Web API — this is the one bridge
111
+ * point between Web Streams and Node.js streams in the pipeline.
112
+ */
113
+ /**
114
+ * Options for the Node.js flight injector.
115
+ */
116
+ export interface NodeFlightInjectorOptions {
117
+ /**
118
+ * Timeout in milliseconds for individual RSC stream reads.
119
+ * If a single `rscReader.read()` call does not resolve within
120
+ * this duration, the read is aborted and the stream errors with
121
+ * a RenderTimeoutError. Default: 30000 (30s).
122
+ */
123
+ renderTimeoutMs?: number;
124
+ }
125
+
126
+ export function createNodeFlightInjector(
127
+ rscStream: ReadableStream<Uint8Array> | undefined,
128
+ options?: NodeFlightInjectorOptions
129
+ ): Transform {
130
+ if (!rscStream) {
131
+ return new Transform({
132
+ transform(chunk, _enc, cb) {
133
+ cb(null, chunk);
134
+ },
135
+ });
136
+ }
137
+
138
+ const timeoutMs = options?.renderTimeoutMs ?? 30_000;
139
+ const suffix = '</body></html>';
140
+ const suffixBuf = Buffer.from(suffix, 'utf-8');
141
+ const rscReader = rscStream.getReader();
142
+ const decoder = new TextDecoder('utf-8', { fatal: true });
143
+
144
+ const machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
145
+ initial: { phase: 'init' },
146
+ transitions: flightInjectionTransitions,
147
+ });
148
+
149
+ // Stored promise from pullLoop — awaited in flush() via .then()
150
+ // instead of polling. Matches the Web Streams pattern in
151
+ // html-injectors.ts (pullPromise.then(finish)).
152
+ let pullPromise: Promise<void> | null = null;
153
+
154
+ // pullLoop reads RSC chunks and pushes them directly to the transform
155
+ // output as <script> tags. This ensures RSC data is delivered to the
156
+ // browser as soon as it's available — not deferred until the next HTML
157
+ // chunk. Critical for streaming: the shell RSC payload must arrive
158
+ // with the shell HTML so hydration can start before Suspense resolves.
159
+
160
+ async function pullLoop(stream: Transform): Promise<void> {
161
+ // Yield once so the first transform() call can emit the bootstrap
162
+ // signal before we start pushing data chunks.
163
+ await new Promise<void>((r) => setImmediate(r));
164
+ try {
165
+ for (;;) {
166
+ // Guard each RSC read with a timeout so a permanently hung
167
+ // RSC stream (e.g. a Suspense component with a fetch that
168
+ // never resolves) eventually aborts instead of blocking
169
+ // forever. When timeoutMs <= 0, the guard is disabled.
170
+ // See design/02-rendering-pipeline.md §"Streaming Constraints".
171
+ const readPromise = rscReader.read();
172
+ const { done, value } =
173
+ timeoutMs > 0
174
+ ? await withTimeout(readPromise, timeoutMs, 'RSC stream read timed out')
175
+ : await readPromise;
176
+ if (done) {
177
+ machine.send({ type: 'PULL_DONE' });
178
+ return;
179
+ }
180
+ const decoded = decoder.decode(value, { stream: true });
181
+ const scriptBuf = Buffer.from(flightChunkScript(decoded), 'utf-8');
182
+ // Push directly to the transform output — don't wait for an
183
+ // HTML chunk to trigger drainPending.
184
+ stream.push(scriptBuf);
185
+ // Yield between reads so HTML chunks get a chance to flow
186
+ // through transform() first — but only while HTML is still
187
+ // streaming. Once flush() fires (all HTML emitted), drain
188
+ // remaining RSC chunks without yielding.
189
+ if (!isHtmlDone(machine.state)) {
190
+ await new Promise<void>((r) => setImmediate(r));
191
+ }
192
+ }
193
+ } catch (err) {
194
+ // On timeout, cancel the RSC reader to release resources.
195
+ if (err instanceof RenderTimeoutError) {
196
+ rscReader.cancel(err).catch(() => {});
197
+ }
198
+ machine.send({ type: 'PULL_ERROR', error: err });
199
+ }
200
+ }
201
+
202
+ // No bootstrap script here — the init script is in <head> via
203
+ // flightInitScript() (see flight-scripts.ts). This ensures __timber_f
204
+ // exists before any chunk scripts execute.
205
+
206
+ const transform = new Transform({
207
+ transform(chunk: Buffer, _encoding, callback) {
208
+ const isFirst = machine.state.phase === 'init';
209
+ if (isFirst) {
210
+ machine.send({ type: 'FIRST_CHUNK' });
211
+ }
212
+
213
+ if (isSuffixStripped(machine.state)) {
214
+ transform.push(chunk);
215
+ callback();
216
+ return;
217
+ }
218
+
219
+ const text = chunk.toString('utf-8');
220
+ const idx = text.indexOf(suffix);
221
+ if (idx !== -1) {
222
+ machine.send({ type: 'SUFFIX_FOUND' });
223
+ const before = text.slice(0, idx);
224
+ const after = text.slice(idx + suffix.length);
225
+ if (before) transform.push(Buffer.from(before, 'utf-8'));
226
+ if (after) transform.push(Buffer.from(after, 'utf-8'));
227
+ } else {
228
+ transform.push(chunk);
229
+ }
230
+
231
+ // Start the pull loop on the first HTML chunk to stream RSC
232
+ // data chunks alongside the HTML. The __timber_f init script is
233
+ // already in <head> (via flightInitScript), so no bootstrap needed.
234
+ // Store the promise so flush() can await it instead of polling.
235
+ if (isFirst) {
236
+ pullPromise = pullLoop(transform);
237
+ }
238
+ callback();
239
+ },
240
+ flush(callback) {
241
+ // All HTML chunks have been emitted. Transition to flushing —
242
+ // the pull loop will stop yielding between RSC reads since
243
+ // isHtmlDone() now returns true.
244
+ machine.send({ type: 'HTML_DONE' });
245
+
246
+ const finish = () => {
247
+ if (machine.state.phase === 'error') {
248
+ const err = machine.state.error;
249
+ transform.destroy(err instanceof Error ? err : new Error(String(err)));
250
+ return;
251
+ }
252
+ const hadSuffix =
253
+ (machine.state.phase === 'done' && machine.state.hadSuffix) ||
254
+ (machine.state.phase === 'flushing' && machine.state.hadSuffix);
255
+ if (hadSuffix) {
256
+ transform.push(suffixBuf);
257
+ }
258
+ callback();
259
+ };
260
+
261
+ if (isPullDone(machine.state)) {
262
+ finish();
263
+ return;
264
+ }
265
+ // Wait for the RSC pull loop promise to resolve instead of
266
+ // polling with setImmediate. This matches the Web Streams
267
+ // pattern in html-injectors.ts: `pullPromise.then(finish)`.
268
+ // No CPU spin, no busy-poll — just a Promise chain.
269
+ if (!pullPromise) {
270
+ pullPromise = pullLoop(transform);
271
+ }
272
+ pullPromise.then(finish, (err) => {
273
+ machine.send({ type: 'PULL_ERROR', error: err });
274
+ finish();
275
+ });
276
+ },
277
+ });
278
+
279
+ return transform;
280
+ }
281
+
282
+ // ─── Error Handling ──────────────────────────────────────────────────────────
283
+
284
+ const NOINDEX_SCRIPT =
285
+ '<script>document.head.appendChild(Object.assign(document.createElement("meta"),{name:"robots",content:"noindex"}))</script>';
286
+
287
+ /**
288
+ * Node.js Transform that catches post-shell streaming errors.
289
+ *
290
+ * Equivalent to wrapStreamWithErrorHandling() in ssr-render.ts.
291
+ * Catches errors from React's streaming phase (deny/throw inside Suspense
292
+ * after the shell has flushed) and closes the stream cleanly.
293
+ */
294
+ export function createNodeErrorHandler(signal?: AbortSignal): Transform {
295
+ const transform = new Transform({
296
+ transform(chunk, _encoding, callback) {
297
+ callback(null, chunk);
298
+ },
299
+ });
300
+
301
+ transform.on('error', (error) => {
302
+ const isAbort =
303
+ (error instanceof DOMException && error.name === 'AbortError') ||
304
+ (error instanceof Error && error.name === 'AbortError') ||
305
+ signal?.aborted;
306
+
307
+ if (isAbort) {
308
+ transform.end();
309
+ return;
310
+ }
311
+
312
+ logStreamingError({ error });
313
+ transform.push(Buffer.from(NOINDEX_SCRIPT, 'utf-8'));
314
+ transform.end();
315
+ });
316
+
317
+ return transform;
318
+ }
319
+
320
+ // ─── Compression ─────────────────────────────────────────────────────────────
321
+
322
+ const COMPRESSIBLE_TYPES = new Set([
323
+ 'text/html',
324
+ 'text/css',
325
+ 'text/plain',
326
+ 'text/xml',
327
+ 'text/javascript',
328
+ 'text/x-component',
329
+ 'application/json',
330
+ 'application/javascript',
331
+ 'application/xml',
332
+ 'application/xhtml+xml',
333
+ 'application/rss+xml',
334
+ 'application/atom+xml',
335
+ 'image/svg+xml',
336
+ ]);
337
+
338
+ /**
339
+ * Create a Node.js gzip Transform using native node:zlib.
340
+ *
341
+ * Uses `createGzip()` which is backed by C++ zlib — significantly faster
342
+ * than the Web Streams `CompressionStream` API (which is a JS wrapper
343
+ * around the same zlib but with per-chunk Promise overhead).
344
+ *
345
+ * Returns null if the response shouldn't be compressed (wrong content type,
346
+ * client doesn't accept gzip, already encoded, etc.).
347
+ */
348
+ export function createNodeGzipCompressor(
349
+ requestHeaders: Headers,
350
+ responseHeaders: Headers
351
+ ): Transform | null {
352
+ // Check Accept-Encoding
353
+ const acceptEncoding = requestHeaders.get('accept-encoding') || '';
354
+ if (!acceptEncoding.includes('gzip')) return null;
355
+
356
+ // Check content type is compressible
357
+ const contentType = responseHeaders.get('content-type') || '';
358
+ const mimeType = contentType.split(';')[0].trim().toLowerCase();
359
+ if (!COMPRESSIBLE_TYPES.has(mimeType)) return null;
360
+
361
+ // Don't double-compress
362
+ if (responseHeaders.has('content-encoding')) return null;
363
+
364
+ // Set response headers for gzip
365
+ responseHeaders.set('content-encoding', 'gzip');
366
+ responseHeaders.delete('content-length');
367
+ const existingVary = responseHeaders.get('vary');
368
+ if (existingVary) {
369
+ if (!existingVary.toLowerCase().includes('accept-encoding')) {
370
+ responseHeaders.set('vary', existingVary + ', Accept-Encoding');
371
+ }
372
+ } else {
373
+ responseHeaders.set('vary', 'Accept-Encoding');
374
+ }
375
+
376
+ // Z_SYNC_FLUSH ensures each chunk is flushed to the output immediately.
377
+ // Without it, gzip buffers internally and the browser doesn't receive
378
+ // the HTML shell until the gzip stream closes — breaking streaming.
379
+ // ~2–5% size overhead vs Z_NO_FLUSH but preserves correct streaming.
380
+ return createGzip({ flush: constants.Z_SYNC_FLUSH });
381
+ }
@@ -21,6 +21,7 @@ import {
21
21
  setMutableCookieContext,
22
22
  getSetCookieHeaders,
23
23
  markResponseFlushed,
24
+ setSegmentParams,
24
25
  } from './request-context.js';
25
26
  import {
26
27
  generateTraceId,
@@ -42,6 +43,8 @@ import {
42
43
  } from './logger.js';
43
44
  import { callOnRequestError } from './instrumentation.js';
44
45
  import { RedirectSignal, DenySignal } from './primitives.js';
46
+ import { ParamCoercionError } from './route-element-builder.js';
47
+ import { checkVersionSkew, applyReloadHeaders } from './version-skew.js';
45
48
  import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
46
49
  import { findInterceptionMatch } from './pipeline-interception.js';
47
50
  import type { MiddlewareContext } from './types.js';
@@ -117,12 +120,15 @@ export interface PipelineConfig {
117
120
  */
118
121
  interceptionRewrites?: import('#/routing/interception.js').InterceptionRewrite[];
119
122
  /**
120
- * Emit Server-Timing header on responses for Chrome DevTools visibility.
121
- * Only enable in dev mode — exposes internal timing data.
123
+ * Control Server-Timing header output.
122
124
  *
123
- * Default: false (production-safe).
125
+ * - `'detailed'` — per-phase breakdown (proxy, middleware, render).
126
+ * - `'total'` — single `total;dur=N` entry (production-safe).
127
+ * - `false` — no Server-Timing header at all.
128
+ *
129
+ * Default: `'total'`.
124
130
  */
125
- enableServerTiming?: boolean;
131
+ serverTiming?: 'detailed' | 'total' | false;
126
132
  /**
127
133
  * Dev pipeline error callback — called when a pipeline phase (proxy,
128
134
  * middleware, render) catches an unhandled error. Used to wire the error
@@ -149,6 +155,42 @@ export interface PipelineConfig {
149
155
  ) => Response | Promise<Response>;
150
156
  }
151
157
 
158
+ // ─── Param Coercion ────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Run segment param coercion on the matched route's segments.
162
+ *
163
+ * Loads params.ts modules from segments that have them, extracts the
164
+ * segmentParams definition, and coerces raw string params through codecs.
165
+ * Throws ParamCoercionError if any codec fails (→ 404).
166
+ *
167
+ * This runs BEFORE middleware, so ctx.segmentParams is already typed.
168
+ * See design/07-routing.md §"Where Coercion Runs"
169
+ */
170
+ async function coerceSegmentParams(match: RouteMatch): Promise<void> {
171
+ const segments = match.segments as unknown as import('./route-matcher.js').ManifestSegmentNode[];
172
+
173
+ for (const segment of segments) {
174
+ // Only process segments that have a params.ts convention file
175
+ if (!segment.params) continue;
176
+
177
+ const mod = (await segment.params.load()) as Record<string, unknown>;
178
+ const segmentParamsDef = mod.segmentParams as
179
+ | { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
180
+ | undefined;
181
+
182
+ if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
183
+
184
+ try {
185
+ const coerced = segmentParamsDef.parse(match.params);
186
+ // Merge coerced values back into match.params
187
+ Object.assign(match.params, coerced);
188
+ } catch (err) {
189
+ throw new ParamCoercionError(err instanceof Error ? err.message : String(err));
190
+ }
191
+ }
192
+ }
193
+
152
194
  // ─── Pipeline ──────────────────────────────────────────────────────────────
153
195
 
154
196
  /**
@@ -165,7 +207,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
165
207
  earlyHints,
166
208
  stripTrailingSlash = true,
167
209
  slowRequestMs = 3000,
168
- enableServerTiming = false,
210
+ serverTiming = 'total',
169
211
  onPipelineError,
170
212
  } = config;
171
213
 
@@ -216,25 +258,25 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
216
258
  // DevSpanProcessor reads this for tree/summary output.
217
259
  await setSpanAttribute('http.response.status_code', result.status);
218
260
 
219
- // Append Server-Timing header.
220
- // In dev mode: detailed per-phase breakdown (proxy, middleware, render).
221
- // In production: single total duration — safe to expose, no phase names.
261
+ // Append Server-Timing header based on configured mode.
222
262
  // Response.redirect() creates immutable headers, so we must
223
263
  // ensure mutability before writing Server-Timing.
224
- if (enableServerTiming) {
225
- const serverTiming = getServerTimingHeader();
226
- if (serverTiming) {
264
+ if (serverTiming === 'detailed') {
265
+ // Detailed: per-phase breakdown (proxy, middleware, render).
266
+ const timingHeader = getServerTimingHeader();
267
+ if (timingHeader) {
227
268
  result = ensureMutableResponse(result);
228
- result.headers.set('Server-Timing', serverTiming);
269
+ result.headers.set('Server-Timing', timingHeader);
229
270
  }
230
- } else {
231
- // Production: emit total request duration only.
232
- // No phase breakdown prevents information disclosure
233
- // while giving browser DevTools useful timing data.
271
+ } else if (serverTiming === 'total') {
272
+ // Total only: single `total;dur=N` no phase names.
273
+ // Prevents information disclosure while giving browser
274
+ // DevTools useful timing data.
234
275
  const totalMs = Math.round(performance.now() - startTime);
235
276
  result = ensureMutableResponse(result);
236
277
  result.headers.set('Server-Timing', `total;dur=${totalMs}`);
237
278
  }
279
+ // serverTiming === false: no header at all
238
280
 
239
281
  return result;
240
282
  }
@@ -254,7 +296,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
254
296
  return response;
255
297
  };
256
298
 
257
- return enableServerTiming ? runWithTimingCollector(runRequest) : runRequest();
299
+ return serverTiming === 'detailed' ? runWithTimingCollector(runRequest) : runRequest();
258
300
  });
259
301
  });
260
302
  };
@@ -272,7 +314,7 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
272
314
  }
273
315
  const proxyFn = () => runProxy(proxyExport, req, () => handleRequest(req, method, path));
274
316
  return await withSpan('timber.proxy', {}, () =>
275
- enableServerTiming ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
317
+ serverTiming === 'detailed' ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
276
318
  );
277
319
  } catch (error) {
278
320
  // Uncaught proxy.ts error → bare HTTP 500
@@ -283,6 +325,24 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
283
325
  }
284
326
  }
285
327
 
328
+ /**
329
+ * Build a redirect Response from a RedirectSignal.
330
+ *
331
+ * For RSC payload requests (client navigation), returns 204 + X-Timber-Redirect
332
+ * so the client router can perform a soft SPA redirect. A raw 302 would be
333
+ * turned into an opaque redirect by fetch({redirect:'manual'}), crashing
334
+ * createFromFetch. See design/19-client-navigation.md.
335
+ */
336
+ function buildRedirectResponse(signal: RedirectSignal, req: Request, headers: Headers): Response {
337
+ const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
338
+ if (isRsc) {
339
+ headers.set('X-Timber-Redirect', signal.location);
340
+ return new Response(null, { status: 204, headers });
341
+ }
342
+ headers.set('Location', signal.location);
343
+ return new Response(null, { status: signal.status, headers });
344
+ }
345
+
286
346
  async function handleRequest(req: Request, method: string, path: string): Promise<Response> {
287
347
  // Stage 1: URL canonicalization
288
348
  const url = new URL(req.url);
@@ -339,6 +399,21 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
339
399
  }
340
400
  }
341
401
 
402
+ // Stage 1c: Version skew detection (TIM-446).
403
+ // For RSC payload requests (client navigation), check if the client's
404
+ // deployment ID matches the current build. On mismatch, signal the
405
+ // client to do a full page reload instead of returning an RSC payload
406
+ // that references mismatched module IDs.
407
+ const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
408
+ if (isRscRequest) {
409
+ const skewCheck = checkVersionSkew(req);
410
+ if (!skewCheck.ok) {
411
+ const reloadHeaders = new Headers();
412
+ applyReloadHeaders(reloadHeaders);
413
+ return new Response(null, { status: 204, headers: reloadHeaders });
414
+ }
415
+ }
416
+
342
417
  // Stage 2: Route matching
343
418
  let match = matchRoute(canonicalPathname);
344
419
  let interception: InterceptionContext | undefined;
@@ -397,18 +472,42 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
397
472
  }
398
473
  }
399
474
 
475
+ // Stage 2c: Param coercion (before middleware)
476
+ // Load params.ts modules from matched segments and coerce raw string
477
+ // params through defineSegmentParams codecs. Coercion failure → 404
478
+ // (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
479
+ try {
480
+ await coerceSegmentParams(match);
481
+ } catch (error) {
482
+ if (error instanceof ParamCoercionError) {
483
+ return new Response(null, { status: 404 });
484
+ }
485
+ throw error;
486
+ }
487
+
488
+ // Store coerced segment params in ALS so components can access them
489
+ // via rawSegmentParams() instead of receiving them as a prop.
490
+ // See design/07-routing.md §"params.ts — Convention File for Typed Params"
491
+ setSegmentParams(match.params);
492
+
400
493
  // Stage 3: Leaf middleware.ts (only the leaf route's middleware runs)
401
494
  if (match.middleware) {
402
495
  const ctx: MiddlewareContext = {
403
496
  req,
404
497
  requestHeaders: requestHeaderOverlay,
405
498
  headers: responseHeaders,
406
- params: match.params,
407
- searchParams: new URL(req.url).searchParams,
499
+ segmentParams: match.params,
408
500
  earlyHints: (hints) => {
409
501
  for (const hint of hints) {
410
- let value = `<${hint.href}>; rel=${hint.rel}`;
411
- if (hint.as !== undefined) value += `; as=${hint.as}`;
502
+ // Match Cloudflare's cached Early Hints attribute order: `as` before `rel`.
503
+ // Cloudflare caches Link headers and re-emits them on subsequent 200s.
504
+ // If our order differs, the browser sees duplicate preloads and warns.
505
+ let value: string;
506
+ if (hint.as !== undefined) {
507
+ value = `<${hint.href}>; as=${hint.as}; rel=${hint.rel}`;
508
+ } else {
509
+ value = `<${hint.href}>; rel=${hint.rel}`;
510
+ }
412
511
  if (hint.crossOrigin !== undefined) value += `; crossorigin=${hint.crossOrigin}`;
413
512
  if (hint.fetchPriority !== undefined) value += `; fetchpriority=${hint.fetchPriority}`;
414
513
  responseHeaders.append('Link', value);
@@ -421,7 +520,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
421
520
  setMutableCookieContext(true);
422
521
  const middlewareFn = () => runMiddleware(match.middleware!, ctx);
423
522
  const middlewareResponse = await withSpan('timber.middleware', {}, () =>
424
- enableServerTiming ? withTiming('mw', 'middleware.ts', middlewareFn) : middlewareFn()
523
+ serverTiming === 'detailed'
524
+ ? withTiming('mw', 'middleware.ts', middlewareFn)
525
+ : middlewareFn()
425
526
  );
426
527
  setMutableCookieContext(false);
427
528
  if (middlewareResponse) {
@@ -438,20 +539,10 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
438
539
  applyRequestHeaderOverlay(requestHeaderOverlay);
439
540
  } catch (error) {
440
541
  setMutableCookieContext(false);
441
- // RedirectSignal from middleware → HTTP redirect (not an error).
442
- // For RSC payload requests (client navigation), return 204 + X-Timber-Redirect
443
- // so the client router can perform a soft SPA redirect. A raw 302 would be
444
- // turned into an opaque redirect by fetch({redirect:'manual'}), crashing
445
- // createFromFetch. See design/19-client-navigation.md.
542
+ // RedirectSignal from middleware → HTTP redirect (not an error)
446
543
  if (error instanceof RedirectSignal) {
447
544
  applyCookieJar(responseHeaders);
448
- const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
449
- if (isRsc) {
450
- responseHeaders.set('X-Timber-Redirect', error.location);
451
- return new Response(null, { status: 204, headers: responseHeaders });
452
- }
453
- responseHeaders.set('Location', error.location);
454
- return new Response(null, { status: error.status, headers: responseHeaders });
545
+ return buildRedirectResponse(error, req, responseHeaders);
455
546
  }
456
547
  // DenySignal from middleware → HTTP deny status
457
548
  if (error instanceof DenySignal) {
@@ -476,7 +567,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
476
567
  const renderFn = () =>
477
568
  render(req, match, responseHeaders, requestHeaderOverlay, interception);
478
569
  const response = await withSpan('timber.render', { 'http.route': canonicalPathname }, () =>
479
- enableServerTiming ? withTiming('render', 'RSC + SSR render', renderFn) : renderFn()
570
+ serverTiming === 'detailed'
571
+ ? withTiming('render', 'RSC + SSR render', renderFn)
572
+ : renderFn()
480
573
  );
481
574
  markResponseFlushed();
482
575
  return response;
@@ -486,10 +579,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
486
579
  if (error instanceof DenySignal) {
487
580
  return new Response(null, { status: error.status });
488
581
  }
489
- // RedirectSignal leaked from render — honour the redirect.
582
+ // RedirectSignal leaked from render — honour the redirect
490
583
  if (error instanceof RedirectSignal) {
491
- responseHeaders.set('Location', error.location);
492
- return new Response(null, { status: error.status, headers: responseHeaders });
584
+ return buildRedirectResponse(error, req, responseHeaders);
493
585
  }
494
586
  logRenderError({ method, path, error });
495
587
  await fireOnRequestError(error, req, 'render');