@timber-js/app 0.2.0-alpha.185 → 0.2.0-alpha.186

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 (418) hide show
  1. package/dist/_chunks/{actions-O_LsyCE4.js → actions-C-Rw9vPc.js} +3 -3
  2. package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-C-Rw9vPc.js.map} +1 -1
  3. package/dist/_chunks/als-slots-mFweg276.js +27 -0
  4. package/dist/_chunks/als-slots-mFweg276.js.map +1 -0
  5. package/dist/_chunks/base-path-DaQrzbez.js +80 -0
  6. package/dist/_chunks/base-path-DaQrzbez.js.map +1 -0
  7. package/dist/_chunks/build-manifest-DWppEdLB.js +86 -0
  8. package/dist/_chunks/build-manifest-DWppEdLB.js.map +1 -0
  9. package/dist/_chunks/build-output-helper-C3DrfzZR.js +369 -0
  10. package/dist/_chunks/build-output-helper-C3DrfzZR.js.map +1 -0
  11. package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-Cd0VZ_Pd.js} +3 -3
  12. package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-Cd0VZ_Pd.js.map} +1 -1
  13. package/dist/_chunks/cli-schema-sync-CKgHC2MB.js +1857 -0
  14. package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +1 -0
  15. package/dist/_chunks/{cloudflare-CnT5Lr7U.js → cloudflare-Cs0uZXea.js} +89 -12
  16. package/dist/_chunks/{cloudflare-CnT5Lr7U.js.map → cloudflare-Cs0uZXea.js.map} +1 -1
  17. package/dist/_chunks/error-boundary-DpYRI_I1.js +330 -0
  18. package/dist/_chunks/error-boundary-DpYRI_I1.js.map +1 -0
  19. package/dist/_chunks/{fast-hash-D6hIVt1Y.js → fast-hash-C-tVYrLb.js} +6 -1
  20. package/dist/_chunks/fast-hash-C-tVYrLb.js.map +1 -0
  21. package/dist/_chunks/fs-identity-D7vb6GdI.js +208 -0
  22. package/dist/_chunks/fs-identity-D7vb6GdI.js.map +1 -0
  23. package/dist/_chunks/{logger-AWfuX-KJ.js → logger-N7e5auP0.js} +9 -13
  24. package/dist/_chunks/logger-N7e5auP0.js.map +1 -0
  25. package/dist/_chunks/navigation-root-B29qg0_T.js +274 -0
  26. package/dist/_chunks/navigation-root-B29qg0_T.js.map +1 -0
  27. package/dist/_chunks/param-value-C8TNYchQ.js +66 -0
  28. package/dist/_chunks/param-value-C8TNYchQ.js.map +1 -0
  29. package/dist/_chunks/{plugin-context---kTF5v8.js → plugin-context-DEGLSJs3.js} +2 -1
  30. package/dist/_chunks/plugin-context-DEGLSJs3.js.map +1 -0
  31. package/dist/_chunks/{router-ref-CtmF-aPv.js → router-ref-DuYuV_0Q.js} +2 -2
  32. package/dist/_chunks/router-ref-DuYuV_0Q.js.map +1 -0
  33. package/dist/_chunks/rsc-cache-key-DD0fl_-s.js +243 -0
  34. package/dist/_chunks/rsc-cache-key-DD0fl_-s.js.map +1 -0
  35. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +40 -0
  36. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +1 -0
  37. package/dist/_chunks/rsc-payload-path-B_LBodc2.js +48 -0
  38. package/dist/_chunks/rsc-payload-path-B_LBodc2.js.map +1 -0
  39. package/dist/_chunks/{segment-classify-Byy425ng.js → segment-classify-C539Pa2O.js} +23 -2
  40. package/dist/_chunks/{segment-classify-Byy425ng.js.map → segment-classify-C539Pa2O.js.map} +1 -1
  41. package/dist/_chunks/{canonicalize-DQHyFClh.js → segment-keys-BawYuNFO.js} +61 -2
  42. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -0
  43. package/dist/_chunks/slot-params-BCTmZkQB.js +76 -0
  44. package/dist/_chunks/slot-params-BCTmZkQB.js.map +1 -0
  45. package/dist/_chunks/{ssr-data-BOWsq18U.js → ssr-data-14MXm7Pj.js} +17 -2
  46. package/dist/_chunks/ssr-data-14MXm7Pj.js.map +1 -0
  47. package/dist/_chunks/use-segment-params-ClyUNq4d.js +128 -0
  48. package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +1 -0
  49. package/dist/_chunks/{walkers-_6zKFlch.js → walkers-BhhwI9TD.js} +21 -87
  50. package/dist/_chunks/walkers-BhhwI9TD.js.map +1 -0
  51. package/dist/adapters/build-output-helper.d.ts +37 -0
  52. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  53. package/dist/adapters/cloudflare-dev.js +1 -1
  54. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  55. package/dist/adapters/cloudflare.d.ts.map +1 -1
  56. package/dist/adapters/cloudflare.js +1 -1
  57. package/dist/adapters/fs-identity.d.ts +64 -0
  58. package/dist/adapters/fs-identity.d.ts.map +1 -0
  59. package/dist/adapters/nitro.d.ts.map +1 -1
  60. package/dist/adapters/nitro.js +2 -3
  61. package/dist/adapters/nitro.js.map +1 -1
  62. package/dist/adapters/shared.d.ts +57 -1
  63. package/dist/adapters/shared.d.ts.map +1 -1
  64. package/dist/adapters/types.d.ts +13 -1
  65. package/dist/adapters/types.d.ts.map +1 -1
  66. package/dist/cache/fast-hash.d.ts +5 -0
  67. package/dist/cache/fast-hash.d.ts.map +1 -1
  68. package/dist/cache/index.js +1 -1
  69. package/dist/cli.js +2 -2
  70. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  71. package/dist/client/browser-entry/hydrate.d.ts +20 -9
  72. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  73. package/dist/client/browser-entry/post-hydration.d.ts +4 -1
  74. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  75. package/dist/client/browser-entry/router-init.d.ts +3 -0
  76. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  77. package/dist/client/browser-entry/rsc-stream.d.ts +8 -0
  78. package/dist/client/browser-entry/rsc-stream.d.ts.map +1 -1
  79. package/dist/client/error-boundary.d.ts +1 -0
  80. package/dist/client/error-boundary.d.ts.map +1 -1
  81. package/dist/client/error-boundary.js +1 -162
  82. package/dist/client/history.d.ts +13 -6
  83. package/dist/client/history.d.ts.map +1 -1
  84. package/dist/client/index.js +18 -11
  85. package/dist/client/index.js.map +1 -1
  86. package/dist/client/internal.d.ts +4 -3
  87. package/dist/client/internal.d.ts.map +1 -1
  88. package/dist/client/internal.js +865 -306
  89. package/dist/client/internal.js.map +1 -1
  90. package/dist/client/link.d.ts.map +1 -1
  91. package/dist/client/navigation-api.d.ts +2 -2
  92. package/dist/client/navigation-commit.d.ts +71 -0
  93. package/dist/client/navigation-commit.d.ts.map +1 -0
  94. package/dist/client/navigation-context.d.ts +37 -20
  95. package/dist/client/navigation-context.d.ts.map +1 -1
  96. package/dist/client/navigation-root.d.ts +63 -23
  97. package/dist/client/navigation-root.d.ts.map +1 -1
  98. package/dist/client/params-context.d.ts +81 -0
  99. package/dist/client/params-context.d.ts.map +1 -0
  100. package/dist/client/router-effects.d.ts +129 -0
  101. package/dist/client/router-effects.d.ts.map +1 -0
  102. package/dist/client/router-lifecycle.d.ts +55 -0
  103. package/dist/client/router-lifecycle.d.ts.map +1 -0
  104. package/dist/client/router-pipeline.d.ts +84 -0
  105. package/dist/client/router-pipeline.d.ts.map +1 -0
  106. package/dist/client/router-ref.d.ts +1 -1
  107. package/dist/client/router-ref.d.ts.map +1 -1
  108. package/dist/client/router-skew.d.ts +21 -0
  109. package/dist/client/router-skew.d.ts.map +1 -0
  110. package/dist/client/router-types.d.ts +207 -0
  111. package/dist/client/router-types.d.ts.map +1 -0
  112. package/dist/client/router.d.ts +1 -182
  113. package/dist/client/router.d.ts.map +1 -1
  114. package/dist/client/rsc-fetch.d.ts +38 -25
  115. package/dist/client/rsc-fetch.d.ts.map +1 -1
  116. package/dist/client/segment-cache.d.ts +79 -34
  117. package/dist/client/segment-cache.d.ts.map +1 -1
  118. package/dist/client/ssr-data.d.ts +6 -0
  119. package/dist/client/ssr-data.d.ts.map +1 -1
  120. package/dist/client/stale-client.d.ts +69 -0
  121. package/dist/client/stale-client.d.ts.map +1 -0
  122. package/dist/client/state.d.ts +10 -1
  123. package/dist/client/state.d.ts.map +1 -1
  124. package/dist/client/top-loader.d.ts +2 -1
  125. package/dist/client/top-loader.d.ts.map +1 -1
  126. package/dist/client/unload-guard.d.ts +5 -0
  127. package/dist/client/unload-guard.d.ts.map +1 -1
  128. package/dist/client/use-cookie.d.ts.map +1 -1
  129. package/dist/client/use-router.d.ts +1 -1
  130. package/dist/client/use-segment-params.d.ts +18 -8
  131. package/dist/client/use-segment-params.d.ts.map +1 -1
  132. package/dist/cookies/index.js +3 -3
  133. package/dist/cookies/index.js.map +1 -1
  134. package/dist/index.d.ts.map +1 -1
  135. package/dist/index.js +906 -321
  136. package/dist/index.js.map +1 -1
  137. package/dist/plugin-context.d.ts +31 -0
  138. package/dist/plugin-context.d.ts.map +1 -1
  139. package/dist/plugins/adapter-build.d.ts.map +1 -1
  140. package/dist/plugins/build-manifest.d.ts +1 -1
  141. package/dist/plugins/build-manifest.d.ts.map +1 -1
  142. package/dist/plugins/entries.d.ts.map +1 -1
  143. package/dist/plugins/fonts.d.ts.map +1 -1
  144. package/dist/plugins/request-dep/analysis.d.ts +106 -0
  145. package/dist/plugins/request-dep/analysis.d.ts.map +1 -0
  146. package/dist/plugins/request-dep/index.d.ts +75 -0
  147. package/dist/plugins/request-dep/index.d.ts.map +1 -0
  148. package/dist/plugins/request-dep/origin.d.ts +61 -0
  149. package/dist/plugins/request-dep/origin.d.ts.map +1 -0
  150. package/dist/plugins/routing.d.ts +9 -0
  151. package/dist/plugins/routing.d.ts.map +1 -1
  152. package/dist/plugins/static-build.d.ts +136 -8
  153. package/dist/plugins/static-build.d.ts.map +1 -1
  154. package/dist/routing/codegen-types.d.ts +18 -0
  155. package/dist/routing/codegen-types.d.ts.map +1 -1
  156. package/dist/routing/codegen.d.ts.map +1 -1
  157. package/dist/routing/collision-probe.d.ts +104 -0
  158. package/dist/routing/collision-probe.d.ts.map +1 -0
  159. package/dist/routing/collision-spaces.d.ts +26 -0
  160. package/dist/routing/collision-spaces.d.ts.map +1 -0
  161. package/dist/routing/index.js +3 -3
  162. package/dist/routing/interception.d.ts +235 -6
  163. package/dist/routing/interception.d.ts.map +1 -1
  164. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  165. package/dist/routing/scanner.d.ts.map +1 -1
  166. package/dist/routing/segment-classify.d.ts +33 -0
  167. package/dist/routing/segment-classify.d.ts.map +1 -1
  168. package/dist/routing/segment-keys.d.ts +95 -0
  169. package/dist/routing/segment-keys.d.ts.map +1 -0
  170. package/dist/routing/slot-placement.d.ts +48 -0
  171. package/dist/routing/slot-placement.d.ts.map +1 -0
  172. package/dist/routing/walkers.d.ts +6 -0
  173. package/dist/routing/walkers.d.ts.map +1 -1
  174. package/dist/search-params/define.d.ts +0 -6
  175. package/dist/search-params/define.d.ts.map +1 -1
  176. package/dist/search-params/index.js +176 -2
  177. package/dist/search-params/index.js.map +1 -1
  178. package/dist/segment-params/define.d.ts +0 -6
  179. package/dist/segment-params/define.d.ts.map +1 -1
  180. package/dist/segment-params/index.js +69 -1
  181. package/dist/segment-params/index.js.map +1 -0
  182. package/dist/server/actions.d.ts +6 -3
  183. package/dist/server/actions.d.ts.map +1 -1
  184. package/dist/server/als-registry.d.ts +2 -1
  185. package/dist/server/als-registry.d.ts.map +1 -1
  186. package/dist/server/build-manifest.d.ts +13 -1
  187. package/dist/server/build-manifest.d.ts.map +1 -1
  188. package/dist/server/chain-url-parts.d.ts +82 -0
  189. package/dist/server/chain-url-parts.d.ts.map +1 -0
  190. package/dist/server/children-interception.d.ts +49 -0
  191. package/dist/server/children-interception.d.ts.map +1 -0
  192. package/dist/server/default-status-page.d.ts +45 -0
  193. package/dist/server/default-status-page.d.ts.map +1 -0
  194. package/dist/server/deny-renderer.d.ts +14 -3
  195. package/dist/server/deny-renderer.d.ts.map +1 -1
  196. package/dist/server/head-response.d.ts +35 -0
  197. package/dist/server/head-response.d.ts.map +1 -0
  198. package/dist/server/index.js +2 -2
  199. package/dist/server/internal.js +393 -212
  200. package/dist/server/internal.js.map +1 -1
  201. package/dist/server/param-coercion.d.ts +10 -1
  202. package/dist/server/param-coercion.d.ts.map +1 -1
  203. package/dist/server/pipeline-helpers.d.ts +0 -18
  204. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  205. package/dist/server/pipeline-interception.d.ts +59 -9
  206. package/dist/server/pipeline-interception.d.ts.map +1 -1
  207. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  208. package/dist/server/pipeline-phases.d.ts.map +1 -1
  209. package/dist/server/pipeline.d.ts +11 -0
  210. package/dist/server/pipeline.d.ts.map +1 -1
  211. package/dist/server/publish-params.d.ts +33 -0
  212. package/dist/server/publish-params.d.ts.map +1 -0
  213. package/dist/server/request-context.d.ts +48 -0
  214. package/dist/server/request-context.d.ts.map +1 -1
  215. package/dist/server/route-element-builder.d.ts +32 -2
  216. package/dist/server/route-element-builder.d.ts.map +1 -1
  217. package/dist/server/route-handler.d.ts.map +1 -1
  218. package/dist/server/route-matcher.d.ts +13 -0
  219. package/dist/server/route-matcher.d.ts.map +1 -1
  220. package/dist/server/rsc-cache-key-guard.d.ts +78 -0
  221. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -0
  222. package/dist/server/rsc-entry/api-handler.d.ts.map +1 -1
  223. package/dist/server/rsc-entry/error-renderer.d.ts +14 -2
  224. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  225. package/dist/server/rsc-entry/helpers.d.ts +2 -10
  226. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  227. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  228. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  229. package/dist/server/rsc-entry/rsc-payload.d.ts +3 -2
  230. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  231. package/dist/server/rsc-entry/rsc-stream.d.ts +3 -2
  232. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  233. package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
  234. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  235. package/dist/server/skippable-prefix.d.ts +23 -0
  236. package/dist/server/skippable-prefix.d.ts.map +1 -0
  237. package/dist/server/slot-interception.d.ts +80 -0
  238. package/dist/server/slot-interception.d.ts.map +1 -0
  239. package/dist/server/slot-resolver.d.ts +70 -8
  240. package/dist/server/slot-resolver.d.ts.map +1 -1
  241. package/dist/server/ssr-bridge-types.d.ts +6 -0
  242. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  243. package/dist/server/ssr-entry.d.ts.map +1 -1
  244. package/dist/server/ssr-wrappers.d.ts +24 -13
  245. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  246. package/dist/server/state-tree-diff.d.ts +6 -27
  247. package/dist/server/state-tree-diff.d.ts.map +1 -1
  248. package/dist/server/static-generator.d.ts +6 -0
  249. package/dist/server/static-generator.d.ts.map +1 -1
  250. package/dist/server/static-not-found.d.ts +52 -0
  251. package/dist/server/static-not-found.d.ts.map +1 -0
  252. package/dist/shared/als-slots.d.ts +41 -0
  253. package/dist/shared/als-slots.d.ts.map +1 -0
  254. package/dist/shared/base-path.d.ts +56 -0
  255. package/dist/shared/base-path.d.ts.map +1 -0
  256. package/dist/shared/param-value.d.ts +80 -0
  257. package/dist/shared/param-value.d.ts.map +1 -0
  258. package/dist/shared/payload-root.d.ts +108 -0
  259. package/dist/shared/payload-root.d.ts.map +1 -0
  260. package/dist/shared/rsc-cache-key.d.ts +163 -0
  261. package/dist/shared/rsc-cache-key.d.ts.map +1 -0
  262. package/dist/shared/rsc-error-envelope.d.ts +25 -0
  263. package/dist/shared/rsc-error-envelope.d.ts.map +1 -0
  264. package/dist/shared/rsc-manifest.d.ts +28 -0
  265. package/dist/shared/rsc-manifest.d.ts.map +1 -0
  266. package/dist/shared/rsc-payload-path.d.ts +42 -0
  267. package/dist/shared/rsc-payload-path.d.ts.map +1 -0
  268. package/dist/shared/segment-info.d.ts +59 -0
  269. package/dist/shared/segment-info.d.ts.map +1 -0
  270. package/dist/shared/slot-params.d.ts +51 -0
  271. package/dist/shared/slot-params.d.ts.map +1 -0
  272. package/dist/shared/static-platform-files.d.ts +71 -0
  273. package/dist/shared/static-platform-files.d.ts.map +1 -0
  274. package/docs/api/31-api-client.mdx +4 -2
  275. package/docs/learn/02-pages-and-layouts.mdx +1 -1
  276. package/docs/learn/11-error-handling.mdx +2 -0
  277. package/docs/learn/12-client-navigation.mdx +2 -2
  278. package/docs/learn/14-deploying.mdx +23 -0
  279. package/docs/more/01-advanced-routing.mdx +172 -2
  280. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  281. package/package.json +2 -2
  282. package/src/adapters/build-output-helper.ts +116 -3
  283. package/src/adapters/cloudflare.ts +86 -11
  284. package/src/adapters/fs-identity.ts +156 -0
  285. package/src/adapters/nitro.ts +6 -6
  286. package/src/adapters/shared.ts +196 -21
  287. package/src/adapters/types.ts +13 -1
  288. package/src/cache/fast-hash.ts +5 -0
  289. package/src/client/browser-entry/action-dispatch.ts +9 -4
  290. package/src/client/browser-entry/hydrate.ts +40 -29
  291. package/src/client/browser-entry/index.ts +15 -1
  292. package/src/client/browser-entry/post-hydration.ts +10 -9
  293. package/src/client/browser-entry/router-init.ts +104 -48
  294. package/src/client/browser-entry/rsc-stream.ts +11 -2
  295. package/src/client/error-boundary.tsx +20 -15
  296. package/src/client/history.ts +13 -6
  297. package/src/client/internal.ts +4 -3
  298. package/src/client/link.tsx +55 -8
  299. package/src/client/navigation-api.ts +3 -3
  300. package/src/client/navigation-commit.ts +174 -0
  301. package/src/client/navigation-context.ts +40 -67
  302. package/src/client/navigation-root.tsx +326 -101
  303. package/src/client/params-context.ts +192 -0
  304. package/src/client/router-effects.ts +231 -0
  305. package/src/client/router-lifecycle.ts +288 -0
  306. package/src/client/router-pipeline.ts +389 -0
  307. package/src/client/router-ref.ts +1 -1
  308. package/src/client/router-skew.ts +30 -0
  309. package/src/client/router-types.ts +230 -0
  310. package/src/client/router.ts +242 -707
  311. package/src/client/rsc-fetch.ts +169 -102
  312. package/src/client/segment-cache.ts +121 -44
  313. package/src/client/ssr-data.ts +6 -0
  314. package/src/client/stale-client.ts +191 -0
  315. package/src/client/state.ts +14 -1
  316. package/src/client/top-loader.tsx +2 -1
  317. package/src/client/unload-guard.ts +34 -2
  318. package/src/client/use-cookie.ts +9 -2
  319. package/src/client/use-router.ts +1 -1
  320. package/src/client/use-segment-params.ts +41 -18
  321. package/src/index.ts +5 -1
  322. package/src/plugin-context.ts +32 -0
  323. package/src/plugins/adapter-build.ts +4 -0
  324. package/src/plugins/build-manifest.ts +34 -13
  325. package/src/plugins/entries.ts +4 -0
  326. package/src/plugins/fonts.ts +11 -8
  327. package/src/plugins/request-dep/analysis.ts +447 -0
  328. package/src/plugins/request-dep/index.ts +376 -0
  329. package/src/plugins/request-dep/origin.ts +310 -0
  330. package/src/plugins/routing.ts +59 -2
  331. package/src/plugins/static-build.ts +315 -73
  332. package/src/routing/codegen-types.ts +18 -0
  333. package/src/routing/codegen.ts +50 -10
  334. package/src/routing/collision-probe.ts +438 -0
  335. package/src/routing/collision-spaces.ts +281 -0
  336. package/src/routing/interception.ts +896 -50
  337. package/src/routing/manifest-codegen.ts +5 -3
  338. package/src/routing/scanner.ts +114 -89
  339. package/src/routing/segment-classify.ts +57 -0
  340. package/src/routing/segment-keys.ts +146 -0
  341. package/src/routing/slot-placement.ts +97 -0
  342. package/src/routing/walkers.ts +8 -0
  343. package/src/search-params/define.ts +6 -29
  344. package/src/segment-params/define.ts +4 -27
  345. package/src/server/action-handler.ts +3 -1
  346. package/src/server/actions.ts +6 -3
  347. package/src/server/als-registry.ts +2 -1
  348. package/src/server/build-manifest.ts +14 -1
  349. package/src/server/chain-url-parts.ts +163 -0
  350. package/src/server/children-interception.ts +115 -0
  351. package/src/server/default-status-page.ts +127 -0
  352. package/src/server/deny-renderer.ts +81 -20
  353. package/src/server/head-response.ts +54 -0
  354. package/src/server/param-coercion.ts +48 -18
  355. package/src/server/pipeline-helpers.ts +0 -45
  356. package/src/server/pipeline-interception.ts +81 -32
  357. package/src/server/pipeline-outcome.ts +2 -4
  358. package/src/server/pipeline-phases.ts +68 -16
  359. package/src/server/pipeline.ts +57 -10
  360. package/src/server/prebuilt-runtime.ts +4 -4
  361. package/src/server/publish-params.ts +42 -0
  362. package/src/server/request-context.ts +97 -18
  363. package/src/server/route-element-builder.ts +89 -98
  364. package/src/server/route-handler.ts +8 -8
  365. package/src/server/route-matcher.ts +21 -9
  366. package/src/server/rsc-cache-key-guard.ts +170 -0
  367. package/src/server/rsc-entry/api-handler.ts +6 -8
  368. package/src/server/rsc-entry/error-renderer.ts +64 -25
  369. package/src/server/rsc-entry/helpers.ts +31 -16
  370. package/src/server/rsc-entry/index.ts +36 -28
  371. package/src/server/rsc-entry/render-route.ts +9 -4
  372. package/src/server/rsc-entry/revalidate-renderer.ts +1 -1
  373. package/src/server/rsc-entry/rsc-payload.ts +14 -25
  374. package/src/server/rsc-entry/rsc-stream.ts +4 -3
  375. package/src/server/rsc-entry/ssr-renderer.ts +13 -9
  376. package/src/server/skippable-prefix.ts +92 -0
  377. package/src/server/slot-interception.ts +168 -0
  378. package/src/server/slot-resolver.ts +253 -156
  379. package/src/server/ssr-bridge-types.ts +6 -0
  380. package/src/server/ssr-entry.ts +12 -4
  381. package/src/server/ssr-wrappers.tsx +33 -32
  382. package/src/server/state-tree-diff.ts +14 -55
  383. package/src/server/static-generator.ts +65 -27
  384. package/src/server/static-not-found.ts +134 -0
  385. package/src/shared/als-slots.ts +72 -0
  386. package/src/shared/base-path.ts +80 -0
  387. package/src/shared/param-value.ts +244 -0
  388. package/src/shared/payload-root.ts +151 -0
  389. package/src/shared/rsc-cache-key.ts +269 -0
  390. package/src/shared/rsc-error-envelope.ts +31 -0
  391. package/src/shared/rsc-manifest.ts +29 -0
  392. package/src/shared/rsc-payload-path.ts +45 -0
  393. package/src/shared/segment-info.ts +60 -0
  394. package/src/shared/slot-params.ts +111 -0
  395. package/src/shared/static-platform-files.ts +75 -0
  396. package/dist/_chunks/build-output-helper-DOGYFb_X.js +0 -196
  397. package/dist/_chunks/build-output-helper-DOGYFb_X.js.map +0 -1
  398. package/dist/_chunks/canonicalize-DQHyFClh.js.map +0 -1
  399. package/dist/_chunks/cli-schema-sync-BTWEKJXo.js +0 -668
  400. package/dist/_chunks/cli-schema-sync-BTWEKJXo.js.map +0 -1
  401. package/dist/_chunks/define-COtkxMRT.js +0 -191
  402. package/dist/_chunks/define-COtkxMRT.js.map +0 -1
  403. package/dist/_chunks/define-c4au4I9R.js +0 -82
  404. package/dist/_chunks/define-c4au4I9R.js.map +0 -1
  405. package/dist/_chunks/fast-hash-D6hIVt1Y.js.map +0 -1
  406. package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
  407. package/dist/_chunks/navigation-root-bUDZLw74.js +0 -124
  408. package/dist/_chunks/navigation-root-bUDZLw74.js.map +0 -1
  409. package/dist/_chunks/plugin-context---kTF5v8.js.map +0 -1
  410. package/dist/_chunks/router-ref-CtmF-aPv.js.map +0 -1
  411. package/dist/_chunks/ssr-data-BOWsq18U.js.map +0 -1
  412. package/dist/_chunks/use-segment-params-D1CzlgBo.js +0 -151
  413. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +0 -1
  414. package/dist/_chunks/walkers-_6zKFlch.js.map +0 -1
  415. package/dist/client/error-boundary.js.map +0 -1
  416. package/dist/plugins/request-dep.d.ts +0 -32
  417. package/dist/plugins/request-dep.d.ts.map +0 -1
  418. package/src/plugins/request-dep.ts +0 -499
@@ -13,8 +13,13 @@ import {
13
13
  runSharedBuildSteps,
14
14
  findUnhashedStaticFiles,
15
15
  buildStaticDirectoryOutput,
16
+ writeHeadersFile,
16
17
  } from './build-output-helper.js';
17
- import { generateHeadersFile } from './shared.js';
18
+ import { adapterBase, generateHeadersFile } from './shared.js';
19
+ // Isomorphic, zero-import module — safe to load from an adapter, which Node's
20
+ // native ESM loader evaluates before Vite's resolver exists. Same precedent as
21
+ // `adapters/shared.ts` importing `../shared/base-path.js`.
22
+ import { RSC_KEY_HEADERS } from '../shared/rsc-cache-key.js';
18
23
 
19
24
  // ─── Bindings passthrough ─────────────────────────────────────────────────
20
25
  // ALS stores the env object per-request so server components and middleware
@@ -409,14 +414,78 @@ function initPlatformTracer() {
409
414
  return tracerInit
410
415
  }
411
416
 
417
+ // The \`Vary\` tokens this cache can serve a correct answer for.
418
+ //
419
+ // Cloudflare's Cache API keys on the request URL. It does not implement
420
+ // RFC 9111 §4.1 secondary key selection, so a stored response is handed to
421
+ // every request for that URL regardless of what \`Vary\` nominates — except
422
+ // \`Accept-Encoding\`, which the platform segregates itself. Storing a
423
+ // response whose \`Vary\` says "this answer is specific to header H" under a
424
+ // key that ignores H is cross-client reuse (TIM-1269): a developer writing
425
+ // the correct \`public, s-maxage=60\` + \`Vary: Cookie\` gets their logged-in
426
+ // page served to anonymous visitors. Refusing to store is the conservative
427
+ // answer, and the only one available without rewriting the key.
428
+ //
429
+ // The framework's own tokens are on this list because the adapter already
430
+ // segregates them, more strictly than \`Vary\` would:
431
+ //
432
+ // - \`Accept\` — \`isRsc\` above excludes \`text/x-component\` from both lookup
433
+ // and store, so the HTML/Flight split \`Vary: Accept\` exists to protect
434
+ // never reaches this cache in the first place.
435
+ // - \`X-Timber-State-Tree\`, \`X-Timber-URL\`, \`X-Timber-Deployment-Id\` — the
436
+ // origin reads all three only on RSC requests (version skew at
437
+ // \`server/pipeline-phases.ts\`, interception rewrites likewise, state-tree
438
+ // diffing at \`server/rsc-entry/render-route.ts\`). On the non-RSC requests
439
+ // this cache stores, they cannot change the response.
440
+ //
441
+ // That second bullet is a coupling, not an observation: if any of those
442
+ // headers ever starts varying a document response, this list is wrong and
443
+ // the cache poisons. Because the list is derived, a header added to
444
+ // \`RSC_KEY_HEADERS\` inherits the exemption silently — so
445
+ // \`tests/adapters/cloudflare-vary.test.ts\` fails until that header is named
446
+ // there alongside the evidence for its gate, and the gates themselves are
447
+ // covered there, in \`tests/render-route.test.ts\`, and in
448
+ // \`tests/interception-x-timber-url.test.ts\`.
449
+ //
450
+ // Interpolated from \`RSC_KEY_HEADERS\` rather than spelled again — "which
451
+ // headers vary an RSC response" has one answer, and a hand-copied second
452
+ // list here would drift silently on the fallback path nobody exercises.
453
+ //
454
+ // Known residual: a route handler that content-negotiates on \`Accept\`
455
+ // (JSON vs HTML) *and* marks itself \`public\` is still mis-keyed, because the
456
+ // \`isRsc\` split is binary and does not separate those two. That is no worse
457
+ // than the pre-TIM-1269 behaviour, which stored every \`Vary\` but \`*\`.
458
+ const VARY_HONORED = new Set(${JSON.stringify(['accept-encoding', ...RSC_KEY_HEADERS.map((h) => h.toLowerCase())])})
459
+
460
+ // Applied to BOTH sides of the cache — what may be stored, and what a hit
461
+ // may serve. Refusing to store only stops new poisoning: entries a previous
462
+ // deployment wrote under the old predicate stay servable for whatever
463
+ // s-maxage the developer chose, which can be a day. Checking on the way out
464
+ // makes the fix take effect on deploy rather than on expiry, and costs one
465
+ // header read per hit. A rejected entry falls through to the origin and is
466
+ // then declined by the same predicate on the way back in, so it is not
467
+ // rewritten — it simply ages out.
468
+ function canHonorVary(vary) {
469
+ // Absent or empty Vary nominates nothing — the URL is the whole key.
470
+ if (!vary) return true
471
+ return vary.split(',').every((token) => VARY_HONORED.has(token.trim().toLowerCase()))
472
+ }
473
+
412
474
  export default {
413
475
  ${handlersSpread} async fetch(request, env, ctx) {
414
476
  // One-time on first request; resolved-promise await thereafter.
415
477
  await initPlatformTracer()
416
478
 
417
479
  // Edge-cache GET/HEAD responses via the Cache API. RSC navigation
418
- // requests (Accept: text/x-component) are excluded — they vary by
419
- // X-Timber-State-Tree which the cache key doesn't capture.
480
+ // requests (Accept: text/x-component) are excluded. Their varying
481
+ // inputs are now in the URL via ?_rsc= (TIM-1268), but this cache keys
482
+ // on the request URL alone and cannot honour Vary, so a payload request
483
+ // that arrives without the param — stripped query, hand-rolled client —
484
+ // would share a key with the document.
485
+ //
486
+ // This exclusion is load-bearing in the other direction too: the store
487
+ // predicate below treats "Vary: Accept" as honoured *because* of it
488
+ // (see VARY_HONORED). Removing it would make that allowlist unsound.
420
489
  const isRsc = request.headers.get('Accept')?.includes('text/x-component')
421
490
  const reqCc = request.headers.get('Cache-Control') || ''
422
491
  const bypassCache = reqCc.includes('no-cache') || reqCc.includes('max-age=0')
@@ -426,7 +495,17 @@ ${handlersSpread} async fetch(request, env, ctx) {
426
495
  const cache = cacheable && typeof caches !== 'undefined' ? caches.default : null
427
496
  if (cache) {
428
497
  const cached = await cache.match(request)
429
- if (cached) return cached
498
+ // A hit whose Vary this cache cannot key on is treated as a miss —
499
+ // see canHonorVary. Entries written before that predicate existed are
500
+ // exactly the ones that would be served to the wrong client.
501
+ if (cached) {
502
+ if (canHonorVary(cached.headers.get('Vary'))) return cached
503
+ // Declining the hit means nobody will ever read this body. Release
504
+ // it rather than leaving the stream dangling for the rest of the
505
+ // request (design/02-rendering-pipeline.md §"Cancel Readers When You
506
+ // No Longer Need the Stream").
507
+ cached.body?.cancel().catch(() => {})
508
+ }
430
509
  }
431
510
 
432
511
  // Bridge ctx.cache into the per-request ALS so WorkersCachePurge can
@@ -449,10 +528,10 @@ ${handlersSpread} async fetch(request, env, ctx) {
449
528
  // See design/25-production-deployments.md §"CDN / Edge Cache".
450
529
  if (cache && request.method === 'GET' && response.status === 200) {
451
530
  const cc = response.headers.get('Cache-Control')
452
- const vary = response.headers.get('Vary')
453
531
  if (cc && !cc.includes('no-store') && !cc.includes('private')
454
532
  && (cc.includes('public') || cc.includes('s-maxage'))
455
- && vary !== '*' && !response.headers.has('Set-Cookie')) {
533
+ && canHonorVary(response.headers.get('Vary'))
534
+ && !response.headers.has('Set-Cookie')) {
456
535
  ctx.waitUntil(cache.put(request, response.clone()))
457
536
  }
458
537
  }
@@ -804,11 +883,7 @@ export async function buildStaticOutput(
804
883
  await buildStaticDirectoryOutput(config, buildDir, outDir, '.');
805
884
 
806
885
  // Write _headers file for cache control + content-type rules.
807
- const unhashedPaths = await findUnhashedStaticFiles(outDir);
808
- await writeFile(
809
- join(outDir, '_headers'),
810
- generateHeadersFile(unhashedPaths, config.staticContentTypes)
811
- );
886
+ await writeHeadersFile(outDir, config, await findUnhashedStaticFiles(outDir));
812
887
  }
813
888
 
814
889
  // ─── Preview ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,156 @@
1
+ /**
2
+ * What the output filesystem considers "the same file".
3
+ *
4
+ * Two build steps compare paths to decide whether two artifacts would land on
5
+ * one file — the render pass (`plugins/static-build.ts`) and the adapter's
6
+ * tree merge (`build-output-helper.ts`). Both are predicting a filesystem
7
+ * decision, so both ask the filesystem rather than comparing raw strings
8
+ * (TIM-1266).
9
+ *
10
+ * Two properties matter, and they are independent:
11
+ *
12
+ * - **Case folding.** macOS APFS and Windows NTFS fold by default, ext4 does
13
+ * not. Dynamic params `Foo` and `foo` are one file on a Mac and two on
14
+ * Linux CI.
15
+ * - **Unicode normalization.** APFS compares normalization-insensitively, so
16
+ * `café` spelled NFC (`é`) and NFD (`e` + combining acute) address one
17
+ * file. This holds even on a *case-sensitive* APFS volume, which is why it
18
+ * is probed separately rather than inferred from case folding.
19
+ *
20
+ * Probed rather than read off `process.platform`: a case-sensitive volume on
21
+ * macOS and a case-insensitive mount on Linux are both ordinary, and the
22
+ * answer that matters is the one for the directory being written.
23
+ *
24
+ * Leaf module — Node built-ins only. Deliberately not in `adapters/shared.ts`,
25
+ * which `server/asset-headers.ts` re-exports from and which therefore must
26
+ * stay free of `node:fs` for the Workers bundle.
27
+ */
28
+
29
+ import { writeFile, stat, rm } from 'node:fs/promises';
30
+ import { randomUUID } from 'node:crypto';
31
+ import { join } from 'node:path';
32
+
33
+ /** How the output filesystem decides that two paths name one file. */
34
+ export interface FsIdentity {
35
+ /** `Foo` and `foo` are the same file. */
36
+ caseInsensitive: boolean;
37
+ /** Canonically equivalent Unicode spellings are the same file. */
38
+ normalizationInsensitive: boolean;
39
+ /**
40
+ * Trailing dots and spaces are stripped from each segment, so `foo.` and
41
+ * `foo` are the same file. Win32 does this in path resolution rather than
42
+ * in the filesystem, which is why it is probed like the others.
43
+ */
44
+ stripsTrailingDotsAndSpaces: boolean;
45
+ }
46
+
47
+ /** Every path comparison is exact — the conservative answer when unprobed. */
48
+ export const EXACT_FS_IDENTITY: FsIdentity = {
49
+ caseInsensitive: false,
50
+ normalizationInsensitive: false,
51
+ stripsTrailingDotsAndSpaces: false,
52
+ };
53
+
54
+ /**
55
+ * Probe `dir` by writing one file and asking for it under other spellings.
56
+ *
57
+ * Returns {@link EXACT_FS_IDENTITY} when the probe cannot run. That
58
+ * under-detects, but a directory this cannot write to is one the caller is
59
+ * about to fail on anyway, with a clearer error than a missed collision.
60
+ */
61
+ export async function detectFsIdentity(dir: string): Promise<FsIdentity> {
62
+ // `café` — NFC, with a precomposed é. The UUID contributes the
63
+ // hex letters the case probe needs.
64
+ const name = `.timber-probe-${randomUUID()}-café`;
65
+ const path = join(dir, name);
66
+
67
+ try {
68
+ await writeFile(path, '');
69
+ const [caseInsensitive, normalizationInsensitive, stripsTrailingDotsAndSpaces] =
70
+ await Promise.all([
71
+ exists(join(dir, name.toUpperCase())),
72
+ // NFD spells the same string as `e` + U+0301; a filesystem that
73
+ // resolves it to the NFC file compares normalization-insensitively.
74
+ exists(join(dir, name.normalize('NFD'))),
75
+ // Win32 strips trailing dots and spaces during path resolution, so
76
+ // asking for `name.` reaches `name`.
77
+ exists(join(dir, `${name}.`)),
78
+ ]);
79
+ return { caseInsensitive, normalizationInsensitive, stripsTrailingDotsAndSpaces };
80
+ } catch {
81
+ return EXACT_FS_IDENTITY;
82
+ } finally {
83
+ await rm(path, { force: true }).catch(() => {});
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Fold a path to the identity the filesystem compares it under.
89
+ *
90
+ * Normalization first, then case: `toLowerCase()` operates per code point, so
91
+ * folding a decomposed string first would leave the combining marks in a
92
+ * different order than folding a precomposed one.
93
+ *
94
+ * An approximation of the filesystem's own algorithm — APFS and NTFS fold per
95
+ * their own Unicode tables, which `toLowerCase()` matches for everything short
96
+ * of the exotic. It errs toward *finding* a collision, and the cost of a false
97
+ * positive is a build error naming two routes rather than a silent overwrite.
98
+ */
99
+ export function fsKey(path: string, identity: FsIdentity): string {
100
+ let key = identity.caseInsensitive ? caseFold(path) : path;
101
+ if (identity.normalizationInsensitive) key = key.normalize('NFC');
102
+ if (identity.stripsTrailingDotsAndSpaces) key = key.split('/').map(stripTrailing).join('/');
103
+ return key;
104
+ }
105
+
106
+ /**
107
+ * Approximate Unicode case folding, which is what case-insensitive
108
+ * filesystems actually compare under.
109
+ *
110
+ * `toLowerCase()` is not it. Greek `σ` and final `ς` are both already
111
+ * lowercase and stay distinct under it, yet NTFS upcases both to `Σ` and APFS
112
+ * folds `ς` to `σ` — so two routes differing only in that letter are one file
113
+ * on both, and a lowercase key admitted them as two claims (TIM-1266).
114
+ * Uppercasing first collapses them.
115
+ *
116
+ * The residual is over-folding, in these cases and a few like them:
117
+ *
118
+ * | pair | folded together here | NTFS / APFS |
119
+ * | --------- | -------------------- | ----------------- |
120
+ * | `σ` / `ς` | yes | yes — the fix |
121
+ * | `ß` / `ss`| yes | no |
122
+ * | `ı` / `i` | yes | no |
123
+ * | `fi` / `fi`| yes | no |
124
+ *
125
+ * That direction is deliberate. Over-folding costs a build error naming two
126
+ * routes, which a developer fixes by renaming one; under-folding costs a
127
+ * payload silently overwritten by another route's, which nothing downstream
128
+ * can detect. An exact fold would need `Intl.Collator`, which compares but
129
+ * emits no sort key, so using it would turn a hash lookup into a pairwise
130
+ * scan over every claimed path.
131
+ */
132
+ function caseFold(value: string): string {
133
+ return value.toUpperCase().toLowerCase();
134
+ }
135
+
136
+ /**
137
+ * Drop trailing dots and spaces from one path segment, as Win32 does.
138
+ *
139
+ * A segment that is nothing but dots and spaces is left alone: `.` and `..`
140
+ * are traversal, which the caller rejects before reaching here, and emptying
141
+ * a segment would merge two unrelated paths rather than the aliases this
142
+ * models.
143
+ */
144
+ function stripTrailing(segment: string): string {
145
+ const stripped = segment.replace(/[. ]+$/, '');
146
+ return stripped === '' ? segment : stripped;
147
+ }
148
+
149
+ async function exists(path: string): Promise<boolean> {
150
+ try {
151
+ await stat(path);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
@@ -10,11 +10,12 @@ import { execFile } from 'node:child_process';
10
10
  import { join, relative, dirname, basename } from 'node:path';
11
11
  import type { TimberPlatformAdapter, TimberConfig } from './types';
12
12
  import { generateCompressModule } from './compress-module.js';
13
- import { IMMUTABLE_CACHE, generateHeadersFile } from './shared.js';
13
+ import { adapterBase, IMMUTABLE_CACHE, generateHeadersFile } from './shared.js';
14
14
  import {
15
15
  runSharedBuildSteps,
16
16
  buildStaticDirectoryOutput,
17
17
  findUnhashedStaticFiles,
18
+ writeHeadersFile,
18
19
  } from './build-output-helper.js';
19
20
 
20
21
  // ─── Presets ─────────────────────────────────────────────────────────────────
@@ -229,15 +230,14 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
229
230
 
230
231
  // Write _headers for cache control + extensionless Content-Type.
231
232
  // Netlify supports the _headers convention just like Cloudflare.
232
- const unhashedPaths = await findUnhashedStaticFiles(publicDir);
233
- await writeFile(
234
- join(publicDir, '_headers'),
235
- generateHeadersFile(unhashedPaths, config.staticContentTypes)
236
- );
233
+ await writeHeadersFile(publicDir, config, await findUnhashedStaticFiles(publicDir));
237
234
 
238
235
  // Platform-specific files for static deploy targets.
239
236
  const target = options.staticTarget ?? 'generic';
240
237
  if (target === 'github-pages') {
238
+ // Deploy-from-a-branch runs Jekyll, which drops `_`-prefixed paths —
239
+ // every `_rsc/` payload. Written unconditionally: GitHub reads only
240
+ // that the file exists, never its contents.
241
241
  await writeFile(join(publicDir, '.nojekyll'), '');
242
242
  // GitHub Pages doesn't support _redirects files. Warn if any
243
243
  // redirects were generated — those routes will 404 on GH Pages.
@@ -8,6 +8,25 @@
8
8
  // IMPORTANT: Do NOT add imports from ../server/, ../client/, ../plugins/,
9
9
  // or any module that transitively depends on Vite. A test in
10
10
  // tests/adapters/shared-no-forbidden-imports.test.ts enforces this.
11
+ // ../shared/ is exempt: it is dependency-free by the same rule.
12
+
13
+ import { withBasePath } from '../shared/base-path.js';
14
+
15
+ /**
16
+ * The deployment base an adapter should write platform rules against.
17
+ *
18
+ * `config.base` is optional on `TimberConfig` because adapters can be invoked
19
+ * by callers that predate it (a hand-written adapter, a test harness). Falling
20
+ * back to `'/'` reproduces the pre-TIM-1274 output exactly, which is correct
21
+ * for the only deployments those callers can have: a build under a non-root
22
+ * base always goes through `plugins/adapter-build.ts`, which populates it.
23
+ *
24
+ * Typed structurally rather than as `TimberConfig` so this module keeps no
25
+ * import of `./types.js` at runtime.
26
+ */
27
+ export function adapterBase(config: { base?: string }): string {
28
+ return config.base ?? '/';
29
+ }
11
30
 
12
31
  // ─── Static asset cache headers ──────────────────────────────────────────
13
32
 
@@ -33,27 +52,165 @@ export const STATIC_CACHE = 'public, max-age=3600, must-revalidate';
33
52
  * A `/*` catch-all is NOT used — it would be additive on Cloudflare,
34
53
  * producing duplicate Cache-Control directives on hashed assets
35
54
  * (invalid per RFC 9111).
55
+ *
56
+ * **Every rule path is written in deployed-URL space** (TIM-1274). The
57
+ * platform matches these patterns against the request path, which under a
58
+ * non-root `base` sits below the prefix: with `base: '/repo/'` the assets are
59
+ * at `/repo/assets/*`, so a rule saying `/assets/*` matches nothing. Silent
60
+ * for the cache rules (slower, still correct) and not silent for the
61
+ * Content-Type rules — an extensionless API route output is unreadable on
62
+ * Cloudflare Workers Static Assets without one.
63
+ *
64
+ * The prefix is applied at exactly one point, `rulePath()`, which every rule
65
+ * in this function goes through. Prefixing the three wildcards and
66
+ * hand-checking that the per-file rules agree is how the next rule gets
67
+ * missed.
36
68
  */
37
69
  // Cloudflare Workers Static Assets caps _headers at 100 rules.
38
70
  // Reserve 3 for the wildcard hashed-asset rules (/assets/*, /_timber/fonts/*, /_rsc/*).
39
71
  const MAX_PER_FILE_RULES = 97;
40
72
 
41
- export function generateHeadersFile(
42
- unhashedPaths?: string[],
43
- contentTypes?: Record<string, string>
44
- ): string {
45
- let content = `# Auto-generated by @timber-js/app — static asset cache headers.
46
- # See design/25-production-deployments.md §"CDN / Edge Cache"
73
+ export interface HeadersFileOptions {
74
+ /**
75
+ * The deployment base, already normalized by `normalizeBasePath()` —
76
+ * leading and trailing slash, `'/'` at the domain root.
77
+ *
78
+ * Required rather than defaulted: a caller that forgets it would emit
79
+ * root-relative rules that match nothing, which is the bug this closes.
80
+ */
81
+ base: string;
82
+ /** Unhashed static files (route-space paths) needing per-file cache rules. */
83
+ unhashedPaths?: string[];
84
+ /** Route-space path → Content-Type, for extensionless API route outputs. */
85
+ contentTypes?: Record<string, string>;
86
+ /**
87
+ * Verbatim contents of the user's `public/_headers`, appended after the
88
+ * generated rules. See `mergeUserHeaders` for why it goes last.
89
+ */
90
+ userRules?: string;
91
+ }
47
92
 
48
- /assets/*
49
- Cache-Control: ${IMMUTABLE_CACHE}
93
+ /**
94
+ * Count the rules in a `_headers` file — a rule is a path line, which is any
95
+ * line that is neither blank, a comment, nor an indented header.
96
+ *
97
+ * @internal Exported for testing.
98
+ */
99
+ export function countHeaderRules(content: string): number {
100
+ return content
101
+ .split('\n')
102
+ .filter((line) => line.trim() !== '' && !line.startsWith('#') && !/^\s/.test(line)).length;
103
+ }
50
104
 
51
- /_timber/fonts/*
52
- Cache-Control: ${IMMUTABLE_CACHE}
105
+ /** One `_headers` block: a path pattern and the headers it sets. */
106
+ export interface HeaderRule {
107
+ pattern: string;
108
+ headers: Array<{ name: string; value: string }>;
109
+ }
53
110
 
54
- /_rsc/*
55
- Cache-Control: ${IMMUTABLE_CACHE}
111
+ /**
112
+ * Parse a `_headers` file. A non-indented, non-comment line opens a rule; the
113
+ * indented `Name: value` lines beneath it belong to that rule.
114
+ *
115
+ * @internal Exported for testing.
116
+ */
117
+ export function parseHeaderRules(content: string): HeaderRule[] {
118
+ const rules: HeaderRule[] = [];
119
+ for (const raw of content.split('\n')) {
120
+ const line = raw.replace(/\r$/, '');
121
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
122
+ if (/^\s/.test(line)) {
123
+ const current = rules[rules.length - 1];
124
+ if (!current) continue; // header with no rule above it — not ours to fix
125
+ const colon = line.indexOf(':');
126
+ if (colon === -1) continue;
127
+ current.headers.push({
128
+ name: line.slice(0, colon).trim(),
129
+ value: line.slice(colon + 1).trim(),
130
+ });
131
+ } else {
132
+ rules.push({ pattern: line.trim(), headers: [] });
133
+ }
134
+ }
135
+ return rules;
136
+ }
137
+
138
+ /**
139
+ * Whether a user pattern can match paths a generated pattern also matches.
140
+ *
141
+ * Deliberately narrow: equality, or a prefix-glob (`/x/*`, `/*`) whose literal
142
+ * prefix contains the other's. Anything cleverer risks claiming an overlap
143
+ * that is not there, and the only thing this drives is a warning — a false
144
+ * positive is noise a developer cannot act on.
145
+ */
146
+ function patternsOverlap(a: string, b: string): boolean {
147
+ if (a === b) return true;
148
+ const prefixOf = (p: string) => (p.endsWith('*') ? p.slice(0, -1) : null);
149
+ const [pa, pb] = [prefixOf(a), prefixOf(b)];
150
+ if (pa !== null && (b.startsWith(pa) || pa === '')) return true;
151
+ if (pb !== null && (a.startsWith(pb) || pb === '')) return true;
152
+ return false;
153
+ }
154
+
155
+ export function generateHeadersFile({
156
+ base,
157
+ unhashedPaths,
158
+ contentTypes,
159
+ userRules,
160
+ }: HeadersFileOptions): string {
161
+ /** Route-space rule path → deployed-URL rule path. */
162
+ const rulePath = (path: string) => withBasePath(base, path);
163
+
164
+ // Cloudflare and Netlify apply *every* matching rule, so emitting a header
165
+ // the user also sets on the same path yields two definitions of it — a
166
+ // comma-joined `Cache-Control` that is invalid per RFC 9111, and neither
167
+ // side's intent. This module already refuses to emit a `/*` catch-all for
168
+ // that reason; appending the user's file verbatim reintroduced it through
169
+ // the back door (TIM-1326).
170
+ //
171
+ // An explicit app rule beats a framework default, so where the user names
172
+ // the same pattern and the same header, ours is dropped. Only exact pattern
173
+ // matches are resolved this way: silently dropping asset immutability
174
+ // because a `/*` rule *might* subsume it would be a worse surprise than the
175
+ // duplicate, so overlapping-but-different patterns warn instead.
176
+ const userRuleList = userRules ? parseHeaderRules(userRules) : [];
177
+ const overriddenExactly = new Set(
178
+ userRuleList.flatMap((rule) =>
179
+ rule.headers.map((h) => `${rule.pattern}\0${h.name.toLowerCase()}`)
180
+ )
181
+ );
182
+ const overridden = (pattern: string, header: string) =>
183
+ overriddenExactly.has(`${pattern}\0${header.toLowerCase()}`);
184
+
185
+ const warnedOverlaps = new Set<string>();
186
+ const emit = (pattern: string, header: string, value: string): string => {
187
+ if (overridden(pattern, header)) return '';
188
+ for (const rule of userRuleList) {
189
+ if (rule.pattern === pattern) continue;
190
+ if (!patternsOverlap(rule.pattern, pattern)) continue;
191
+ for (const h of rule.headers) {
192
+ if (h.name.toLowerCase() !== header.toLowerCase()) continue;
193
+ const key = `${rule.pattern}\0${pattern}\0${header.toLowerCase()}`;
194
+ if (warnedOverlaps.has(key)) continue;
195
+ warnedOverlaps.add(key);
196
+ console.warn(
197
+ `[timber] _headers: your rule "${rule.pattern}" sets ${header}, which ` +
198
+ `timber also sets on "${pattern}". Cloudflare and Netlify apply both, ` +
199
+ `so the response gets two ${header} values. Use the exact pattern ` +
200
+ `"${pattern}" to replace timber's rule, or narrow yours so they do ` +
201
+ `not overlap.`
202
+ );
203
+ }
204
+ }
205
+ return `\n${pattern}\n ${header}: ${value}\n`;
206
+ };
207
+
208
+ let content = `# Auto-generated by @timber-js/app — static asset cache headers.
209
+ # See design/25-production-deployments.md §"CDN / Edge Cache"
56
210
  `;
211
+ for (const path of ['/assets/*', '/_timber/fonts/*', '/_rsc/*']) {
212
+ content += emit(rulePath(path), 'Cache-Control', IMMUTABLE_CACHE);
213
+ }
57
214
 
58
215
  // Content-Type rules are higher priority than cache headers for
59
216
  // extensionless files — without them the file is unreadable.
@@ -80,18 +237,36 @@ export function generateHeadersFile(
80
237
  `files will get Cache-Control headers.`
81
238
  );
82
239
  }
83
- if (capped.length > 0) {
84
- content += `\n# Unhashed static assets — short-lived cache with revalidation\n`;
85
- for (const p of capped) {
86
- content += `\n${p}\n Cache-Control: ${STATIC_CACHE}\n`;
87
- }
240
+ // Section bodies are built before their comment header so a section the
241
+ // user has overridden entirely does not leave a heading over nothing.
242
+ const body = capped.map((p) => emit(rulePath(p), 'Cache-Control', STATIC_CACHE)).join('');
243
+ if (body !== '') {
244
+ content += `\n# Unhashed static assets — short-lived cache with revalidation\n${body}`;
88
245
  }
89
246
  }
90
247
 
91
- if (ctEntries.length > 0) {
92
- content += `\n# Content-Type for extensionless API routes\n`;
93
- for (const [urlPath, ct] of ctEntries) {
94
- content += `\n${urlPath}\n Content-Type: ${ct}\n`;
248
+ const ctBody = ctEntries
249
+ .map(([urlPath, ct]) => emit(rulePath(urlPath), 'Content-Type', ct))
250
+ .join('');
251
+ if (ctBody !== '') {
252
+ content += `\n# Content-Type for extensionless API routes\n${ctBody}`;
253
+ }
254
+
255
+ // User rules last, matching the `_redirects` merge. Their paths are left
256
+ // verbatim: `_redirects` prefixes the rules it generates because it wrote
257
+ // them in route space, but these are the user's own text, already authored
258
+ // against the URLs the site is deployed at.
259
+ if (userRules && userRules.trim() !== '') {
260
+ content += `\n# User-authored rules from public/_headers\n\n${userRules.trim()}\n`;
261
+
262
+ const total = countHeaderRules(content);
263
+ if (total > MAX_PER_FILE_RULES + 3) {
264
+ console.warn(
265
+ `[timber] _headers: ${total} rules after merging public/_headers, over ` +
266
+ `Cloudflare's ${MAX_PER_FILE_RULES + 3}-rule limit. Rules past the ` +
267
+ `limit are dropped by the platform — remove some, or move static ` +
268
+ `assets under a directory the wildcard rules already cover.`
269
+ );
95
270
  }
96
271
  }
97
272
 
@@ -11,6 +11,18 @@
11
11
  export interface TimberConfig {
12
12
  /** Absolute path to the project root (Vite root). */
13
13
  root?: string;
14
+ /**
15
+ * The resolved deployment base, normalized by `normalizeBasePath()` —
16
+ * leading and trailing slash, `'/'` at the domain root.
17
+ *
18
+ * Adapters write platform files (`_headers`, `_redirects`) whose rules the
19
+ * host matches against the *request* path, so those rules live in deployed-
20
+ * URL space rather than route space and need this prefix (TIM-1274).
21
+ *
22
+ * Populated by `plugins/adapter-build.ts` from `ctx.base`, the single copy
23
+ * lifted in TIM-1261. Adapters must not re-derive it from `config.base`.
24
+ */
25
+ base?: string;
14
26
  output: 'server' | 'static';
15
27
  clientJavascriptDisabled?: boolean;
16
28
  /**
@@ -23,7 +35,7 @@ export interface TimberConfig {
23
35
  * Absolute path to the directory containing pre-rendered static HTML,
24
36
  * RSC flight files, and API route outputs. Present only when
25
37
  * output: 'static' and the static generation pass completed.
26
- * Layout: path/index.html, _rsc/path.rsc, api-route-path
38
+ * Layout: path/index.html, _rsc/path/index.rsc, api-route-path
27
39
  */
28
40
  staticPagesDir?: string;
29
41
  /**
@@ -9,6 +9,11 @@
9
9
  * Uses BigInt for 64-bit arithmetic — supported in all modern runtimes
10
10
  * including Cloudflare Workers. No node:crypto dependency.
11
11
  *
12
+ * Server-side only. The `_rsc` payload cache key deliberately does NOT use
13
+ * this — the origin compares that value, which makes it a check rather than
14
+ * a cache key, and FNV is solvable for a chosen digest. See
15
+ * `shared/rsc-cache-key.ts`.
16
+ *
12
17
  * See TIM-370.
13
18
  */
14
19
 
@@ -18,6 +18,7 @@ import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runti
18
18
  import { getRouter, getRouterOrNull } from '#client-internal';
19
19
  import { setHardNavigating } from '../navigation-root.js';
20
20
  import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
21
+ import { markClientStale } from '../stale-client.js';
21
22
  export function setupServerActions(): void {
22
23
  setServerCallback(async (id: string, args: unknown[]) => {
23
24
  const body = await encodeReply(args);
@@ -44,11 +45,15 @@ export function setupServerActions(): void {
44
45
  headers: actionHeaders,
45
46
  body,
46
47
  }).then((res) => {
47
- // Version skew detection (TIM-446): if the server signals a reload,
48
- // trigger a full page load to pick up the new deployment.
48
+ // Version skew detection (TIM-446): the server has signalled that this
49
+ // client is stale.
49
50
  if (res.headers.get(RELOAD_HEADER) === '1') {
50
- window.location.reload();
51
- throw new Error('Version skew detected — reloading page');
51
+ // Record it and fail the action. No page load: the action cannot
52
+ // succeed either way (its ID does not exist in the new build), and
53
+ // tearing the document down here would destroy whatever the user had
54
+ // typed into the form. Their next navigation recovers (TIM-1275).
55
+ markClientStale();
56
+ throw new Error('Version skew detected — this client is out of date');
52
57
  }
53
58
  hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
54
59
  hasRedirect = res.headers.get('X-Timber-Redirect') != null;