@timber-js/app 0.2.0-alpha.196 → 0.2.0-alpha.198

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 (289) hide show
  1. package/dist/_chunks/{actions-CWYtq6ii.js → actions-BS-m5SLv.js} +3 -3
  2. package/dist/_chunks/{actions-CWYtq6ii.js.map → actions-BS-m5SLv.js.map} +1 -1
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/{build-manifest-DWppEdLB.js → build-manifest-DTmSGLRz.js} +51 -2
  5. package/dist/_chunks/build-manifest-DTmSGLRz.js.map +1 -0
  6. package/dist/_chunks/{cache-api-CQeYzA5g.js → cache-api-DqzgTEqk.js} +4 -49
  7. package/dist/_chunks/cache-api-DqzgTEqk.js.map +1 -0
  8. package/dist/_chunks/{chains-h7EO-u3n.js → chains-CZG7E5zg.js} +2 -2
  9. package/dist/_chunks/{chains-h7EO-u3n.js.map → chains-CZG7E5zg.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-BVthpfLS.js → cli-check-dVDi1GQz.js} +3 -3
  11. package/dist/_chunks/{cli-check-BVthpfLS.js.map → cli-check-dVDi1GQz.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js → cli-schema-sync-DTy_-Msq.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js.map → cli-schema-sync-DTy_-Msq.js.map} +1 -1
  14. package/dist/_chunks/{cloudflare-BKJC3SC_.js → cloudflare-BFb__LYG.js} +2 -2
  15. package/dist/_chunks/{cloudflare-BKJC3SC_.js.map → cloudflare-BFb__LYG.js.map} +1 -1
  16. package/dist/_chunks/{convention-lint-DO10_pVl.js → convention-lint-Ph6luW4c.js} +4 -2
  17. package/dist/_chunks/convention-lint-Ph6luW4c.js.map +1 -0
  18. package/dist/_chunks/{error-boundary-D-lkwyaD.js → error-boundary-BvRCCmbN.js} +3 -3
  19. package/dist/_chunks/{error-boundary-D-lkwyaD.js.map → error-boundary-BvRCCmbN.js.map} +1 -1
  20. package/dist/_chunks/{href-validation-CMc5JRls.js → href-validation-BIrxavIy.js} +74 -2
  21. package/dist/_chunks/href-validation-BIrxavIy.js.map +1 -0
  22. package/dist/_chunks/{live-graph-Bx4HodF1.js → live-graph-BXDsdzBv.js} +3 -3
  23. package/dist/_chunks/{live-graph-Bx4HodF1.js.map → live-graph-BXDsdzBv.js.map} +1 -1
  24. package/dist/_chunks/{logger-pumCm3Il.js → logger-DDirEsn7.js} +3 -4
  25. package/dist/_chunks/{logger-pumCm3Il.js.map → logger-DDirEsn7.js.map} +1 -1
  26. package/dist/_chunks/navigation-root-B00jjGd5.js +233 -0
  27. package/dist/_chunks/navigation-root-B00jjGd5.js.map +1 -0
  28. package/dist/_chunks/{segment-context-CjOlyB8Y.js → param-value-C8TNYchQ.js} +2 -33
  29. package/dist/_chunks/param-value-C8TNYchQ.js.map +1 -0
  30. package/dist/_chunks/{poison-scan-BAxfTT5L.js → poison-scan-BoDLgbix.js} +2 -2
  31. package/dist/_chunks/{poison-scan-BAxfTT5L.js.map → poison-scan-BoDLgbix.js.map} +1 -1
  32. package/dist/_chunks/{router-ref-BzqbPwYC.js → router-ref-8gr8qsxN.js} +2 -2
  33. package/dist/_chunks/{router-ref-BzqbPwYC.js.map → router-ref-8gr8qsxN.js.map} +1 -1
  34. package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js → rsc-cache-key-ClUiXQnK.js} +2 -2
  35. package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js.map → rsc-cache-key-ClUiXQnK.js.map} +1 -1
  36. package/dist/_chunks/{scanner-BRIOmHE2.js → scanner-tdFPvDYi.js} +174 -7
  37. package/dist/_chunks/scanner-tdFPvDYi.js.map +1 -0
  38. package/dist/_chunks/segment-context-D9_89u34.js +34 -0
  39. package/dist/_chunks/segment-context-D9_89u34.js.map +1 -0
  40. package/dist/_chunks/singleflight-2lUWfcAk.js +54 -0
  41. package/dist/_chunks/singleflight-2lUWfcAk.js.map +1 -0
  42. package/dist/_chunks/{ssr-data-Ya2HJPFp.js → ssr-data-BQGhTPAK.js} +2 -17
  43. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +1 -0
  44. package/dist/_chunks/{walkers-BU6z9xRV.js → walkers-DNX05dC0.js} +2 -2
  45. package/dist/_chunks/{walkers-BU6z9xRV.js.map → walkers-DNX05dC0.js.map} +1 -1
  46. package/dist/adapters/cloudflare-dev.js +1 -1
  47. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  48. package/dist/adapters/cloudflare.js +1 -1
  49. package/dist/adapters/nitro.d.ts +1 -1
  50. package/dist/adapters/nitro.d.ts.map +1 -1
  51. package/dist/adapters/nitro.js.map +1 -1
  52. package/dist/analyze/crawl-entry.js +2 -2
  53. package/dist/analyze/graph-command.js +2 -2
  54. package/dist/cache/index.js +1 -1
  55. package/dist/cache/singleflight.d.ts +2 -0
  56. package/dist/cache/singleflight.d.ts.map +1 -1
  57. package/dist/cli.js +2 -2
  58. package/dist/client/browser-entry/hydrate.d.ts +21 -15
  59. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  60. package/dist/client/browser-entry/index.d.ts +4 -3
  61. package/dist/client/browser-entry/index.d.ts.map +1 -1
  62. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  63. package/dist/client/browser-entry/router-init.d.ts +17 -1
  64. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  65. package/dist/client/error-boundary.js +1 -1
  66. package/dist/client/global-context.d.ts +15 -0
  67. package/dist/client/global-context.d.ts.map +1 -0
  68. package/dist/client/index.js +138 -35
  69. package/dist/client/index.js.map +1 -1
  70. package/dist/client/internal.d.ts +0 -1
  71. package/dist/client/internal.d.ts.map +1 -1
  72. package/dist/client/internal.js +206 -55
  73. package/dist/client/internal.js.map +1 -1
  74. package/dist/client/link.d.ts.map +1 -1
  75. package/dist/client/location-search.d.ts +12 -0
  76. package/dist/client/location-search.d.ts.map +1 -0
  77. package/dist/client/navigation-api.d.ts.map +1 -1
  78. package/dist/client/navigation-commit.d.ts +18 -0
  79. package/dist/client/navigation-commit.d.ts.map +1 -1
  80. package/dist/client/navigation-context.d.ts +13 -11
  81. package/dist/client/navigation-context.d.ts.map +1 -1
  82. package/dist/client/navigation-root.d.ts +47 -108
  83. package/dist/client/navigation-root.d.ts.map +1 -1
  84. package/dist/client/navigation-transition.d.ts +136 -0
  85. package/dist/client/navigation-transition.d.ts.map +1 -0
  86. package/dist/client/nuqs-adapter.d.ts.map +1 -1
  87. package/dist/client/params-context.d.ts +4 -5
  88. package/dist/client/params-context.d.ts.map +1 -1
  89. package/dist/client/react-root.d.ts +44 -0
  90. package/dist/client/react-root.d.ts.map +1 -0
  91. package/dist/client/router-pipeline.d.ts +2 -2
  92. package/dist/client/router-pipeline.d.ts.map +1 -1
  93. package/dist/client/router-types.d.ts +12 -2
  94. package/dist/client/router-types.d.ts.map +1 -1
  95. package/dist/client/router.d.ts.map +1 -1
  96. package/dist/client/segment-cache.d.ts +39 -0
  97. package/dist/client/segment-cache.d.ts.map +1 -1
  98. package/dist/client/segment-context.d.ts.map +1 -1
  99. package/dist/client/segment-outlet.d.ts +25 -14
  100. package/dist/client/segment-outlet.d.ts.map +1 -1
  101. package/dist/client/segment-update-context.d.ts +3 -9
  102. package/dist/client/segment-update-context.d.ts.map +1 -1
  103. package/dist/client/slot-content-cache-context.d.ts +35 -0
  104. package/dist/client/slot-content-cache-context.d.ts.map +1 -0
  105. package/dist/client/ssr-data.d.ts +8 -2
  106. package/dist/client/ssr-data.d.ts.map +1 -1
  107. package/dist/client/state.d.ts +0 -15
  108. package/dist/client/state.d.ts.map +1 -1
  109. package/dist/client/use-pathname.d.ts +13 -11
  110. package/dist/client/use-pathname.d.ts.map +1 -1
  111. package/dist/client/use-search-params.d.ts +13 -13
  112. package/dist/client/use-search-params.d.ts.map +1 -1
  113. package/dist/client/use-segment-params.d.ts +18 -68
  114. package/dist/client/use-segment-params.d.ts.map +1 -1
  115. package/dist/config-types.d.ts +17 -0
  116. package/dist/config-types.d.ts.map +1 -1
  117. package/dist/config-validation.d.ts.map +1 -1
  118. package/dist/cookies/index.js +1 -1
  119. package/dist/dev-tools/holding-server.d.ts +15 -10
  120. package/dist/dev-tools/holding-server.d.ts.map +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +44 -43
  123. package/dist/index.js.map +1 -1
  124. package/dist/plugins/dev-server.d.ts.map +1 -1
  125. package/dist/plugins/entries.d.ts.map +1 -1
  126. package/dist/plugins/shims.d.ts.map +1 -1
  127. package/dist/plugins/static-build.d.ts +2 -2
  128. package/dist/plugins/static-build.d.ts.map +1 -1
  129. package/dist/routing/codegen-write.d.ts.map +1 -1
  130. package/dist/routing/index.js +2 -2
  131. package/dist/routing/interception-overlap.d.ts +35 -0
  132. package/dist/routing/interception-overlap.d.ts.map +1 -0
  133. package/dist/routing/interception.d.ts.map +1 -1
  134. package/dist/rsc-runtime/ssr.d.ts +3 -1
  135. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  136. package/dist/server/als-registry.d.ts +6 -0
  137. package/dist/server/als-registry.d.ts.map +1 -1
  138. package/dist/server/csp-nonce.d.ts +45 -0
  139. package/dist/server/csp-nonce.d.ts.map +1 -0
  140. package/dist/server/default-status-page.d.ts.map +1 -1
  141. package/dist/server/deny-renderer.d.ts.map +1 -1
  142. package/dist/server/flight-scripts.d.ts +5 -2
  143. package/dist/server/flight-scripts.d.ts.map +1 -1
  144. package/dist/server/html-injector-core.d.ts +17 -2
  145. package/dist/server/html-injector-core.d.ts.map +1 -1
  146. package/dist/server/html-injectors.d.ts +3 -2
  147. package/dist/server/html-injectors.d.ts.map +1 -1
  148. package/dist/server/index.js +2 -2
  149. package/dist/server/internal.js +86 -37
  150. package/dist/server/internal.js.map +1 -1
  151. package/dist/server/metadata-render.d.ts.map +1 -1
  152. package/dist/server/node-stream-transforms.d.ts +3 -17
  153. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  154. package/dist/server/nuqs-ssr-provider.d.ts +7 -3
  155. package/dist/server/nuqs-ssr-provider.d.ts.map +1 -1
  156. package/dist/server/pipeline-phases.d.ts.map +1 -1
  157. package/dist/server/prebuilt/key-discipline.d.ts +32 -3
  158. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -1
  159. package/dist/server/primitives.d.ts.map +1 -1
  160. package/dist/server/render-utils.d.ts +4 -3
  161. package/dist/server/render-utils.d.ts.map +1 -1
  162. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  163. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  164. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  166. package/dist/server/ssr-bridge-types.d.ts +22 -2
  167. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  168. package/dist/server/ssr-entry.d.ts.map +1 -1
  169. package/dist/server/ssr-render.d.ts +5 -1
  170. package/dist/server/ssr-render.d.ts.map +1 -1
  171. package/dist/server/ssr-wrappers.d.ts +59 -27
  172. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  173. package/dist/server/types.d.ts +10 -0
  174. package/dist/server/types.d.ts.map +1 -1
  175. package/dist/shims/navigation-rsc.d.ts +21 -0
  176. package/dist/shims/navigation-rsc.d.ts.map +1 -0
  177. package/docs/api/30-api-server.mdx +1 -0
  178. package/docs/api/35-api-typescript.mdx +4 -83
  179. package/docs/learn/03-fetching-data.mdx +1 -1
  180. package/docs/learn/{03b-access-control.mdx → 04-access-control.mdx} +2 -17
  181. package/docs/learn/05-the-flush-point.mdx +175 -0
  182. package/docs/learn/{05-typed-params.mdx → 06-typed-params.mdx} +1 -1
  183. package/docs/learn/07-typed-routes.mdx +25 -49
  184. package/docs/learn/{08-streaming.mdx → 09-streaming.mdx} +1 -7
  185. package/docs/learn/{10-middleware.mdx → 11-middleware.mdx} +1 -0
  186. package/package.json +3 -3
  187. package/src/adapters/nitro.ts +7 -7
  188. package/src/cache/singleflight.ts +5 -0
  189. package/src/client/browser-entry/hydrate.ts +54 -104
  190. package/src/client/browser-entry/index.ts +16 -6
  191. package/src/client/browser-entry/post-hydration.ts +3 -2
  192. package/src/client/browser-entry/router-init.ts +84 -33
  193. package/src/client/global-context.ts +31 -0
  194. package/src/client/internal.ts +1 -2
  195. package/src/client/link.tsx +18 -18
  196. package/src/client/location-search.ts +15 -0
  197. package/src/client/navigation-api.ts +4 -2
  198. package/src/client/navigation-commit.ts +48 -2
  199. package/src/client/navigation-context.ts +25 -37
  200. package/src/client/navigation-root.tsx +55 -411
  201. package/src/client/navigation-transition.ts +278 -0
  202. package/src/client/nuqs-adapter.tsx +4 -5
  203. package/src/client/params-context.ts +13 -18
  204. package/src/client/react-root.ts +72 -0
  205. package/src/client/router-lifecycle.ts +1 -1
  206. package/src/client/router-pipeline.ts +96 -22
  207. package/src/client/router-types.ts +12 -2
  208. package/src/client/router.ts +48 -36
  209. package/src/client/segment-cache.ts +70 -2
  210. package/src/client/segment-context.ts +7 -4
  211. package/src/client/segment-outlet.tsx +41 -86
  212. package/src/client/segment-update-context.ts +7 -26
  213. package/src/client/slot-content-cache-context.ts +43 -0
  214. package/src/client/ssr-data.ts +8 -2
  215. package/src/client/state.ts +0 -26
  216. package/src/client/use-pathname.ts +21 -31
  217. package/src/client/use-search-params.ts +31 -29
  218. package/src/client/use-segment-params.ts +27 -126
  219. package/src/config-types.ts +17 -0
  220. package/src/config-validation.ts +17 -0
  221. package/src/dev-tools/holding-server.ts +23 -12
  222. package/src/index.ts +26 -11
  223. package/src/plugins/dev-server.ts +9 -12
  224. package/src/plugins/entries.ts +3 -0
  225. package/src/plugins/shims.ts +8 -7
  226. package/src/plugins/static-build.ts +9 -5
  227. package/src/react-canary.d.ts +2 -0
  228. package/src/routing/codegen-write.ts +2 -0
  229. package/src/routing/interception-overlap.ts +141 -0
  230. package/src/routing/interception.ts +118 -5
  231. package/src/rsc-runtime/ssr.ts +3 -2
  232. package/src/server/als-registry.ts +6 -0
  233. package/src/server/csp-nonce.ts +70 -0
  234. package/src/server/default-status-page.ts +1 -0
  235. package/src/server/deny-renderer.ts +7 -3
  236. package/src/server/flight-scripts.ts +9 -4
  237. package/src/server/html-injector-core.ts +26 -9
  238. package/src/server/html-injectors.ts +8 -8
  239. package/src/server/metadata-render.ts +26 -4
  240. package/src/server/node-stream-transforms.ts +7 -20
  241. package/src/server/nuqs-ssr-provider.tsx +8 -7
  242. package/src/server/pipeline-phases.ts +5 -0
  243. package/src/server/prebuilt/key-discipline.ts +82 -13
  244. package/src/server/prebuilt-runtime.ts +2 -2
  245. package/src/server/primitives.ts +4 -4
  246. package/src/server/render-utils.ts +8 -4
  247. package/src/server/rsc-entry/action-middleware-runner.ts +7 -0
  248. package/src/server/rsc-entry/error-renderer.ts +5 -2
  249. package/src/server/rsc-entry/index.ts +8 -0
  250. package/src/server/rsc-entry/ssr-renderer.ts +11 -4
  251. package/src/server/ssr-bridge-types.ts +22 -2
  252. package/src/server/ssr-entry.ts +35 -28
  253. package/src/server/ssr-render.ts +13 -4
  254. package/src/server/ssr-wrappers.tsx +81 -61
  255. package/src/server/types.ts +10 -0
  256. package/src/shared/slot-params.ts +3 -4
  257. package/src/shims/navigation-rsc.ts +47 -0
  258. package/dist/_chunks/build-manifest-DWppEdLB.js.map +0 -1
  259. package/dist/_chunks/cache-api-CQeYzA5g.js.map +0 -1
  260. package/dist/_chunks/convention-lint-DO10_pVl.js.map +0 -1
  261. package/dist/_chunks/href-validation-CMc5JRls.js.map +0 -1
  262. package/dist/_chunks/scanner-BRIOmHE2.js.map +0 -1
  263. package/dist/_chunks/segment-context-CjOlyB8Y.js.map +0 -1
  264. package/dist/_chunks/slot-params-BCTmZkQB.js +0 -76
  265. package/dist/_chunks/slot-params-BCTmZkQB.js.map +0 -1
  266. package/dist/_chunks/ssr-data-Ya2HJPFp.js.map +0 -1
  267. package/dist/_chunks/use-segment-params-DzTBpkvj.js +0 -398
  268. package/dist/_chunks/use-segment-params-DzTBpkvj.js.map +0 -1
  269. package/docs/learn/04-loading-states.mdx +0 -67
  270. package/docs/learn/04b-the-flush-point.mdx +0 -115
  271. package/docs/learn/12-client-navigation.mdx +0 -176
  272. package/docs/learn/13-configuration.mdx +0 -166
  273. package/docs/more/01-advanced-routing.mdx +0 -344
  274. package/docs/more/02-advanced-forms.mdx +0 -137
  275. package/docs/more/03-coming-from-nextjs.mdx +0 -186
  276. package/docs/more/04-metadata-and-fonts.mdx +0 -193
  277. package/docs/more/04b-mdx.mdx +0 -229
  278. package/docs/more/05-content-collections.mdx +0 -90
  279. package/docs/more/06-instrumentation.mdx +0 -214
  280. package/docs/more/07-security.mdx +0 -129
  281. package/docs/more/08-developer-experience.mdx +0 -134
  282. package/docs/more/40-why-timber.mdx +0 -50
  283. package/docs/more/41-timber-vs-nextjs.mdx +0 -81
  284. package/docs/more/42-timber-vs-others.mdx +0 -68
  285. package/docs/more/50-ai-agent-instructions.mdx +0 -171
  286. /package/docs/learn/{06-forms-and-actions.mdx → 08-forms-and-actions.mdx} +0 -0
  287. /package/docs/learn/{09-caching.mdx → 10-caching.mdx} +0 -0
  288. /package/docs/learn/{11-error-handling.mdx → 12-error-handling.mdx} +0 -0
  289. /package/docs/learn/{14-deploying.mdx → 13-deploying.mdx} +0 -0
@@ -0,0 +1,2 @@
1
+ /// <reference types="react/canary" />
2
+ /// <reference types="react-dom/canary" />
@@ -60,6 +60,8 @@ const TIMBER_ENV_DTS = [
60
60
  '/* eslint-disable @typescript-eslint/triple-slash-reference */',
61
61
  '/// <reference path=".timber/timber-routes.d.ts" />',
62
62
  '/// <reference types="vite/client" />',
63
+ '/// <reference types="react/canary" />',
64
+ '/// <reference types="react-dom/canary" />',
63
65
  '',
64
66
  ].join('\n');
65
67
 
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pattern intersection for intercepted URL patterns.
3
+ *
4
+ * Checks whether two intercepted URL patterns can both match the same concrete
5
+ * URL. Used by the cross-mode claim check (TIM-1319) to detect when a
6
+ * children-path interceptor and a slot interceptor from the same owner overlap.
7
+ *
8
+ * This is not the order-dependence oracle (`findOrderDependentCollision`) the
9
+ * within-mode collision checks use. `findInterceptionMatch` picks the mode by
10
+ * first-match on `pathnameMatchesPattern`, which has no priority tiers, so a
11
+ * static `/feed/foo` and a dynamic `/feed/[id]` both match `/feed/foo` even
12
+ * though `matchUrlParts` would resolve the static deterministically. The
13
+ * intersection test catches that: it walks both patterns segment by segment and
14
+ * asks whether any value satisfies both.
15
+ *
16
+ * This is not a model of `matchNode` precedence — that decides *which*
17
+ * candidate wins when several match one URL. This decides the prior question:
18
+ * *do* they both match one URL at all? The two questions are independent, and
19
+ * conflating them is what caused TIM-1308's five-round drift.
20
+ */
21
+
22
+ import { classifyUrlSegment } from './segment-classify.ts';
23
+
24
+ /**
25
+ * Check whether two intercepted URL patterns can both match the same concrete
26
+ * URL. Returns a display string for the overlapping URL, or null.
27
+ *
28
+ * Walks both patterns segment by segment:
29
+ * - Two statics: must be identical.
30
+ * - Static + dynamic: static must satisfy the dynamic's affix constraints.
31
+ * - Two dynamics: affix constraints must be compatible.
32
+ * - A catch-all at either side: compatible with everything remaining.
33
+ *
34
+ * If the patterns differ in length, they can only overlap if the longer one's
35
+ * extra part is an optional catch-all (which also matches zero segments).
36
+ */
37
+ export function interceptedPatternsOverlap(p1: string, p2: string): string | null {
38
+ if (p1 === p2) return p1;
39
+
40
+ const parts1 = p1 === '/' ? [] : p1.slice(1).split('/');
41
+ const parts2 = p2 === '/' ? [] : p2.slice(1).split('/');
42
+ const display: string[] = [];
43
+ let i = 0;
44
+
45
+ while (i < parts1.length && i < parts2.length) {
46
+ const seg1 = classifyUrlSegment(parts1[i]);
47
+ const seg2 = classifyUrlSegment(parts2[i]);
48
+
49
+ if (
50
+ seg1.kind === 'catch-all' ||
51
+ seg1.kind === 'optional-catch-all' ||
52
+ seg2.kind === 'catch-all' ||
53
+ seg2.kind === 'optional-catch-all'
54
+ ) {
55
+ display.push('<any>');
56
+ return '/' + display.join('/');
57
+ }
58
+
59
+ const part = segmentsOverlap(seg1, seg2);
60
+ if (part === null) return null;
61
+ display.push(part);
62
+ i++;
63
+ }
64
+
65
+ const remaining = i < parts1.length ? parts1 : i < parts2.length ? parts2 : null;
66
+ if (remaining) {
67
+ const next = classifyUrlSegment(remaining[i]);
68
+ if (next.kind !== 'optional-catch-all') return null;
69
+ }
70
+
71
+ return display.length === 0 ? '/' : '/' + display.join('/');
72
+ }
73
+
74
+ /**
75
+ * Can some concrete value satisfy both classified URL segments?
76
+ *
77
+ * An affixed dynamic (`a-[id].png`) constrains both the prefix and suffix of
78
+ * the value, so a static segment must satisfy those constraints and two
79
+ * dynamics must have compatible affixes — prefixes where one extends the other,
80
+ * and likewise for suffixes. Without this check, `/feed/photo.jpg` beside
81
+ * `/feed/asset-[id].png` would be falsely rejected.
82
+ */
83
+ function segmentsOverlap(
84
+ seg1: ReturnType<typeof classifyUrlSegment>,
85
+ seg2: ReturnType<typeof classifyUrlSegment>
86
+ ): string | null {
87
+ if (seg1.kind === 'static' && seg2.kind === 'static') {
88
+ return seg1.value === seg2.value ? seg1.value : null;
89
+ }
90
+
91
+ if (seg1.kind === 'static' && seg2.kind === 'dynamic') {
92
+ return staticMatchesAffix(seg1.value, seg2) ? seg1.value : null;
93
+ }
94
+ if (seg2.kind === 'static' && seg1.kind === 'dynamic') {
95
+ return staticMatchesAffix(seg2.value, seg1) ? seg2.value : null;
96
+ }
97
+
98
+ if (seg1.kind === 'dynamic' && seg2.kind === 'dynamic') {
99
+ return dynamicAffixesCompatible(seg1, seg2) ? '<any>' : null;
100
+ }
101
+
102
+ return '<any>';
103
+ }
104
+
105
+ /**
106
+ * Does a static value satisfy an affixed dynamic's prefix, suffix, and
107
+ * non-empty capture constraints?
108
+ *
109
+ * Mirrors the check `pathnameMatchesPattern` applies: the value must start with
110
+ * the prefix, end with the suffix, and be strictly longer than their combined
111
+ * length (a capture of zero characters is not a match).
112
+ */
113
+ function staticMatchesAffix(value: string, dynamic: { prefix?: string; suffix?: string }): boolean {
114
+ const prefix = dynamic.prefix ?? '';
115
+ const suffix = dynamic.suffix ?? '';
116
+ if (!prefix && !suffix) return true;
117
+ if (prefix && !value.startsWith(prefix)) return false;
118
+ if (suffix && !value.endsWith(suffix)) return false;
119
+ return value.length > prefix.length + suffix.length;
120
+ }
121
+
122
+ /**
123
+ * Can some value simultaneously satisfy two affixed dynamics?
124
+ *
125
+ * Two prefix constraints are compatible when one extends the other (a value
126
+ * starting with the longer one also starts with the shorter). Suffixes are
127
+ * analogous. Without affixes on either side, every value works.
128
+ */
129
+ function dynamicAffixesCompatible(
130
+ d1: { prefix?: string; suffix?: string },
131
+ d2: { prefix?: string; suffix?: string }
132
+ ): boolean {
133
+ const p1 = d1.prefix ?? '',
134
+ s1 = d1.suffix ?? '';
135
+ const p2 = d2.prefix ?? '',
136
+ s2 = d2.suffix ?? '';
137
+
138
+ if (p1 && p2 && !p1.startsWith(p2) && !p2.startsWith(p1)) return false;
139
+ if (s1 && s2 && !s1.endsWith(s2) && !s2.endsWith(s1)) return false;
140
+ return true;
141
+ }
@@ -24,6 +24,7 @@ import { classifyUrlSegment, effectiveUrlSegment } from './segment-classify.ts';
24
24
  // — decides order-dependence with the same oracle, so the spaces cannot drift
25
25
  // on what counts as two pages answering one URL. See TIM-1308.
26
26
  import { findOrderDependentCollision, type ProbeLeaf } from './collision-probe.ts';
27
+ import { interceptedPatternsOverlap } from './interception-overlap.ts';
27
28
 
28
29
  /** Fields shared by both interception shapes. */
29
30
  interface InterceptionRewriteBase {
@@ -256,11 +257,12 @@ export function collectInterceptionRewrites(root: SegmentNode): InterceptionRewr
256
257
  * nothing per request.
257
258
  *
258
259
  * Sorting is stable, so rewrites sharing one scope keep tree-walk order. Equal
259
- * scopes mean one owner, and of the three pairings that produces only one is an
260
- * ambiguity — a children-path interceptor beside a slot one, tracked as
261
- * TIM-1319. The enumeration is in design/07-routing.md §"Selection Between
262
- * Owners" rather than repeated here, so there is one place to correct if a
263
- * rewrite ever grows a field that makes two of them distinguishable.
260
+ * scopes mean one owner, and of the three pairings that produces only one was
261
+ * ambiguous — a children-path interceptor beside a slot one — and that is now
262
+ * rejected at build time by `assertNoCrossModeClaims` (TIM-1319). The
263
+ * enumeration is in design/07-routing.md §"Selection Between Owners" rather
264
+ * than repeated here, so there is one place to correct if a rewrite ever grows
265
+ * a field that makes two of them distinguishable.
264
266
  */
265
267
  function sortByScopeDepth(rewrites: InterceptionRewrite[]): InterceptionRewrite[] {
266
268
  return rewrites.sort(
@@ -595,6 +597,108 @@ function assertNoInterceptedPatternCollisions(owner: SegmentNode, ownerPath: str
595
597
  }
596
598
  }
597
599
 
600
+ /**
601
+ * Reject a children-path interceptor and a slot interceptor from the same owner
602
+ * that claim URLs in common.
603
+ *
604
+ * Within one mode the ambiguity is either non-existent (two children-path
605
+ * rewrites are interchangeable, §"Selection Between Owners") or harmless (two
606
+ * slots on one owner both render). Across modes one is a layout swap and the
607
+ * other is a modal overlay, and the walk order picks the children-path shape —
608
+ * making the modal dead, silently and deterministically (TIM-1319).
609
+ *
610
+ * Overlap is a **pattern intersection** test: do two intercepted URL patterns
611
+ * share any concrete URL? This is not the same question the within-mode
612
+ * collision oracle answers. `findOrderDependentCollision` checks whether two
613
+ * candidates in one `matchUrlParts` walk produce different winners when
614
+ * `children` order is reversed — that is, order-dependence. A static segment
615
+ * always beats a dynamic one deterministically (no order-dependence), so
616
+ * `/feed/foo` and `/feed/[id]` pass the oracle even though
617
+ * `pathnameMatchesPattern` — the actual rewrite matcher — accepts both. The
618
+ * pattern intersection test catches that: it walks both patterns segment by
619
+ * segment and asks whether any value satisfies both.
620
+ *
621
+ * The check collects intercepted patterns from children-path interceptors and
622
+ * from each slot's interceptors independently. Slots are checked one at a time
623
+ * so two slot interceptors from *different* slots claiming one URL — the legal
624
+ * "two slots on one owner" case — are never compared.
625
+ */
626
+ function assertNoCrossModeClaims(node: SegmentNode, ownerPath: string): void {
627
+ // Collect children-path interceptor patterns
628
+ const childrenLeaves: Array<{ pattern: string; filePath: string }> = [];
629
+ for (const child of node.children) {
630
+ if (
631
+ child.segmentType !== 'intercepting' ||
632
+ !child.interceptionMarker ||
633
+ !child.interceptedSegmentName
634
+ )
635
+ continue;
636
+ const urlBase = computeInterceptedUrlBase(
637
+ node.urlPath,
638
+ child.interceptionMarker,
639
+ child.interceptedSegmentName
640
+ );
641
+ walkInterceptedLeaves(child, urlBase, [child], (page, leaf) => {
642
+ childrenLeaves.push({ pattern: leaf.interceptedPattern, filePath: page.filePath });
643
+ });
644
+ }
645
+
646
+ if (childrenLeaves.length === 0) return;
647
+
648
+ const owner = ownerPath || '(app root)';
649
+
650
+ for (const slot of Object.values(node.slots)) {
651
+ const slotLeaves: Array<{ pattern: string; filePath: string }> = [];
652
+ for (const child of slot.children) {
653
+ if (
654
+ child.segmentType !== 'intercepting' ||
655
+ !child.interceptionMarker ||
656
+ !child.interceptedSegmentName
657
+ )
658
+ continue;
659
+ const urlBase = computeInterceptedUrlBase(
660
+ node.urlPath,
661
+ child.interceptionMarker,
662
+ child.interceptedSegmentName
663
+ );
664
+ walkInterceptedLeaves(child, urlBase, [child], (page, leaf) => {
665
+ slotLeaves.push({ pattern: leaf.interceptedPattern, filePath: page.filePath });
666
+ });
667
+ }
668
+
669
+ for (const cl of childrenLeaves) {
670
+ for (const sl of slotLeaves) {
671
+ const overlap = interceptedPatternsOverlap(cl.pattern, sl.pattern);
672
+ if (overlap !== null) {
673
+ throwCrossModeClaim(owner, overlap, cl.filePath, sl.filePath);
674
+ }
675
+ }
676
+ }
677
+ }
678
+ }
679
+
680
+ function throwCrossModeClaim(
681
+ owner: string,
682
+ url: string,
683
+ childFile: string,
684
+ slotFile: string
685
+ ): never {
686
+ throw new Error(
687
+ `Build error: a children-path interception and a slot interception both ` +
688
+ `claim the same URL.\n` +
689
+ ` Owner: app/${owner}\n` +
690
+ ` URL: ${url}\n` +
691
+ ` Children: ${childFile}\n` +
692
+ ` Slot: ${slotFile}\n` +
693
+ `The children-path interceptor replaces the owner's children (layout swap) ` +
694
+ `while the slot interceptor renders inside a parallel slot (modal overlay). ` +
695
+ `Both apply from the same scope at the same URL, and which one wins would ` +
696
+ `depend on internal walk order rather than an explicit rule.\n` +
697
+ `Remove one of the two, or move them to different scopes.\n` +
698
+ `See design/07-routing.md §"Selection Between Owners".`
699
+ );
700
+ }
701
+
598
702
  /**
599
703
  * The URL-matching identity of an intercepting node, as a node.
600
704
  *
@@ -792,6 +896,15 @@ function walkForPlacement(node: SegmentNode, path: string, ctx: PlacementContext
792
896
  ancestorsBindParams: false,
793
897
  });
794
898
  }
899
+
900
+ // After both children and slots have been validated independently, check for
901
+ // the cross-mode ambiguity that neither space covers: a children-path
902
+ // interceptor and a slot interceptor from the same owner claiming one URL.
903
+ // Within each mode the collision is either ordered (two children-path) or
904
+ // harmless (two slots) — see §"Selection Between Owners" — but across modes
905
+ // one is an overlay and the other a layout swap, and the walk order picks the
906
+ // children-path shape silently (TIM-1319).
907
+ assertNoCrossModeClaims(node, path);
795
908
  }
796
909
 
797
910
  /**
@@ -53,10 +53,11 @@ try {
53
53
  * signaling the caller to use createFromReadableStream instead.
54
54
  */
55
55
  export function createFromNodeStream(
56
- stream: import('node:stream').Readable
56
+ stream: import('node:stream').Readable,
57
+ options?: { nonce?: string }
57
58
  ): React.ReactNode | null {
58
59
  if (!_createFromNodeStream) return null;
59
- return _createFromNodeStream(stream, createServerConsumerManifest());
60
+ return _createFromNodeStream(stream, createServerConsumerManifest(), options);
60
61
  }
61
62
 
62
63
  /** Whether the Node.js stream RSC decode path is available. */
@@ -76,6 +76,12 @@ export interface RequestContextStore {
76
76
  parsedCookies?: Map<string, string>;
77
77
  /** Original (pre-overlay) frozen headers, kept for overlay merging. */
78
78
  originalHeaders: Headers;
79
+ /**
80
+ * CSP nonce, created the first time middleware reads `ctx.nonce`. Read by
81
+ * the renderers via `getCspNonce()` and stamped on every inline script.
82
+ * Absent when no middleware asked for one. See server/csp-nonce.ts.
83
+ */
84
+ cspNonce?: string;
79
85
  /**
80
86
  * Raw URLSearchParams for the current request.
81
87
  * To get typed parsed params, import a search params definition and
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CSP nonce — per-request, generated on demand.
3
+ *
4
+ * timber emits several inline `<script>` tags per HTML response — React's
5
+ * bootstrap script and Fizz runtime, the Flight init/chunk scripts, the
6
+ * segment-tree script, and the post-flush noindex script. Under a strict
7
+ * `Content-Security-Policy` (`script-src 'nonce-…' 'strict-dynamic'`) every
8
+ * one of them needs the request's nonce or the browser refuses to run it.
9
+ *
10
+ * The app opts in by reading `ctx.nonce` in `middleware.ts` and putting it
11
+ * in the CSP header it sets through `ctx.headers`. The first read generates
12
+ * the value and stores it on the request context; the render pipeline reads
13
+ * it back with `getCspNonce()` and stamps it on every inline script. A
14
+ * request whose middleware never reads `ctx.nonce` has no nonce, and its
15
+ * markup is byte-identical to a render without this feature. timber does
16
+ * not parse the CSP header — the app owns the policy, timber owns the
17
+ * markup, and `ctx.nonce` is the one value both are built from.
18
+ *
19
+ * `proxy.ts` cannot participate: it only sees the finished response from
20
+ * `next()`, after the markup exists.
21
+ *
22
+ * See design/13-security.md §"Content Security Policy" (TIM-1414).
23
+ */
24
+
25
+ import { requestContextAls } from './als-registry.ts';
26
+
27
+ /**
28
+ * Generate a CSP nonce: 128 random bits, base64. Matches CSP's
29
+ * `base64-value` grammar, so it is valid in the header verbatim and safe
30
+ * inside a double-quoted HTML attribute without escaping.
31
+ */
32
+ function generateNonce(): string {
33
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
34
+ let binary = '';
35
+ for (const b of bytes) binary += String.fromCharCode(b);
36
+ return btoa(binary);
37
+ }
38
+
39
+ /**
40
+ * The request's CSP nonce, creating it on first call. Backs the
41
+ * `ctx.nonce` getter on `MiddlewareContext`. Outside a request context
42
+ * (tests, build-time rendering) a fresh value is returned but not
43
+ * retained, since there is no request for it to belong to.
44
+ */
45
+ export function requestCspNonce(): string {
46
+ const store = requestContextAls.getStore();
47
+ if (!store) return generateNonce();
48
+ store.cspNonce ??= generateNonce();
49
+ return store.cspNonce;
50
+ }
51
+
52
+ /**
53
+ * The request's CSP nonce if middleware asked for one, else `undefined` —
54
+ * in which case the pipeline emits bare `<script>` tags.
55
+ */
56
+ export function getCspNonce(): string | undefined {
57
+ return requestContextAls.getStore()?.cspNonce;
58
+ }
59
+
60
+ /**
61
+ * The ` nonce="…"` attribute fragment for a timber-generated `<script>` tag,
62
+ * or `''` when the request has no nonce. Callers interpolate it directly
63
+ * after the tag name: `` `<script${nonceAttr(nonce)}>` ``.
64
+ *
65
+ * The value comes from `generateNonce` and is base64, so no attribute
66
+ * escaping is required.
67
+ */
68
+ export function nonceAttr(nonce: string | undefined): string {
69
+ return nonce ? ` nonce="${nonce}"` : '';
70
+ }
@@ -94,6 +94,7 @@ body {
94
94
  background: var(--bg); color: var(--fg);
95
95
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
96
96
  text-align: center; padding: 2rem;
97
+ box-sizing: border-box;
97
98
  }
98
99
  h1 { font-size: 3rem; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
99
100
  p { margin: 0.5rem 0 0; color: var(--muted); font-size: 1rem; }
@@ -37,6 +37,7 @@ import type { ManifestSegmentNode } from './route-matcher.ts';
37
37
  import type { RouteMatch } from './pipeline.ts';
38
38
  import type { NavContext } from './ssr-bridge-types.ts';
39
39
  import { flightInitScript } from './flight-scripts.ts';
40
+ import { getCspNonce } from './csp-nonce.ts';
40
41
  import type { ClientBootstrapConfig } from './html-injectors.ts';
41
42
  import type { Metadata } from './types.ts';
42
43
  import { teeWithErrorPropagation } from './stream-utils.ts';
@@ -226,15 +227,18 @@ export async function renderDenyPage(
226
227
 
227
228
  const slotParams = getSlotParamsForClient();
228
229
 
230
+ const denyUrl = new URL(req.url);
231
+ const nonce = getCspNonce();
229
232
  const navContext: NavContext = {
230
- pathname: new URL(req.url).pathname,
233
+ pathname: denyUrl.pathname,
234
+ search: appVisibleSearch(denyUrl).search,
231
235
  segmentParams: match.segmentParams,
232
- searchParams: Object.fromEntries(appVisibleSearch(new URL(req.url)).params),
233
236
  slotParams,
234
237
  statusCode: deny.status,
235
238
  responseHeaders,
236
- headHtml: flightInitScript(),
239
+ headHtml: flightInitScript(nonce),
237
240
  bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
241
+ nonce,
238
242
  rscStream: inlineStream,
239
243
  signal: req.signal,
240
244
  cookies: getCookiesForSsr(),
@@ -9,9 +9,14 @@
9
9
  * to execute before any streaming chunk scripts). Push scripts are emitted
10
10
  * as RSC chunks arrive during streaming.
11
11
  *
12
+ * Every generator takes the request's CSP nonce (from `getCspNonce()`)
13
+ * so the tags run under a nonce-based `script-src` (TIM-1414).
14
+ *
12
15
  * See design/02-rendering-pipeline.md, LOCAL-415
13
16
  */
14
17
 
18
+ import { nonceAttr } from './csp-nonce.ts';
19
+
15
20
  // ─── JSON Escaping ────────────────────────────────────────────────────────
16
21
 
17
22
  /**
@@ -43,8 +48,8 @@ const FLIGHT_VAR = 'self.__timber_f';
43
48
  * Also emits the bootstrap signal [0] which tells the client that
44
49
  * Flight data is active for this page.
45
50
  */
46
- export function flightInitScript(): string {
47
- return `<script>${FLIGHT_VAR}=[${htmlEscapeJsonString(JSON.stringify([0]))}]</script>`;
51
+ export function flightInitScript(nonce?: string): string {
52
+ return `<script${nonceAttr(nonce)}>${FLIGHT_VAR}=[${htmlEscapeJsonString(JSON.stringify([0]))}]</script>`;
48
53
  }
49
54
 
50
55
  /**
@@ -56,7 +61,7 @@ export function flightInitScript(): string {
56
61
  * never runs. The `||[]` guard ensures the array is created on first
57
62
  * push if needed, preventing a TypeError. See TIM-559.
58
63
  */
59
- export function flightChunkScript(data: string): string {
64
+ export function flightChunkScript(data: string, nonce?: string): string {
60
65
  const escaped = htmlEscapeJsonString(JSON.stringify([1, data]));
61
- return `<script>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
66
+ return `<script${nonceAttr(nonce)}>(${FLIGHT_VAR}=${FLIGHT_VAR}||[]).push(${escaped})</script>`;
62
67
  }
@@ -68,7 +68,11 @@ export class BufferAggregator {
68
68
  private chunks: Uint8Array[] = [];
69
69
  private byteLength = 0;
70
70
 
71
- constructor(private readonly maxBufferByteLength: number = Infinity) {}
71
+ private readonly maxBufferByteLength: number;
72
+
73
+ constructor(maxBufferByteLength: number = Infinity) {
74
+ this.maxBufferByteLength = maxBufferByteLength;
75
+ }
72
76
 
73
77
  /**
74
78
  * Append a chunk. Returns `true` if the caller should flush synchronously
@@ -350,6 +354,21 @@ export function injectorFlush(state: InjectorState): Uint8Array | null {
350
354
  * **No polling.** See design/02-rendering-pipeline.md §"No Polling".
351
355
  * **Always timeout-guarded reads.** Per design/02 §"Streaming Constraints".
352
356
  */
357
+ export interface FlightInjectorOptions {
358
+ /**
359
+ * Timeout in milliseconds for individual RSC stream reads. If a single
360
+ * `rscReader.read()` call does not resolve within this duration, the
361
+ * read is aborted and the stream errors with a `RenderTimeoutError`.
362
+ * Default: 30000 (30s). `0` ("disabled") still enforces a 120s ceiling
363
+ * so a stalled RSC stream can never hang the connection forever.
364
+ */
365
+ renderTimeoutMs?: number;
366
+ /** Signal that fires when an unhandled RSC error is detected. */
367
+ errorSignal?: AbortSignal;
368
+ /** Request CSP nonce, stamped on every emitted `<script>` (TIM-1414). */
369
+ nonce?: string;
370
+ }
371
+
353
372
  export class FlightInjectorCore {
354
373
  readonly machine: Machine<FlightInjectionState, FlightInjectionEvent>;
355
374
  /** Script chunks waiting to be drained between HTML chunks. */
@@ -358,18 +377,16 @@ export class FlightInjectorCore {
358
377
  private readonly rscReader: ReadableStreamDefaultReader<Uint8Array>;
359
378
  private readonly timeoutMs: number;
360
379
  private readonly errorSignal?: AbortSignal;
380
+ private readonly nonce?: string;
361
381
  private readonly decoder = new TextDecoder('utf-8', { fatal: true });
362
382
  private pullPromise: Promise<void> | null = null;
363
383
  private _aborted = false;
364
384
 
365
- constructor(
366
- rscStream: ReadableStream<Uint8Array>,
367
- renderTimeoutMs?: number,
368
- errorSignal?: AbortSignal
369
- ) {
385
+ constructor(rscStream: ReadableStream<Uint8Array>, options: FlightInjectorOptions = {}) {
370
386
  this.rscReader = rscStream.getReader();
371
- this.timeoutMs = (renderTimeoutMs ?? 30_000) || 120_000;
372
- this.errorSignal = errorSignal;
387
+ this.timeoutMs = (options.renderTimeoutMs ?? 30_000) || 120_000;
388
+ this.errorSignal = options.errorSignal;
389
+ this.nonce = options.nonce;
373
390
  this.machine = createMachine<FlightInjectionState, FlightInjectionEvent>({
374
391
  initial: { phase: 'init' },
375
392
  transitions: flightInjectionTransitions,
@@ -470,7 +487,7 @@ export class FlightInjectorCore {
470
487
  }
471
488
 
472
489
  const decoded = this.decoder.decode(value, { stream: true });
473
- this.pending.push(encoder.encode(flightChunkScript(decoded)));
490
+ this.pending.push(encoder.encode(flightChunkScript(decoded, this.nonce)));
474
491
 
475
492
  // Yield between reads so HTML chunks get priority — but only while
476
493
  // HTML is still streaming. Once flush() fires (HTML_DONE), drain
@@ -21,6 +21,7 @@
21
21
  import {
22
22
  BufferAggregator,
23
23
  FlightInjectorCore,
24
+ type FlightInjectorOptions,
24
25
  createInjectorState,
25
26
  createSuffixState,
26
27
  encodeUtf8,
@@ -201,7 +202,8 @@ export function injectScripts(
201
202
  */
202
203
  export function createInlinedRscStream(
203
204
  rscStream: ReadableStream<Uint8Array>,
204
- renderTimeoutMs?: number
205
+ renderTimeoutMs?: number,
206
+ nonce?: string
205
207
  ): ReadableStream<Uint8Array> {
206
208
  const rscReader = rscStream.getReader();
207
209
  const decoder = new TextDecoder('utf-8', { fatal: true });
@@ -224,7 +226,7 @@ export function createInlinedRscStream(
224
226
  }
225
227
  if (value) {
226
228
  const decoded = decoder.decode(value, { stream: true });
227
- controller.enqueue(encodeUtf8(flightChunkScript(decoded)));
229
+ controller.enqueue(encodeUtf8(flightChunkScript(decoded, nonce)));
228
230
  }
229
231
  } catch (error) {
230
232
  if (error instanceof RenderTimeoutError) {
@@ -257,10 +259,9 @@ export function createInlinedRscStream(
257
259
  */
258
260
  function createFlightInjectionTransform(
259
261
  rscStream: ReadableStream<Uint8Array>,
260
- renderTimeoutMs?: number,
261
- errorSignal?: AbortSignal
262
+ options?: FlightInjectorOptions
262
263
  ): TransformStream<Uint8Array, Uint8Array> {
263
- const core = new FlightInjectorCore(rscStream, renderTimeoutMs, errorSignal);
264
+ const core = new FlightInjectorCore(rscStream, options);
264
265
  // Controller reference — set on first transform() call so the pull
265
266
  // loop can drain pending scripts between HTML chunks (after yielding).
266
267
  let _controller: TransformStreamDefaultController<Uint8Array> | null = null;
@@ -340,8 +341,7 @@ function createFlightInjectionTransform(
340
341
  export function injectRscPayload(
341
342
  htmlStream: ReadableStream<Uint8Array>,
342
343
  rscStream: ReadableStream<Uint8Array> | undefined,
343
- renderTimeoutMs?: number,
344
- errorSignal?: AbortSignal
344
+ options?: FlightInjectorOptions
345
345
  ): ReadableStream<Uint8Array> {
346
346
  if (!rscStream) return htmlStream;
347
347
  // Pass the raw RSC stream directly to the flight injection transform.
@@ -349,7 +349,7 @@ export function injectRscPayload(
349
349
  // <script> via flightChunkScript itself — do NOT pre-wrap with
350
350
  // createInlinedRscStream here, that would double-wrap.
351
351
  return htmlStream
352
- .pipeThrough(createFlightInjectionTransform(rscStream, renderTimeoutMs, errorSignal))
352
+ .pipeThrough(createFlightInjectionTransform(rscStream, options))
353
353
  .pipeThrough(createMoveSuffixStream());
354
354
  }
355
355
 
@@ -78,14 +78,36 @@ export function renderMetadataToElements(metadata: Metadata): HeadElement[] {
78
78
  }
79
79
  }
80
80
 
81
- // Open Graph
81
+ // Open Graph — inherit title/description from top-level when not set explicitly
82
82
  if (metadata.openGraph) {
83
- renderOpenGraph(metadata.openGraph, elements);
83
+ const og = { ...metadata.openGraph };
84
+ if (og.title === undefined && typeof metadata.title === 'string') {
85
+ og.title = metadata.title;
86
+ }
87
+ if (og.description === undefined && metadata.description) {
88
+ og.description = metadata.description;
89
+ }
90
+ renderOpenGraph(og, elements);
84
91
  }
85
92
 
86
- // Twitter
93
+ // Twitter — inherit title/description from openGraph or top-level when not set
87
94
  if (metadata.twitter) {
88
- renderTwitter(metadata.twitter, elements);
95
+ const tw = { ...metadata.twitter };
96
+ if (tw.title === undefined) {
97
+ tw.title =
98
+ metadata.openGraph?.title !== undefined
99
+ ? metadata.openGraph.title
100
+ : typeof metadata.title === 'string'
101
+ ? metadata.title
102
+ : undefined;
103
+ }
104
+ if (tw.description === undefined) {
105
+ tw.description =
106
+ metadata.openGraph?.description !== undefined
107
+ ? metadata.openGraph.description
108
+ : metadata.description;
109
+ }
110
+ renderTwitter(tw, elements);
89
111
  }
90
112
 
91
113
  // Icons