@timber-js/app 0.2.0-alpha.157 → 0.2.0-alpha.159

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 (290) hide show
  1. package/dist/_chunks/{actions-CqE7SW7E.js → actions-CBbP1hHO.js} +22 -6
  2. package/dist/_chunks/actions-CBbP1hHO.js.map +1 -0
  3. package/dist/_chunks/{build-output-helper-C8q4Zpf5.js → build-output-helper-DF5uScqS.js} +4 -1
  4. package/dist/_chunks/{build-output-helper-C8q4Zpf5.js.map → build-output-helper-DF5uScqS.js.map} +1 -1
  5. package/dist/_chunks/cache-api-DsWEYwVZ.js +1120 -0
  6. package/dist/_chunks/cache-api-DsWEYwVZ.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js → cli-schema-sync-Bivx4Rn7.js} +4 -3
  8. package/dist/_chunks/{cli-schema-sync-DcmQrzJG.js.map → cli-schema-sync-Bivx4Rn7.js.map} +1 -1
  9. package/dist/_chunks/dist-BA3u1z3W.js +423 -0
  10. package/dist/_chunks/dist-BA3u1z3W.js.map +1 -0
  11. package/dist/_chunks/{logger-C5v1IMuh.js → logger-CrRmZMlo.js} +81 -56
  12. package/dist/_chunks/logger-CrRmZMlo.js.map +1 -0
  13. package/dist/_chunks/{plugin-context-DUl3dnCF.js → plugin-context-DeAxFRMq.js} +3 -36
  14. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +1 -0
  15. package/dist/_chunks/sizeof-lXffQ1Vr.js +35 -0
  16. package/dist/_chunks/sizeof-lXffQ1Vr.js.map +1 -0
  17. package/dist/_chunks/{walkers-D5jCGPum.js → walkers-DXbJkeCY.js} +217 -19
  18. package/dist/_chunks/walkers-DXbJkeCY.js.map +1 -0
  19. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  20. package/dist/adapters/cloudflare.d.ts +28 -0
  21. package/dist/adapters/cloudflare.d.ts.map +1 -1
  22. package/dist/adapters/cloudflare.js +143 -9
  23. package/dist/adapters/cloudflare.js.map +1 -1
  24. package/dist/adapters/nitro.js +1 -1
  25. package/dist/cache/cache-api.d.ts +2 -0
  26. package/dist/cache/cache-api.d.ts.map +1 -1
  27. package/dist/cache/handler-store.d.ts +2 -0
  28. package/dist/cache/handler-store.d.ts.map +1 -1
  29. package/dist/cache/index.d.ts +2 -0
  30. package/dist/cache/index.d.ts.map +1 -1
  31. package/dist/cache/index.js +3 -186
  32. package/dist/cache/redis-handler.d.ts +17 -5
  33. package/dist/cache/redis-handler.d.ts.map +1 -1
  34. package/dist/cache/singleflight.d.ts +1 -1
  35. package/dist/cache/singleflight.d.ts.map +1 -1
  36. package/dist/cache/store.d.ts +53 -0
  37. package/dist/cache/store.d.ts.map +1 -0
  38. package/dist/cache/stores/cloudflare-kv.d.ts +38 -0
  39. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -0
  40. package/dist/cache/stores/cloudflare-kv.js +29 -0
  41. package/dist/cache/stores/cloudflare-kv.js.map +1 -0
  42. package/dist/cache/stores/memory.d.ts +42 -0
  43. package/dist/cache/stores/memory.d.ts.map +1 -0
  44. package/dist/cache/stores/memory.js +90 -0
  45. package/dist/cache/stores/memory.js.map +1 -0
  46. package/dist/cache/stores/redis.d.ts +69 -0
  47. package/dist/cache/stores/redis.d.ts.map +1 -0
  48. package/dist/cache/stores/redis.js +35 -0
  49. package/dist/cache/stores/redis.js.map +1 -0
  50. package/dist/cache/stores/vercel.d.ts +36 -0
  51. package/dist/cache/stores/vercel.d.ts.map +1 -0
  52. package/dist/cache/stores/vercel.js +33 -0
  53. package/dist/cache/stores/vercel.js.map +1 -0
  54. package/dist/cache/tag-aware-handler.d.ts +51 -0
  55. package/dist/cache/tag-aware-handler.d.ts.map +1 -0
  56. package/dist/cache/timber-cache.d.ts.map +1 -1
  57. package/dist/cdn/cloudflare-purge.d.ts +23 -0
  58. package/dist/cdn/cloudflare-purge.d.ts.map +1 -0
  59. package/dist/cdn/fastly-purge.d.ts +25 -0
  60. package/dist/cdn/fastly-purge.d.ts.map +1 -0
  61. package/dist/cdn/headers.d.ts +82 -0
  62. package/dist/cdn/headers.d.ts.map +1 -0
  63. package/dist/cdn/index.d.ts +5 -0
  64. package/dist/cdn/index.d.ts.map +1 -0
  65. package/dist/cdn/purge-store.d.ts +23 -0
  66. package/dist/cdn/purge-store.d.ts.map +1 -0
  67. package/dist/cli.js +2 -2
  68. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  69. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  70. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  71. package/dist/client/history.d.ts +0 -3
  72. package/dist/client/history.d.ts.map +1 -1
  73. package/dist/client/index.js +0 -1
  74. package/dist/client/index.js.map +1 -1
  75. package/dist/client/internal.js +11 -86
  76. package/dist/client/internal.js.map +1 -1
  77. package/dist/client/navigation-api.d.ts +1 -3
  78. package/dist/client/navigation-api.d.ts.map +1 -1
  79. package/dist/client/router.d.ts +1 -4
  80. package/dist/client/router.d.ts.map +1 -1
  81. package/dist/client/rsc-fetch.d.ts +1 -11
  82. package/dist/client/rsc-fetch.d.ts.map +1 -1
  83. package/dist/client/segment-cache.d.ts +1 -3
  84. package/dist/client/segment-cache.d.ts.map +1 -1
  85. package/dist/client/use-cookie.d.ts.map +1 -1
  86. package/dist/config-types.d.ts +3 -1
  87. package/dist/config-types.d.ts.map +1 -1
  88. package/dist/config-validation.d.ts.map +1 -1
  89. package/dist/cookies/index.js +15 -11
  90. package/dist/cookies/index.js.map +1 -1
  91. package/dist/dev-tools/browser-logs.d.ts +28 -72
  92. package/dist/dev-tools/browser-logs.d.ts.map +1 -1
  93. package/dist/dev-tools/index.d.ts +1 -1
  94. package/dist/dev-tools/index.d.ts.map +1 -1
  95. package/dist/dev-tools/logger.d.ts.map +1 -1
  96. package/dist/dev-tools/terminal.d.ts.map +1 -1
  97. package/dist/fonts/transform.d.ts +4 -0
  98. package/dist/fonts/transform.d.ts.map +1 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +1269 -359
  101. package/dist/index.js.map +1 -1
  102. package/dist/plugin-context.d.ts +36 -0
  103. package/dist/plugin-context.d.ts.map +1 -1
  104. package/dist/plugins/build-report.d.ts +21 -0
  105. package/dist/plugins/build-report.d.ts.map +1 -1
  106. package/dist/plugins/cache.d.ts +5 -14
  107. package/dist/plugins/cache.d.ts.map +1 -1
  108. package/dist/plugins/callsite-ast.d.ts +95 -0
  109. package/dist/plugins/callsite-ast.d.ts.map +1 -0
  110. package/dist/plugins/callsite-jsx.d.ts +35 -0
  111. package/dist/plugins/callsite-jsx.d.ts.map +1 -0
  112. package/dist/plugins/entries.d.ts.map +1 -1
  113. package/dist/plugins/fonts.d.ts.map +1 -1
  114. package/dist/plugins/prebuilt-capture.d.ts +60 -0
  115. package/dist/plugins/prebuilt-capture.d.ts.map +1 -0
  116. package/dist/plugins/prebuilt.d.ts +82 -0
  117. package/dist/plugins/prebuilt.d.ts.map +1 -0
  118. package/dist/plugins/prerender-sugar.d.ts +42 -0
  119. package/dist/plugins/prerender-sugar.d.ts.map +1 -0
  120. package/dist/plugins/server-bundle.d.ts.map +1 -1
  121. package/dist/plugins/shims.d.ts.map +1 -1
  122. package/dist/plugins/static-build.d.ts.map +1 -1
  123. package/dist/routing/convention-lint.d.ts.map +1 -1
  124. package/dist/routing/export-detect.d.ts +56 -0
  125. package/dist/routing/export-detect.d.ts.map +1 -0
  126. package/dist/routing/index.js +2 -2
  127. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  128. package/dist/rsc-runtime/rsc.d.ts +1 -1
  129. package/dist/rsc-runtime/rsc.d.ts.map +1 -1
  130. package/dist/server/action-handler.d.ts.map +1 -1
  131. package/dist/server/actions.d.ts +0 -3
  132. package/dist/server/actions.d.ts.map +1 -1
  133. package/dist/server/als-registry.d.ts +7 -0
  134. package/dist/server/als-registry.d.ts.map +1 -1
  135. package/dist/server/cookie-parsing.d.ts +15 -1
  136. package/dist/server/cookie-parsing.d.ts.map +1 -1
  137. package/dist/server/deny-renderer.d.ts.map +1 -1
  138. package/dist/server/form-data.d.ts +12 -0
  139. package/dist/server/form-data.d.ts.map +1 -1
  140. package/dist/server/html-injectors.d.ts +1 -0
  141. package/dist/server/html-injectors.d.ts.map +1 -1
  142. package/dist/server/index.js +2 -2
  143. package/dist/server/internal.d.ts +6 -1
  144. package/dist/server/internal.d.ts.map +1 -1
  145. package/dist/server/internal.js +563 -74
  146. package/dist/server/internal.js.map +1 -1
  147. package/dist/server/prebuilt/cache-key.d.ts +58 -0
  148. package/dist/server/prebuilt/cache-key.d.ts.map +1 -0
  149. package/dist/server/prebuilt/capture-state.d.ts +57 -0
  150. package/dist/server/prebuilt/capture-state.d.ts.map +1 -0
  151. package/dist/server/prebuilt/manifest-tag-index.d.ts +27 -0
  152. package/dist/server/prebuilt/manifest-tag-index.d.ts.map +1 -0
  153. package/dist/server/prebuilt/overlay.d.ts +100 -0
  154. package/dist/server/prebuilt/overlay.d.ts.map +1 -0
  155. package/dist/server/prebuilt/payload-source.d.ts +75 -0
  156. package/dist/server/prebuilt/payload-source.d.ts.map +1 -0
  157. package/dist/server/prebuilt/synthetic-store.d.ts +64 -0
  158. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -0
  159. package/dist/server/prebuilt-builder.d.ts +110 -0
  160. package/dist/server/prebuilt-builder.d.ts.map +1 -0
  161. package/dist/server/prebuilt-runtime.d.ts +101 -0
  162. package/dist/server/prebuilt-runtime.d.ts.map +1 -0
  163. package/dist/server/route-element-builder.d.ts +10 -8
  164. package/dist/server/route-element-builder.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/helpers.d.ts +0 -5
  166. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  167. package/dist/server/rsc-entry/index.d.ts +10 -0
  168. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  169. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  170. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  171. package/dist/server/rsc-entry/rsc-payload.d.ts +3 -4
  172. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  173. package/dist/server/sitemap-generator.d.ts +6 -6
  174. package/dist/server/sitemap-generator.d.ts.map +1 -1
  175. package/dist/server/tracing.d.ts +37 -3
  176. package/dist/server/tracing.d.ts.map +1 -1
  177. package/docs/api/32-api-cache.mdx +10 -4
  178. package/docs/learn/00-introduction.mdx +8 -13
  179. package/docs/learn/02-pages-and-layouts.mdx +9 -0
  180. package/docs/learn/06-forms-and-actions.mdx +0 -4
  181. package/docs/learn/07-typed-routes.mdx +127 -0
  182. package/docs/learn/09-caching.mdx +12 -1
  183. package/docs/learn/10-middleware.mdx +1 -0
  184. package/docs/learn/11-error-handling.mdx +9 -0
  185. package/docs/learn/13-configuration.mdx +28 -0
  186. package/docs/learn/14-deploying.mdx +52 -0
  187. package/docs/more/06-instrumentation.mdx +27 -1
  188. package/docs/more/07-security.mdx +8 -2
  189. package/docs/more/08-developer-experience.mdx +134 -0
  190. package/package.json +31 -2
  191. package/src/adapters/build-output-helper.ts +10 -0
  192. package/src/adapters/cloudflare.ts +178 -4
  193. package/src/cache/cache-api.ts +60 -0
  194. package/src/cache/handler-store.ts +8 -0
  195. package/src/cache/index.ts +4 -0
  196. package/src/cache/redis-handler.ts +25 -13
  197. package/src/cache/singleflight.ts +11 -11
  198. package/src/cache/store.ts +71 -0
  199. package/src/cache/stores/cloudflare-kv.ts +52 -0
  200. package/src/cache/stores/memory.ts +125 -0
  201. package/src/cache/stores/redis.ts +97 -0
  202. package/src/cache/stores/vercel.ts +59 -0
  203. package/src/cache/tag-aware-handler.ts +233 -0
  204. package/src/cache/timber-cache.ts +17 -7
  205. package/src/cdn/cloudflare-purge.ts +50 -0
  206. package/src/cdn/fastly-purge.ts +63 -0
  207. package/src/cdn/headers.ts +127 -0
  208. package/src/cdn/index.ts +16 -0
  209. package/src/cdn/purge-store.ts +34 -0
  210. package/src/client/browser-entry/action-dispatch.ts +1 -10
  211. package/src/client/browser-entry/post-hydration.ts +0 -1
  212. package/src/client/browser-entry/router-init.ts +0 -5
  213. package/src/client/history.ts +0 -4
  214. package/src/client/navigation-api.ts +5 -11
  215. package/src/client/router.ts +9 -43
  216. package/src/client/rsc-fetch.ts +5 -27
  217. package/src/client/segment-cache.ts +1 -4
  218. package/src/client/use-cookie.ts +18 -12
  219. package/src/config-types.ts +3 -1
  220. package/src/config-validation.ts +1 -0
  221. package/src/dev-tools/browser-logs.ts +37 -273
  222. package/src/dev-tools/index.ts +2 -2
  223. package/src/dev-tools/instrumentation.ts +1 -1
  224. package/src/dev-tools/logger.ts +33 -51
  225. package/src/dev-tools/terminal.ts +21 -22
  226. package/src/fonts/transform.ts +4 -43
  227. package/src/index.ts +16 -3
  228. package/src/plugin-context.ts +36 -0
  229. package/src/plugins/build-report.ts +133 -1
  230. package/src/plugins/cache.ts +32 -131
  231. package/src/plugins/callsite-ast.ts +167 -0
  232. package/src/plugins/callsite-jsx.ts +243 -0
  233. package/src/plugins/entries.ts +23 -4
  234. package/src/plugins/fonts.ts +16 -2
  235. package/src/plugins/prebuilt-capture.ts +208 -0
  236. package/src/plugins/prebuilt.ts +673 -0
  237. package/src/plugins/prerender-sugar.ts +329 -0
  238. package/src/plugins/server-bundle.ts +12 -0
  239. package/src/plugins/shims.ts +9 -0
  240. package/src/plugins/static-build.ts +0 -1
  241. package/src/routing/codegen.ts +1 -21
  242. package/src/routing/convention-lint.ts +85 -49
  243. package/src/routing/export-detect.ts +304 -0
  244. package/src/routing/manifest-codegen.ts +2 -11
  245. package/src/rsc-runtime/rsc.ts +7 -0
  246. package/src/server/action-handler.ts +11 -27
  247. package/src/server/actions.ts +3 -4
  248. package/src/server/als-registry.ts +7 -0
  249. package/src/server/cookie-parsing.ts +75 -59
  250. package/src/server/deny-renderer.ts +7 -15
  251. package/src/server/form-data.ts +23 -7
  252. package/src/server/html-injectors.ts +11 -3
  253. package/src/server/internal.ts +17 -1
  254. package/src/server/prebuilt/cache-key.ts +224 -0
  255. package/src/server/prebuilt/capture-state.ts +89 -0
  256. package/src/server/prebuilt/manifest-tag-index.ts +46 -0
  257. package/src/server/prebuilt/overlay.ts +256 -0
  258. package/src/server/prebuilt/payload-source.ts +192 -0
  259. package/src/server/prebuilt/synthetic-store.ts +108 -0
  260. package/src/server/prebuilt-builder.ts +640 -0
  261. package/src/server/prebuilt-runtime.ts +706 -0
  262. package/src/server/route-element-builder.ts +40 -12
  263. package/src/server/rsc-entry/helpers.ts +0 -17
  264. package/src/server/rsc-entry/index.ts +83 -0
  265. package/src/server/rsc-entry/render-route.ts +6 -33
  266. package/src/server/rsc-entry/revalidate-renderer.ts +16 -11
  267. package/src/server/rsc-entry/rsc-payload.ts +5 -11
  268. package/src/server/sitemap-generator.ts +16 -10
  269. package/src/server/tracing.ts +88 -3
  270. package/dist/_chunks/actions-CqE7SW7E.js.map +0 -1
  271. package/dist/_chunks/cache-api-Cbyh3RW4.js +0 -401
  272. package/dist/_chunks/cache-api-Cbyh3RW4.js.map +0 -1
  273. package/dist/_chunks/logger-C5v1IMuh.js.map +0 -1
  274. package/dist/_chunks/plugin-context-DUl3dnCF.js.map +0 -1
  275. package/dist/_chunks/validation-CnKhGJsp.js +0 -113
  276. package/dist/_chunks/validation-CnKhGJsp.js.map +0 -1
  277. package/dist/_chunks/walkers-D5jCGPum.js.map +0 -1
  278. package/dist/cache/index.js.map +0 -1
  279. package/dist/client/head.d.ts +0 -16
  280. package/dist/client/head.d.ts.map +0 -1
  281. package/dist/client/navigation-api-types.d.ts +0 -90
  282. package/dist/client/navigation-api-types.d.ts.map +0 -1
  283. package/dist/server/client-module-map.d.ts +0 -47
  284. package/dist/server/client-module-map.d.ts.map +0 -1
  285. package/dist/shared/head-header.d.ts +0 -54
  286. package/dist/shared/head-header.d.ts.map +0 -1
  287. package/src/client/head.ts +0 -61
  288. package/src/client/navigation-api-types.ts +0 -112
  289. package/src/server/client-module-map.ts +0 -58
  290. package/src/shared/head-header.ts +0 -69
@@ -196,6 +196,58 @@ export default {
196
196
 
197
197
  Pre-renders every route to HTML at build time. Add `clientJavascript: false` for zero-JavaScript output. Deploy to any static file host — S3, CloudFront, Cloudflare Pages, GitHub Pages, Netlify, or any HTTP server.
198
198
 
199
+ ## CDN Integration
200
+
201
+ timber.js provides typed helpers for CDN caching headers and tag-based purging. CDN caching is always developer-elected — the framework never sets cache headers automatically.
202
+
203
+ ### Header Helpers
204
+
205
+ ```ts title="app/proxy.ts"
206
+ import { cacheControl, surrogateKey } from '@timber-js/app/cdn';
207
+
208
+ export default async function proxy(ctx) {
209
+ if (ctx.pathname.startsWith('/products/')) {
210
+ ctx.headers.set('Cache-Control', cacheControl({
211
+ public: true,
212
+ maxAge: 600,
213
+ staleWhileRevalidate: 60,
214
+ }));
215
+ ctx.headers.set('Surrogate-Key', surrogateKey(['products']));
216
+ }
217
+ }
218
+ ```
219
+
220
+ ### Purge on `revalidateTag`
221
+
222
+ Configure a CDN purge handler so `revalidateTag()` also purges the CDN:
223
+
224
+ ```ts title="timber.config.ts"
225
+ import { CloudflarePurge } from '@timber-js/app/cdn/cloudflare';
226
+
227
+ export default {
228
+ cdnPurge: new CloudflarePurge({
229
+ zoneId: process.env.CF_ZONE_ID!,
230
+ apiToken: process.env.CF_API_TOKEN!,
231
+ }),
232
+ };
233
+ ```
234
+
235
+ Fastly:
236
+
237
+ ```ts title="timber.config.ts"
238
+ import { FastlyPurge } from '@timber-js/app/cdn/fastly';
239
+
240
+ export default {
241
+ cdnPurge: new FastlyPurge({
242
+ serviceId: process.env.FASTLY_SERVICE_ID!,
243
+ apiToken: process.env.FASTLY_API_TOKEN!,
244
+ softPurge: true,
245
+ }),
246
+ };
247
+ ```
248
+
249
+ When `revalidateTag('products')` fires, it clears the application cache **and** calls the CDN purge handler. CDN purge is best-effort — failures are logged but don't break cache invalidation.
250
+
199
251
  ## Cache Handler Selection
200
252
 
201
253
  | Deployment | Recommended Handler | Why |
@@ -115,7 +115,7 @@ const sid = getSpanId(); // "1234567890abcdef" or undefined
115
115
 
116
116
  ### `withSpan(name, attributes, fn)`
117
117
 
118
- Run a function within an OTEL span. If OTEL is not available, the function runs directly with zero overhead.
118
+ Run a function within a span. Emits an OTEL span when an SDK is active, and a native platform span on Cloudflare (see [Cloudflare Native Traces](#cloudflare-native-traces)). With neither, the function runs directly with zero overhead.
119
119
 
120
120
  ```ts
121
121
  import { withSpan } from '@timber-js/app/server';
@@ -144,6 +144,32 @@ import { addSpanEvent } from '@timber-js/app/server';
144
144
  await addSpanEvent('cache.hit', { key: 'user:123' });
145
145
  ```
146
146
 
147
+ ## Cloudflare Native Traces
148
+
149
+ When you deploy with the Cloudflare adapter, timber's pipeline spans (`timber.proxy`, `timber.middleware`, `timber.access`, `timber.render`, `timber.action`, …) appear **natively in the Cloudflare Observability dashboard** — no OTEL SDK required. They nest alongside Cloudflare's auto-instrumented D1, KV, and `fetch` spans with the same attributes timber emits over OTEL (`http.request.method`, `url.path`, `http.route`, `http.response.status_code`, `timber.result`, …).
150
+
151
+ This works out of the box:
152
+
153
+ - The generated `_worker.js` registers timber's spans with the Workers custom spans API (`tracing.enterSpan()` from `cloudflare:workers`).
154
+ - The generated `wrangler.jsonc` enables the trace destination (`observability.traces.enabled`).
155
+
156
+ To turn native traces off, use the `wrangler` escape hatch:
157
+
158
+ ```ts
159
+ // timber.config.ts
160
+ import { cloudflare } from '@timber-js/app/adapters/cloudflare';
161
+
162
+ export default {
163
+ adapter: cloudflare({
164
+ wrangler: { observability: { traces: { enabled: false } } },
165
+ }),
166
+ };
167
+ ```
168
+
169
+ Native emission is additive — if you also initialize an OTEL SDK in `register()`, spans are emitted on both channels, so external collectors (Honeycomb, Jaeger, Datadog) keep working. `timber.cache` HIT/MISS **span events** are OTEL-only; the Workers span API has no event equivalent.
170
+
171
+ On runtimes without the custom spans API (older compatibility dates or an outdated local `wrangler`), the worker silently falls back to OTEL-only emission.
172
+
147
173
  ## Server-Timing
148
174
 
149
175
  timber.js can emit `Server-Timing` headers for browser DevTools performance inspection.
@@ -60,6 +60,8 @@ node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
60
60
 
61
61
  ## Redirect Safety
62
62
 
63
+ Open redirect vulnerabilities let an attacker craft a URL on your domain that redirects users to a malicious site (e.g., `/login?next=https://evil.com`). timber prevents this structurally — `redirect()` cannot produce an external redirect.
64
+
63
65
  ### `redirect()` is relative-only
64
66
 
65
67
  The `redirect()` function only accepts relative paths. Absolute URLs and protocol-relative URLs are rejected:
@@ -72,11 +74,11 @@ redirect('https://evil.com'); // ❌ throws — absolute URL
72
74
  redirect('//evil.com'); // ❌ throws — protocol-relative
73
75
  ```
74
76
 
75
- This prevents open redirect vulnerabilities where an attacker tricks your app into redirecting users to a malicious site.
77
+ This applies everywhere — middleware, access checks, server actions, and server components. There is no code path where `redirect()` can send users off-site.
76
78
 
77
79
  ### `redirectExternal()` with allow-list
78
80
 
79
- When you need to redirect to an external URL, use `redirectExternal()` with an explicit origin allow-list. Only `http:` and `https:` schemes are permitted:
81
+ When you legitimately need to redirect to an external URL (e.g., an OAuth provider), use `redirectExternal()` with an explicit origin allow-list. Only `http:` and `https:` schemes are permitted:
80
82
 
81
83
  ```ts
82
84
  import { redirectExternal } from '@timber-js/app/server';
@@ -89,6 +91,10 @@ redirectExternal('https://docs.example.com/guide', ['https://docs.example.com'],
89
91
 
90
92
  If the target origin isn't in the allow-list, the scheme isn't http(s), or the URL is invalid, the call throws. This gives you external redirects without the open redirect risk.
91
93
 
94
+ <Callout variant="tip" title="vs. Next.js">
95
+ Next.js `redirect()` accepts absolute URLs by default. In timber, external redirects require an explicit allow-list — open redirect bugs are structurally impossible.
96
+ </Callout>
97
+
92
98
  ## Header Immutability
93
99
 
94
100
  Request headers are immutable after the request enters the pipeline:
@@ -0,0 +1,134 @@
1
+ ---
2
+ title: 'Developer Experience'
3
+ description: 'Dev logging, hydration diffs, Server-Timing headers, build reports, and more.'
4
+ slug: 'developer-experience'
5
+ ---
6
+
7
+ # Developer Experience
8
+
9
+ timber.js includes a suite of development tools that surface what your app is doing — where time is spent, what's cached, where errors come from — without reaching for external profiling tools.
10
+
11
+ ## Dev Logging
12
+
13
+ Every request in development emits a structured tree to `stderr` showing the full execution pipeline with timing:
14
+
15
+ ```
16
+ POST /dashboard/projects/123 trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
17
+ ├─ [proxy] proxy.ts 0ms → 2ms
18
+ ├─ [rsc] middleware.ts 2ms → 4ms
19
+ │ ├── fired: requireUser() (timber.cache prefetch)
20
+ │ ├── fired: getProject("123") (timber.cache prefetch)
21
+ │ └── fired: getTaskCounts("123") (timber.cache prefetch)
22
+ ├─ [rsc] render 4ms
23
+ │ ├─ [rsc] AccessGate (authenticated) 4ms → 5ms
24
+ │ │ └── requireUser() timber.cache HIT <1ms
25
+ │ ├─ [rsc] ProjectPage 8ms → 12ms
26
+ │ │ └── getTaskCounts("123") timber.cache HIT <1ms
27
+ │ └── onShellReady 12ms
28
+ ├─ [ssr] hydration render 13ms → 18ms
29
+ └─ ✓ 200 OK total 18ms
30
+ ```
31
+
32
+ The tree mirrors the execution structure. `[rsc]`/`[ssr]`/`[client]` labels show which Vite environment each phase runs in. Cache hits and misses are annotated inline.
33
+
34
+ ### Fetch Instrumentation
35
+
36
+ `fetch()` calls from server components appear as children of the component that made them:
37
+
38
+ ```
39
+ ├─ [rsc] page / 6ms → 101ms
40
+ │ ├─ fetch GET https://api.example.com/products 12ms → 89ms (77ms)
41
+ │ └─ fetch GET https://api.example.com/user 12ms → 45ms (33ms) [cdn: HIT]
42
+ ```
43
+
44
+ Start times reveal whether fetches ran concurrently or sequentially. Cache status from `X-Cache` or `CF-Cache-Status` headers is surfaced automatically. Fetch instrumentation is dev-only — `globalThis.fetch` is not patched in production.
45
+
46
+ ### Log Modes
47
+
48
+ Control the verbosity with `TIMBER_DEV_LOG`:
49
+
50
+ | Mode | Output |
51
+ | --------- | ---------------------------------------------------------- |
52
+ | `tree` | Full indented tree per request (default) |
53
+ | `verbose` | Detailed tree showing every component render |
54
+ | `summary` | One line per request: `POST /path → 200 OK 18ms` |
55
+ | `json` | Chronological NDJSON of all spans with full attributes |
56
+
57
+ Suppress all dev logging with `TIMBER_DEV_QUIET=1`.
58
+
59
+ ### Slow Phase Warnings
60
+
61
+ Phases that exceed a configurable threshold are highlighted in the tree output. The default is 200ms:
62
+
63
+ ```ts title="timber.config.ts"
64
+ export default {
65
+ dev: {
66
+ slowPhaseMs: 200,
67
+ },
68
+ };
69
+ ```
70
+
71
+ This surfaces performance bottlenecks during development without requiring explicit profiling.
72
+
73
+ ## Server-Timing Header
74
+
75
+ timber.js emits a `Server-Timing` HTTP header for every response. The level of detail is configurable:
76
+
77
+ | Value | Output | Default in |
78
+ | ------------ | -------------------------------------------------------- | ----------- |
79
+ | `'detailed'` | Per-phase breakdown: proxy, middleware, render, SSR | Development |
80
+ | `'total'` | Single `total;dur=N` entry | Production |
81
+ | `false` | No header | — |
82
+
83
+ ```ts title="timber.config.ts"
84
+ export default {
85
+ serverTiming: 'detailed',
86
+ };
87
+ ```
88
+
89
+ In production, you can opt into `'detailed'` for APM integration — browser DevTools and monitoring tools parse `Server-Timing` automatically.
90
+
91
+ ## Hydration Mismatch Diff
92
+
93
+ When React 19 detects a hydration mismatch, timber parses the diff and renders it with color-coded lines:
94
+
95
+ - Green (`+`) lines show the client-side markup
96
+ - Red (`-`) lines show the server-side markup
97
+
98
+ This replaces Vite's plain-text rendering of hydration errors, making mismatches easy to spot and fix. Multiple hydration errors accumulate in the same overlay.
99
+
100
+ ## Error Overlay
101
+
102
+ Errors during development are shown in a browser overlay with:
103
+
104
+ - **Component stacks** — the React component hierarchy leading to the error
105
+ - **Phase labels** — whether the error occurred in middleware, access check, RSC render, SSR, or a client component
106
+ - **Source-mapped stack traces** — pointing to your original source files
107
+
108
+ Client-side errors are forwarded to the overlay via Vite's HMR channel, so they get the same treatment as server errors.
109
+
110
+ ## Compiling Indicator
111
+
112
+ A small "Compiling…" indicator appears in the bottom-left corner during HMR updates. It has a 200ms debounce — fast updates never show it. It fades out when the update completes.
113
+
114
+ ## Build Report
115
+
116
+ After `timber build`, a route table shows every route with its bundle size, route type, and first-load JS:
117
+
118
+ | Symbol | Meaning |
119
+ | ------ | -------------- |
120
+ | ○ | Static page |
121
+ | λ | Dynamic (SSR) |
122
+ | ƒ | API route |
123
+
124
+ This helps identify large bundles and verify route classification.
125
+
126
+ ## HTTPS / HTTP-2 Dev Server
127
+
128
+ timber supports `server.https` in your Vite config for local HTTPS development. The request scheme is derived from the TLS socket (not hardcoded), so CSRF protection works correctly under HTTPS. HTTP/2 pseudo-headers are handled automatically.
129
+
130
+ ## What's Next
131
+
132
+ - [Instrumentation](/docs/instrumentation) — production tracing with OpenTelemetry
133
+ - [Configuration](/docs/configuration) — all config options
134
+ - [Streaming](/docs/streaming) — Suspense placement and `deferSuspenseFor`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.157",
3
+ "version": "0.2.0-alpha.159",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -68,6 +68,34 @@
68
68
  "types": "./dist/cache/index.d.ts",
69
69
  "import": "./dist/cache/index.js"
70
70
  },
71
+ "./cache/stores/memory": {
72
+ "types": "./dist/cache/stores/memory.d.ts",
73
+ "import": "./dist/cache/stores/memory.js"
74
+ },
75
+ "./cache/stores/redis": {
76
+ "types": "./dist/cache/stores/redis.d.ts",
77
+ "import": "./dist/cache/stores/redis.js"
78
+ },
79
+ "./cache/stores/cloudflare-kv": {
80
+ "types": "./dist/cache/stores/cloudflare-kv.d.ts",
81
+ "import": "./dist/cache/stores/cloudflare-kv.js"
82
+ },
83
+ "./cache/stores/vercel": {
84
+ "types": "./dist/cache/stores/vercel.d.ts",
85
+ "import": "./dist/cache/stores/vercel.js"
86
+ },
87
+ "./cdn": {
88
+ "types": "./dist/cdn/index.d.ts",
89
+ "import": "./dist/cdn/index.js"
90
+ },
91
+ "./cdn/cloudflare": {
92
+ "types": "./dist/cdn/cloudflare-purge.d.ts",
93
+ "import": "./dist/cdn/cloudflare-purge.js"
94
+ },
95
+ "./cdn/fastly": {
96
+ "types": "./dist/cdn/fastly-purge.d.ts",
97
+ "import": "./dist/cdn/fastly-purge.js"
98
+ },
71
99
  "./codec": {
72
100
  "types": "./dist/codec.d.ts",
73
101
  "import": "./dist/codec.js"
@@ -121,6 +149,7 @@
121
149
  "@opentelemetry/api": "^1.9.1",
122
150
  "@opentelemetry/context-async-hooks": "^2.8.0",
123
151
  "@opentelemetry/sdk-trace-base": "^2.8.0",
152
+ "cookie": "^1.1.1",
124
153
  "magic-string": "^0.30.21",
125
154
  "nitro": "3.0.260610-beta"
126
155
  },
@@ -134,7 +163,7 @@
134
163
  "nuqs": "^2.0.0",
135
164
  "react": "19.2.7",
136
165
  "react-dom": "19.2.7",
137
- "vite": "^8.0.16",
166
+ "vite": "^8.1.0",
138
167
  "wrangler": "^4.0.0",
139
168
  "zod": "^3.22.0 || ^4.0.0"
140
169
  },
@@ -89,6 +89,16 @@ export async function runSharedBuildSteps(opts: BuildOutputBaseOptions): Promise
89
89
  }
90
90
  );
91
91
 
92
+ // 4b. Copy prebuilt/ flight payloads if present (cache.component artifacts).
93
+ // These are produced by the post-build capture pass (design/44) and read
94
+ // at request time via the adapter's PrebuiltPayloadSource.
95
+ await cp(join(buildDir, 'prebuilt'), join(outDir, 'prebuilt'), { recursive: true }).catch(
96
+ (e: NodeJS.ErrnoException) => {
97
+ // No prebuilt dir when the app has no cache.component calls
98
+ if (e.code !== 'ENOENT') throw e;
99
+ }
100
+ );
101
+
92
102
  // 5. Write manifest-init module if present.
93
103
  if (config.manifestInit) {
94
104
  await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);
@@ -3,7 +3,7 @@
3
3
  // Primary deployment target. Generates a Workers-compatible entry point
4
4
  // and wrangler.jsonc configuration. See design/11-platform.md §"Cloudflare Workers".
5
5
 
6
- import { writeFile } from 'node:fs/promises';
6
+ import { writeFile, readFile, cp, rm, readdir } from 'node:fs/promises';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { join, relative } from 'node:path';
9
9
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -228,6 +228,16 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): TimberPlatfo
228
228
  async buildOutput(config: TimberConfig, buildDir: string) {
229
229
  const outDir = join(buildDir, 'cloudflare');
230
230
 
231
+ // Clean stale prebuilt artifacts BEFORE the shared copy step.
232
+ // The shared step uses cp (merge, not replace), so old .flight files
233
+ // from removed/renamed components would persist and get picked up by
234
+ // find_additional_modules. Also clean static/prebuilt/ from pre-TIM-1162
235
+ // builds where prebuilt was copied into static assets.
236
+ await rm(join(outDir, 'prebuilt'), { recursive: true, force: true }).catch(() => {});
237
+ await rm(join(outDir, 'static', 'prebuilt'), { recursive: true, force: true }).catch(
238
+ () => {}
239
+ );
240
+
231
241
  // Steps 1–5: shared across all adapters (mkdir, copy client/rsc/ssr,
232
242
  // write _headers, write manifest-init).
233
243
  await runSharedBuildSteps({
@@ -256,13 +266,25 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): TimberPlatfo
256
266
  emptyOutDir: false,
257
267
  minify: false,
258
268
  rollupOptions: {
259
- external: [/^node:/],
269
+ external: [/^node:/, /^cloudflare:/],
260
270
  },
261
271
  },
262
272
  });
263
273
  hasWorkerHandlers = true;
264
274
  }
265
275
 
276
+ // Embed prebuilt flight payloads directly into the worker module
277
+ // (TIM-1162). Previously, prebuilt/ was copied into static assets and
278
+ // read via env.ASSETS.fetch() — but that made .flight files and
279
+ // manifest.json publicly fetchable, bypassing middleware and access
280
+ // gates. Now payloads are imported as native wrangler Data/Text modules
281
+ // from outDir/prebuilt/ (outside static/), keeping them server-side only.
282
+ const prebuiltData = await readPrebuiltData(buildDir);
283
+ await writeFile(
284
+ join(outDir, '_timber-prebuilt-source.js'),
285
+ generatePrebuiltSourceModule(prebuiltData)
286
+ );
287
+
266
288
  // Generate the Workers entry point
267
289
  const hasManifestInit = !!config.manifestInit;
268
290
  const workerEntry = generateWorkerEntry(outDir, outDir, hasManifestInit, hasWorkerHandlers);
@@ -320,6 +342,10 @@ export function generateWorkerEntry(
320
342
  // Do not edit — this file is regenerated on each build.
321
343
 
322
344
  import { AsyncLocalStorage } from 'node:async_hooks'
345
+ // Side-effect import: sets globalThis.__TIMBER_PREBUILT_SOURCE__ before the
346
+ // RSC handler evaluates. Must be a static import (not body code) so ESM
347
+ // evaluation order guarantees it runs before ./rsc/index.js.
348
+ import './_timber-prebuilt-source.js'
323
349
  ${manifestImport}import handler, { runWithWaitUntil } from '${rscEntryRelative}'
324
350
  ${handlersImport}
325
351
  // Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
@@ -336,8 +362,35 @@ if (!bindingsAls) {
336
362
  globalThis[ALS_KEY] = bindingsAls
337
363
  }
338
364
 
365
+ // Register timber's platform tracer so pipeline phase spans (timber.proxy,
366
+ // timber.middleware, timber.render, timber.access, timber.action, …) appear
367
+ // as native Cloudflare spans in the Observability dashboard, nested under
368
+ // the auto-instrumented fetch/KV/D1 spans. Requires observability.traces
369
+ // enabled in wrangler.jsonc (generated by default).
370
+ //
371
+ // Lazily imported and guarded: runtimes without the custom spans API
372
+ // (older compatibility dates, older local workerd) fall back to OTEL-only
373
+ // emission. withSpan() in the framework composes both channels.
374
+ const TRACER_KEY = Symbol.for('timber:platform-tracer')
375
+ let tracerInit
376
+ function initPlatformTracer() {
377
+ tracerInit ??= import('cloudflare:workers')
378
+ .then(({ tracing }) => {
379
+ if (tracing && typeof tracing.enterSpan === 'function') {
380
+ globalThis[TRACER_KEY] ??= {
381
+ enterSpan: (name, fn) => tracing.enterSpan(name, fn),
382
+ }
383
+ }
384
+ })
385
+ .catch(() => {})
386
+ return tracerInit
387
+ }
388
+
339
389
  export default {
340
390
  ${handlersSpread} async fetch(request, env, ctx) {
391
+ // One-time on first request; resolved-promise await thereafter.
392
+ await initPlatformTracer()
393
+
341
394
  // Edge-cache GET/HEAD responses via the Cache API. RSC navigation
342
395
  // requests (Accept: text/x-component) are excluded — they vary by
343
396
  // X-Timber-State-Tree which the cache key doesn't capture.
@@ -379,6 +432,99 @@ ${handlersSpread} async fetch(request, env, ctx) {
379
432
  `;
380
433
  }
381
434
 
435
+ /** Build-time prebuilt file list read from `.timber/dist/prebuilt/`. */
436
+ export interface PrebuiltModuleData {
437
+ /** Payload file paths relative to prebuilt/ (e.g. "comp_abc/deadbeef.flight"). */
438
+ payloadFiles: string[];
439
+ }
440
+
441
+ /**
442
+ * Discover prebuilt flight payload files from the build directory.
443
+ * Returns null when no prebuilt artifacts exist (no cache.component usage).
444
+ *
445
+ * @internal Exported for testing.
446
+ */
447
+ export async function readPrebuiltData(buildDir: string): Promise<PrebuiltModuleData | null> {
448
+ const prebuiltDir = join(buildDir, 'prebuilt');
449
+ try {
450
+ await readFile(join(prebuiltDir, 'manifest.json'));
451
+ } catch {
452
+ return null;
453
+ }
454
+ const payloadFiles: string[] = [];
455
+ try {
456
+ const componentDirs = await readdir(prebuiltDir, { withFileTypes: true });
457
+ for (const dir of componentDirs) {
458
+ if (!dir.isDirectory()) continue;
459
+ const files = await readdir(join(prebuiltDir, dir.name));
460
+ for (const file of files) {
461
+ if (file.endsWith('.flight')) {
462
+ payloadFiles.push(`${dir.name}/${file}`);
463
+ }
464
+ }
465
+ }
466
+ } catch {
467
+ // No subdirectories — manifest exists but no payloads
468
+ }
469
+ return { payloadFiles };
470
+ }
471
+
472
+ /**
473
+ * Generate the prebuilt payload source module for Cloudflare Workers.
474
+ *
475
+ * Imports prebuilt flight payloads as native wrangler Data/Text modules
476
+ * from the worker's module graph (NOT from static assets — TIM-1162).
477
+ * Files live at `outDir/prebuilt/` which is outside `static/` and thus
478
+ * not publicly fetchable. Wrangler discovers them via
479
+ * `find_additional_modules: true` with Data/Text rules.
480
+ *
481
+ * This is a separate ESM module so it evaluates before the RSC handler —
482
+ * static imports are hoisted, guaranteeing `globalThis.__TIMBER_PREBUILT_SOURCE__`
483
+ * is set before `createRequestHandler()` reads it.
484
+ *
485
+ * @internal Exported for testing.
486
+ */
487
+ export function generatePrebuiltSourceModule(data: PrebuiltModuleData | null): string {
488
+ if (!data) {
489
+ return `// Generated by @timber-js/app/adapters/cloudflare
490
+ // No prebuilt artifacts — all cache.component wrappers render dynamically.
491
+ globalThis.__TIMBER_PREBUILT_SOURCE__ = {
492
+ async loadManifest() { return null },
493
+ async readPayload() { return null },
494
+ }
495
+ `;
496
+ }
497
+
498
+ const imports: string[] = [];
499
+ const entries: string[] = [];
500
+
501
+ imports.push(`import _manifestText from './prebuilt/manifest.json'`);
502
+
503
+ for (let i = 0; i < data.payloadFiles.length; i++) {
504
+ const file = data.payloadFiles[i];
505
+ imports.push(`import _p${i} from './prebuilt/${file}'`);
506
+ entries.push(` ${JSON.stringify(file)}: new Uint8Array(_p${i})`);
507
+ }
508
+
509
+ return `// Generated by @timber-js/app/adapters/cloudflare
510
+ // Prebuilt payload source — imports cache.component flight payloads as
511
+ // native wrangler modules from the worker bundle (TIM-1162). Files live
512
+ // at outDir/prebuilt/, outside static/, so they are not publicly fetchable.
513
+ ${imports.join('\n')}
514
+
515
+ const _manifest = JSON.parse(_manifestText)
516
+
517
+ const _payloads = {
518
+ ${entries.join(',\n')}
519
+ }
520
+
521
+ globalThis.__TIMBER_PREBUILT_SOURCE__ = {
522
+ async loadManifest() { return _manifest },
523
+ async readPayload(file) { return _payloads[file] ?? null },
524
+ }
525
+ `;
526
+ }
527
+
382
528
  /** Wrangler binding sections generated from `CloudflareBindings`. */
383
529
  export interface WranglerBindingsConfig {
384
530
  kv_namespaces?: Array<{ binding: string; id: string }>;
@@ -517,6 +663,11 @@ export function generateWranglerConfig(
517
663
 
518
664
  const flags = options.compatibilityFlags ?? ['nodejs_compat'];
519
665
 
666
+ const PREBUILT_MODULE_RULES = [
667
+ { type: 'Text' as const, globs: ['prebuilt/**/*.json'] },
668
+ { type: 'Data' as const, globs: ['prebuilt/**/*.flight'] },
669
+ ];
670
+
520
671
  const base: Record<string, unknown> = {
521
672
  name: 'timber-app',
522
673
  main: '_worker.js',
@@ -526,9 +677,18 @@ export function generateWranglerConfig(
526
677
  // esbuild pass to avoid issues with top-level await and module format.
527
678
  no_bundle: true,
528
679
  find_additional_modules: true,
529
- rules: [{ type: 'ESModule', globs: ['**/*.js'] }],
680
+ rules: [{ type: 'ESModule', globs: ['**/*.js'] }, ...PREBUILT_MODULE_RULES],
530
681
  assets: {
531
682
  directory: './static',
683
+ binding: 'ASSETS',
684
+ },
685
+ // Native trace destination — required for timber's pipeline spans
686
+ // (emitted via tracing.enterSpan in the generated _worker.js) to appear
687
+ // in the Cloudflare Observability dashboard. Gates traces only; does
688
+ // not enable Workers Logs. Override via the `wrangler` escape hatch:
689
+ // wrangler: { observability: { traces: { enabled: false } } }.
690
+ observability: {
691
+ traces: { enabled: true },
532
692
  },
533
693
  };
534
694
 
@@ -542,7 +702,21 @@ export function generateWranglerConfig(
542
702
  // durable_objects.bindings. Deep merge preserves generated keys while
543
703
  // letting wrangler override on actual key-level conflicts.
544
704
  if (options.wrangler) {
545
- return shallowDeepMerge(merged, options.wrangler);
705
+ const result = shallowDeepMerge(merged, options.wrangler);
706
+ // Ensure required prebuilt module rules survive a user `rules` override.
707
+ // Without this, `wrangler: { rules: [...] }` replaces the entire array
708
+ // and drops the Text/Data rules that _timber-prebuilt-source.js needs.
709
+ if (options.wrangler.rules) {
710
+ const userRules = result.rules as Array<Record<string, unknown>>;
711
+ for (const required of PREBUILT_MODULE_RULES) {
712
+ const exists = userRules.some(
713
+ (r) =>
714
+ r.type === required.type && JSON.stringify(r.globs) === JSON.stringify(required.globs)
715
+ );
716
+ if (!exists) userRules.push(required);
717
+ }
718
+ }
719
+ return result;
546
720
  }
547
721
 
548
722
  return merged;
@@ -1,7 +1,10 @@
1
1
  import type { CacheOptions } from './index';
2
+ import type { PrebuiltComponentOptions } from '../server/prebuilt-runtime';
2
3
  import { createCache } from './timber-cache';
3
4
  import { getCacheHandler } from './handler-store';
4
5
  import { recordInvalidation } from './invalidation-epoch';
6
+ import { writeTombstonesForTag } from '../server/prebuilt/overlay.js';
7
+ import { getCdnPurgeHandler } from '../cdn/purge-store.js';
5
8
 
6
9
  /**
7
10
  * Public caching API: `cache(fn, opts)`.
@@ -44,4 +47,61 @@ cache.invalidate = async function invalidate(opts: { key?: string; tag?: string
44
47
  // post-resolve set instead of resurrecting the invalidated value (TIM-1028).
45
48
  recordInvalidation(opts);
46
49
  await getCacheHandler().invalidate(opts);
50
+ // Write tombstones for build-seed entries carrying the tag (design/45
51
+ // §ISR mechanics). Runs AFTER handler.invalidate so the tombstone
52
+ // overwrites the cleared overlay slot. This covers ALL invalidation
53
+ // paths — actions, webhooks, route handlers — not just executeAction.
54
+ if (opts.tag) {
55
+ await writeTombstonesForTag(opts.tag);
56
+ }
57
+ // Trigger CDN purge if a handler is configured (design/25 §Layer 2).
58
+ // Purge is best-effort: a CDN API failure or timeout must not block
59
+ // cache invalidation. 10s ceiling prevents a hung CDN API from stalling
60
+ // the entire revalidateTag/action flow.
61
+ if (opts.tag) {
62
+ const purgeHandler = getCdnPurgeHandler();
63
+ if (purgeHandler) {
64
+ try {
65
+ await Promise.race([
66
+ purgeHandler.purgeTags([opts.tag]),
67
+ new Promise<void>((_, reject) =>
68
+ setTimeout(() => reject(new Error('CDN purge timed out (10s)')), 10_000)
69
+ ),
70
+ ]);
71
+ } catch (err) {
72
+ console.error('[timber] CDN purge failed for tag:', err);
73
+ }
74
+ }
75
+ }
76
+ };
77
+
78
+ const componentFallbackWarned = new WeakSet<object>();
79
+
80
+ /**
81
+ * Runtime fallback for `cache.component(...)` callsites the timber-prebuilt
82
+ * transform does not rewrite — nested/computed/argument-position forms (see
83
+ * plugins/prebuilt.ts). Only module-scope `const X = cache.component(...)`
84
+ * declarations are transformed and eligible for prebuilding; everything else
85
+ * lands here, warns once per component, and renders dynamically. Same
86
+ * safety-net pattern as the data cache's runtime fnId fallback (TIM-1054):
87
+ * an untraceable callsite degrades in performance, never in correctness.
88
+ *
89
+ * The full public surface (types, docs, `timber` namespace) is TIM-1121;
90
+ * design/45-cache-lifetimes.md specifies the options.
91
+ */
92
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
+ cache.component = function component<C extends (props: any) => unknown>(
94
+ componentFn: C,
95
+ _options?: PrebuiltComponentOptions
96
+ ): C {
97
+ if (!componentFallbackWarned.has(componentFn)) {
98
+ componentFallbackWarned.add(componentFn);
99
+ const name = componentFn.name || 'anonymous component';
100
+ console.warn(
101
+ `[timber] cache.component: "${name}" was not transformed at build time — ` +
102
+ `rendering dynamically. Only module-scope declarations ` +
103
+ `(const X = cache.component(...)) are prebuilt.`
104
+ );
105
+ }
106
+ return componentFn;
47
107
  };