@timber-js/app 0.2.0-alpha.147 → 0.2.0-alpha.149

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 (277) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
  3. package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
  4. package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
  5. package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
  6. package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
  7. package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
  8. package/dist/_chunks/{cli-schema-sync-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
  9. package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
  10. package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
  11. package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
  12. package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
  13. package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
  14. package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
  15. package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
  16. package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
  17. package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
  18. package/dist/_chunks/logger-CM0clugc.js.map +1 -0
  19. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
  20. package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
  21. package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
  22. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
  23. package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
  24. package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
  25. package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
  26. package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
  27. package/dist/_chunks/tree-match-D2l830j2.js +102 -0
  28. package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
  29. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
  30. package/dist/_chunks/{walkers-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
  31. package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
  32. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  33. package/dist/adapters/cloudflare-kv-cache.js +36 -14
  34. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  35. package/dist/adapters/compress-module.d.ts +5 -4
  36. package/dist/adapters/compress-module.d.ts.map +1 -1
  37. package/dist/adapters/nitro.d.ts +1 -3
  38. package/dist/adapters/nitro.d.ts.map +1 -1
  39. package/dist/adapters/nitro.js +14 -134
  40. package/dist/adapters/nitro.js.map +1 -1
  41. package/dist/cache/cache-api.d.ts.map +1 -1
  42. package/dist/cache/index.d.ts +3 -1
  43. package/dist/cache/index.d.ts.map +1 -1
  44. package/dist/cache/index.js +20 -235
  45. package/dist/cache/index.js.map +1 -1
  46. package/dist/cache/invalidation-epoch.d.ts +43 -0
  47. package/dist/cache/invalidation-epoch.d.ts.map +1 -0
  48. package/dist/cache/redis-handler.d.ts +13 -0
  49. package/dist/cache/redis-handler.d.ts.map +1 -1
  50. package/dist/cache/timber-cache.d.ts +17 -9
  51. package/dist/cache/timber-cache.d.ts.map +1 -1
  52. package/dist/cli-schema-sync.d.ts +21 -1
  53. package/dist/cli-schema-sync.d.ts.map +1 -1
  54. package/dist/cli.d.ts +12 -0
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +26 -31
  57. package/dist/cli.js.map +1 -1
  58. package/dist/client/browser-entry/hmr.d.ts.map +1 -1
  59. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  60. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  61. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  62. package/dist/client/history.d.ts +10 -17
  63. package/dist/client/history.d.ts.map +1 -1
  64. package/dist/client/index.js +1 -1
  65. package/dist/client/index.js.map +1 -1
  66. package/dist/client/internal.js +30 -33
  67. package/dist/client/internal.js.map +1 -1
  68. package/dist/client/navigation-api.d.ts +9 -2
  69. package/dist/client/navigation-api.d.ts.map +1 -1
  70. package/dist/client/navigation-context.d.ts +0 -11
  71. package/dist/client/navigation-context.d.ts.map +1 -1
  72. package/dist/client/navigation-root.d.ts +0 -5
  73. package/dist/client/navigation-root.d.ts.map +1 -1
  74. package/dist/client/router.d.ts.map +1 -1
  75. package/dist/client/rsc-fetch.d.ts.map +1 -1
  76. package/dist/client/use-search-params.d.ts +10 -5
  77. package/dist/client/use-search-params.d.ts.map +1 -1
  78. package/dist/codec.js +1 -1
  79. package/dist/config-validation.d.ts.map +1 -1
  80. package/dist/cookies/define-cookie.d.ts.map +1 -1
  81. package/dist/cookies/index.js +2 -4
  82. package/dist/cookies/index.js.map +1 -1
  83. package/dist/dev-tools/dev-page-shell.d.ts +1 -0
  84. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
  85. package/dist/dev-tools/hydration-overlay.d.ts +11 -0
  86. package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
  87. package/dist/dev-tools/index.d.ts +1 -2
  88. package/dist/dev-tools/index.d.ts.map +1 -1
  89. package/dist/fonts/ast-walk.d.ts +68 -0
  90. package/dist/fonts/ast-walk.d.ts.map +1 -0
  91. package/dist/fonts/ast.d.ts +23 -11
  92. package/dist/fonts/ast.d.ts.map +1 -1
  93. package/dist/fonts/bundle.d.ts +3 -1
  94. package/dist/fonts/bundle.d.ts.map +1 -1
  95. package/dist/fonts/dev-middleware.d.ts +1 -1
  96. package/dist/fonts/dev-middleware.d.ts.map +1 -1
  97. package/dist/fonts/fallbacks.d.ts +0 -7
  98. package/dist/fonts/fallbacks.d.ts.map +1 -1
  99. package/dist/fonts/google.d.ts +4 -2
  100. package/dist/fonts/google.d.ts.map +1 -1
  101. package/dist/fonts/local.d.ts +3 -3
  102. package/dist/fonts/local.d.ts.map +1 -1
  103. package/dist/fonts/paths.d.ts +43 -0
  104. package/dist/fonts/paths.d.ts.map +1 -0
  105. package/dist/fonts/pipeline.d.ts +7 -21
  106. package/dist/fonts/pipeline.d.ts.map +1 -1
  107. package/dist/fonts/transform.d.ts.map +1 -1
  108. package/dist/fonts/virtual-modules.d.ts +1 -18
  109. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +610 -406
  112. package/dist/index.js.map +1 -1
  113. package/dist/params/index.js +1 -1
  114. package/dist/params/resolve-schema.d.ts.map +1 -1
  115. package/dist/plugin-context.d.ts +28 -5
  116. package/dist/plugin-context.d.ts.map +1 -1
  117. package/dist/plugins/cache.d.ts +13 -5
  118. package/dist/plugins/cache.d.ts.map +1 -1
  119. package/dist/plugins/dev-server.d.ts.map +1 -1
  120. package/dist/plugins/fonts.d.ts +1 -2
  121. package/dist/plugins/fonts.d.ts.map +1 -1
  122. package/dist/plugins/routing.d.ts.map +1 -1
  123. package/dist/plugins/server-bundle.d.ts.map +1 -1
  124. package/dist/routing/codegen.d.ts.map +1 -1
  125. package/dist/routing/convention-lint.d.ts.map +1 -1
  126. package/dist/routing/file-cache.d.ts +8 -0
  127. package/dist/routing/file-cache.d.ts.map +1 -0
  128. package/dist/routing/index.js +2 -2
  129. package/dist/routing/interception.d.ts +0 -5
  130. package/dist/routing/interception.d.ts.map +1 -1
  131. package/dist/routing/manifest-codegen.d.ts +32 -0
  132. package/dist/routing/manifest-codegen.d.ts.map +1 -0
  133. package/dist/routing/scanner.d.ts.map +1 -1
  134. package/dist/schema-bridge.d.ts +15 -0
  135. package/dist/schema-bridge.d.ts.map +1 -1
  136. package/dist/search-params/define.d.ts.map +1 -1
  137. package/dist/search-params/index.js +7 -3
  138. package/dist/search-params/index.js.map +1 -1
  139. package/dist/search-params/wrappers.d.ts.map +1 -1
  140. package/dist/segment-params/define.d.ts.map +1 -1
  141. package/dist/segment-params/index.js +1 -1
  142. package/dist/server/access-gate.d.ts.map +1 -1
  143. package/dist/server/actions.d.ts +0 -14
  144. package/dist/server/actions.d.ts.map +1 -1
  145. package/dist/server/build-manifest.d.ts +0 -29
  146. package/dist/server/build-manifest.d.ts.map +1 -1
  147. package/dist/server/canonicalize.d.ts +10 -0
  148. package/dist/server/canonicalize.d.ts.map +1 -1
  149. package/dist/server/deny-renderer.d.ts +1 -1
  150. package/dist/server/deny-renderer.d.ts.map +1 -1
  151. package/dist/server/html-injector-core.d.ts +8 -0
  152. package/dist/server/html-injector-core.d.ts.map +1 -1
  153. package/dist/server/html-injectors.d.ts.map +1 -1
  154. package/dist/server/index.js +2 -2
  155. package/dist/server/internal.d.ts +1 -5
  156. package/dist/server/internal.d.ts.map +1 -1
  157. package/dist/server/internal.js +12 -171
  158. package/dist/server/internal.js.map +1 -1
  159. package/dist/server/node-stream-transforms.d.ts +0 -11
  160. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  161. package/dist/server/render-utils.d.ts +25 -0
  162. package/dist/server/render-utils.d.ts.map +1 -0
  163. package/dist/server/route-element-builder.d.ts +11 -25
  164. package/dist/server/route-element-builder.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  166. package/dist/server/rsc-entry/helpers.d.ts +2 -18
  167. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  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/rsc-payload.d.ts.map +1 -1
  171. package/dist/server/ssr-render.d.ts.map +1 -1
  172. package/dist/shared/href-validation.d.ts.map +1 -1
  173. package/dist/shims/image.d.ts +15 -15
  174. package/package.json +7 -8
  175. package/src/adapters/cloudflare-kv-cache.ts +56 -19
  176. package/src/adapters/compress-module.ts +27 -118
  177. package/src/adapters/nitro.ts +3 -41
  178. package/src/adapters/raw-imports.d.ts +5 -0
  179. package/src/cache/cache-api.ts +9 -1
  180. package/src/cache/index.ts +5 -2
  181. package/src/cache/invalidation-epoch.ts +93 -0
  182. package/src/cache/redis-handler.ts +33 -2
  183. package/src/cache/timber-cache.ts +109 -53
  184. package/src/cli-schema-sync.ts +123 -30
  185. package/src/cli.ts +33 -37
  186. package/src/client/browser-entry/hmr.ts +0 -11
  187. package/src/client/browser-entry/hydrate.ts +39 -5
  188. package/src/client/browser-entry/post-hydration.ts +6 -15
  189. package/src/client/browser-entry/router-init.ts +98 -3
  190. package/src/client/history.ts +10 -24
  191. package/src/client/navigation-api.ts +14 -9
  192. package/src/client/navigation-context.ts +1 -21
  193. package/src/client/navigation-root.tsx +0 -8
  194. package/src/client/router.ts +18 -8
  195. package/src/client/rsc-fetch.ts +7 -2
  196. package/src/client/use-search-params.ts +10 -5
  197. package/src/config-validation.ts +33 -25
  198. package/src/cookies/define-cookie.ts +2 -18
  199. package/src/dev-tools/dev-page-shell.ts +6 -2
  200. package/src/dev-tools/hydration-overlay.ts +158 -0
  201. package/src/dev-tools/index.ts +3 -16
  202. package/src/fonts/ast-walk.ts +235 -0
  203. package/src/fonts/ast.ts +91 -249
  204. package/src/fonts/bundle.ts +16 -9
  205. package/src/fonts/dev-middleware.ts +16 -5
  206. package/src/fonts/fallbacks.ts +0 -23
  207. package/src/fonts/google.ts +7 -3
  208. package/src/fonts/local.ts +7 -6
  209. package/src/fonts/paths.ts +58 -0
  210. package/src/fonts/pipeline.ts +27 -48
  211. package/src/fonts/transform.ts +27 -0
  212. package/src/fonts/virtual-modules.ts +1 -57
  213. package/src/index.ts +5 -11
  214. package/src/params/resolve-schema.ts +2 -34
  215. package/src/plugin-context.ts +95 -17
  216. package/src/plugins/cache.ts +64 -17
  217. package/src/plugins/dev-server.ts +61 -9
  218. package/src/plugins/fonts.ts +21 -6
  219. package/src/plugins/routing.ts +5 -348
  220. package/src/plugins/server-bundle.ts +1 -0
  221. package/src/routing/codegen.ts +3 -2
  222. package/src/routing/convention-lint.ts +4 -3
  223. package/src/routing/file-cache.ts +23 -0
  224. package/src/routing/interception.ts +2 -21
  225. package/src/routing/manifest-codegen.ts +290 -0
  226. package/src/routing/scanner.ts +5 -17
  227. package/src/routing/schema-validation.ts +2 -2
  228. package/src/schema-bridge.ts +63 -2
  229. package/src/search-params/define.ts +34 -2
  230. package/src/search-params/wrappers.ts +21 -3
  231. package/src/segment-params/define.ts +2 -62
  232. package/src/server/access-gate.tsx +8 -0
  233. package/src/server/actions.ts +8 -31
  234. package/src/server/build-manifest.ts +0 -56
  235. package/src/server/canonicalize.ts +6 -3
  236. package/src/server/deny-renderer.ts +6 -0
  237. package/src/server/html-injector-core.ts +19 -0
  238. package/src/server/html-injectors.ts +14 -4
  239. package/src/server/internal.ts +1 -18
  240. package/src/server/node-stream-transforms.ts +5 -72
  241. package/src/server/render-utils.ts +31 -0
  242. package/src/server/route-element-builder.ts +77 -47
  243. package/src/server/rsc-entry/error-renderer.ts +1 -0
  244. package/src/server/rsc-entry/helpers.ts +6 -34
  245. package/src/server/rsc-entry/index.ts +11 -0
  246. package/src/server/rsc-entry/render-route.ts +7 -9
  247. package/src/server/rsc-entry/rsc-payload.ts +2 -1
  248. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  249. package/src/server/ssr-render.ts +1 -11
  250. package/src/shared/href-validation.ts +8 -0
  251. package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
  252. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
  253. package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
  254. package/dist/_chunks/define-DMVhsipD.js.map +0 -1
  255. package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
  256. package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
  257. package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
  258. package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
  259. package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
  260. package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
  261. package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
  262. package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
  263. package/dist/_chunks/walkers-Ck4Y45XN.js.map +0 -1
  264. package/dist/_chunks/warnings-CNPXaypM.js +0 -125
  265. package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
  266. package/dist/client/link-pending-store.d.ts +0 -65
  267. package/dist/client/link-pending-store.d.ts.map +0 -1
  268. package/dist/client/nav-link-store.d.ts +0 -27
  269. package/dist/client/nav-link-store.d.ts.map +0 -1
  270. package/dist/dev-tools/warnings.d.ts +0 -109
  271. package/dist/dev-tools/warnings.d.ts.map +0 -1
  272. package/dist/server/flush.d.ts +0 -74
  273. package/dist/server/flush.d.ts.map +0 -1
  274. package/src/client/link-pending-store.ts +0 -116
  275. package/src/client/nav-link-store.ts +0 -37
  276. package/src/dev-tools/warnings.ts +0 -238
  277. package/src/server/flush.ts +0 -183
@@ -200,7 +200,7 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
200
200
  // Write the compression helper module for runtime use.
201
201
  // See design/25-production-deployments.md — self-hosted deployments
202
202
  // need application-level compression (Cloudflare handles it at the edge).
203
- await writeFile(join(outDir, '_compress.mjs'), generateCompressModule());
203
+ await writeFile(join(outDir, '_compress.mjs'), await generateCompressModule());
204
204
 
205
205
  // Prepend the manifest assignment directly into the RSC entry so
206
206
  // globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.
@@ -257,8 +257,7 @@ export function generateNitroEntry(
257
257
  buildDir: string,
258
258
  outDir: string,
259
259
  preset: NitroPreset,
260
- compress = true,
261
- hasManifestInit = false
260
+ compress = true
262
261
  ): string {
263
262
  // The RSC entry is the main request handler — it exports the fetch handler as default.
264
263
  // rsc/ is copied into the nitro dir so the import is local.
@@ -315,11 +314,6 @@ export function generateNitroEntry(
315
314
  handlerCall = ` const webResponse = await handler(webRequest)`;
316
315
  }
317
316
 
318
- // Build manifest init must be imported before the handler so that
319
- // globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.
320
- // ESM guarantees imports are evaluated in order.
321
- const manifestImport = hasManifestInit ? "import './_timber-manifest-init.js'\n" : '';
322
-
323
317
  // Import runWithWaitUntil only when the preset supports it.
324
318
  const waitUntilImport = supportsWaitUntil ? ', runWithWaitUntil' : '';
325
319
 
@@ -329,7 +323,7 @@ export function generateNitroEntry(
329
323
  return `// Generated by @timber-js/app/adapters/nitro
330
324
  // Do not edit — this file is regenerated on each build.
331
325
 
332
- ${manifestImport}import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
326
+ import handler, { runWithEarlyHintsSender${waitUntilImport} } from '${serverEntryRelative}'
333
327
  ${compressImport}
334
328
  // Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
335
329
  // See design/25-production-deployments.md §"TIMBER_RUNTIME".
@@ -349,38 +343,6 @@ ${handlerCall}
349
343
  `;
350
344
  }
351
345
 
352
- /** @internal Exported for testing. */
353
- export function generateNitroConfig(
354
- preset: NitroPreset,
355
- userConfig?: Record<string, unknown>
356
- ): string {
357
- const presetConfig = PRESET_CONFIGS[preset];
358
-
359
- const config: Record<string, unknown> = {
360
- preset: presetConfig.nitroPreset,
361
- entry: './entry.ts',
362
- output: { dir: presetConfig.outputDir },
363
- // Static asset cache headers — hashed assets are immutable, others get 1h.
364
- // See design/25-production-deployments.md §"CDN / Edge Cache"
365
- routeRules: {
366
- '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
367
- '/_timber/fonts/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
368
- },
369
- ...presetConfig.extraConfig,
370
- ...userConfig,
371
- };
372
-
373
- const configJson = JSON.stringify(config, null, 2);
374
-
375
- return `// Generated by @timber-js/app/adapters/nitro
376
- // Do not edit — this file is regenerated on each build.
377
-
378
- import { defineNitroConfig } from 'nitro/config'
379
-
380
- export default defineNitroConfig(${configJson})
381
- `;
382
- }
383
-
384
346
  // ─── Preview ─────────────────────────────────────────────────────────────────
385
347
 
386
348
  /** Presets that produce a locally-runnable server entry. */
@@ -0,0 +1,5 @@
1
+ // Vite ?raw import — embeds a file as a string at build time.
2
+ declare module '*?raw' {
3
+ const content: string;
4
+ export default content;
5
+ }
@@ -1,6 +1,7 @@
1
1
  import type { CacheOptions } from './index';
2
2
  import { createCache } from './timber-cache';
3
3
  import { getCacheHandler } from './handler-store';
4
+ import { recordInvalidation } from './invalidation-epoch';
4
5
 
5
6
  /**
6
7
  * Public caching API: `cache(fn, opts)`.
@@ -23,7 +24,11 @@ export function cache<Fn extends (...args: any[]) => Promise<any>>(
23
24
  opts: CacheOptions<Fn>,
24
25
  stableId?: string
25
26
  ): Fn {
26
- return createCache(fn, opts, getCacheHandler(), stableId);
27
+ // Pass the getter, not a resolved handler: the wrapper re-resolves it on
28
+ // every call, so module-scope wrappers created before the framework's boot
29
+ // wiring calls setCacheHandler() still pick up the configured handler
30
+ // instead of permanently binding the in-memory fallback (TIM-1029).
31
+ return createCache(fn, opts, getCacheHandler, stableId);
27
32
  }
28
33
 
29
34
  /**
@@ -35,5 +40,8 @@ export function cache<Fn extends (...args: any[]) => Promise<any>>(
35
40
  * ```
36
41
  */
37
42
  cache.invalidate = async function invalidate(opts: { key?: string; tag?: string }): Promise<void> {
43
+ // Record before the handler delete so a cached fn in flight skips its
44
+ // post-resolve set instead of resurrecting the invalidated value (TIM-1028).
45
+ recordInvalidation(opts);
38
46
  await getCacheHandler().invalidate(opts);
39
47
  };
@@ -42,12 +42,14 @@ export class MemoryCacheHandler implements CacheHandler {
42
42
  private maxBytes: number | undefined;
43
43
  private maxEntryBytes: number | undefined;
44
44
  private currentBytes = 0;
45
+ private readonly _trackBytes: boolean;
45
46
 
46
47
  constructor(opts?: MemoryCacheHandlerOptions) {
47
48
  // maxEntries takes precedence over deprecated maxSize
48
49
  this.maxEntries = opts?.maxEntries ?? opts?.maxSize ?? 1000;
49
50
  this.maxBytes = opts?.maxBytes;
50
51
  this.maxEntryBytes = opts?.maxEntryBytes;
52
+ this._trackBytes = this.maxBytes !== undefined || this.maxEntryBytes !== undefined;
51
53
  }
52
54
 
53
55
  async get(key: string) {
@@ -63,7 +65,7 @@ export class MemoryCacheHandler implements CacheHandler {
63
65
  }
64
66
 
65
67
  async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {
66
- const byteSize = estimateByteSize(value);
68
+ const byteSize = this._trackBytes ? estimateByteSize(value) : 0;
67
69
 
68
70
  // Reject entries exceeding per-entry byte limit
69
71
  if (this.maxEntryBytes !== undefined && byteSize > this.maxEntryBytes) {
@@ -125,7 +127,7 @@ export class MemoryCacheHandler implements CacheHandler {
125
127
  return this.store.size;
126
128
  }
127
129
 
128
- /** Estimated total byte size of all cached values. */
130
+ /** Estimated total byte size of all cached values. Only tracked when maxBytes or maxEntryBytes is configured; returns 0 otherwise. */
129
131
  get bytes(): number {
130
132
  return this.currentBytes;
131
133
  }
@@ -145,6 +147,7 @@ export { RedisCacheHandler } from './redis-handler';
145
147
  export type { RedisClient } from './redis-handler';
146
148
  export { cache } from './cache-api';
147
149
  export { setCacheHandler, getCacheHandler } from './handler-store';
150
+ export { setDefaultSingleflightTimeout } from './timber-cache';
148
151
  // NOTE: registerCachedFunction (runtime for 'use cache' directive) removed.
149
152
  // Future feature pending design doc. See design/06-caching.md.
150
153
  export { estimateByteSize } from './sizeof';
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Per-process invalidation epoch tracking for timber.cache (TIM-1028).
3
+ *
4
+ * handler.set for a cache miss runs after `fn()` resolves. An explicit
5
+ * `cache.invalidate({ tag })` / `revalidateTag` that lands while fn is in
6
+ * flight would otherwise be silently undone — the just-invalidated value
7
+ * re-stored as fresh for the full TTL, resurrecting pre-mutation data.
8
+ *
9
+ * Every invalidation bumps a monotonic epoch and records it against the
10
+ * invalidated key/tag. Before storing a result, the cache wrapper checks
11
+ * whether its key or any of its tags were invalidated after fn started and
12
+ * skips the write if so. Skipping is always safe: the only cost is one
13
+ * extra cache miss on the next call.
14
+ *
15
+ * Scope: per-process, like the singleflight map. An invalidation issued by
16
+ * another instance against a shared handler (Redis/KV) during fn flight is
17
+ * not observable here — closing that window fully would require store-level
18
+ * versioning, which no CacheHandler backend offers.
19
+ */
20
+
21
+ let epoch = 0;
22
+
23
+ /**
24
+ * Last-invalidated epoch per `key:<k>` / `tag:<t>` entry. Insertion order is
25
+ * epoch order (entries are re-inserted on update), so the oldest entry is
26
+ * always first — pruning evicts lowest epochs.
27
+ */
28
+ const lastInvalidated = new Map<string, number>();
29
+
30
+ /**
31
+ * Bound on tracked entries so high-cardinality tag invalidation (e.g.
32
+ * per-user tags) cannot grow this map without limit.
33
+ */
34
+ const MAX_TRACKED_ENTRIES = 10_000;
35
+
36
+ /**
37
+ * Highest epoch evicted from the map. Any execution that started at or
38
+ * before this epoch conservatively counts as invalidated — history was
39
+ * truncated, so we can no longer prove it wasn't.
40
+ */
41
+ let evictedEpochFloor = 0;
42
+
43
+ /** The current epoch. Capture before running fn(), pass to wasInvalidatedSince. */
44
+ export function currentInvalidationEpoch(): number {
45
+ return epoch;
46
+ }
47
+
48
+ /**
49
+ * Record an invalidation. Call BEFORE handler.invalidate() so an execution
50
+ * whose set-check races the invalidation errs toward skipping the write.
51
+ */
52
+ export function recordInvalidation(opts: { key?: string; tag?: string }): void {
53
+ epoch++;
54
+ if (opts.key) track(`key:${opts.key}`);
55
+ if (opts.tag) track(`tag:${opts.tag}`);
56
+ }
57
+
58
+ function track(entry: string): void {
59
+ lastInvalidated.delete(entry);
60
+ lastInvalidated.set(entry, epoch);
61
+ while (lastInvalidated.size > MAX_TRACKED_ENTRIES) {
62
+ const oldest = lastInvalidated.entries().next().value;
63
+ if (oldest === undefined) break;
64
+ evictedEpochFloor = Math.max(evictedEpochFloor, oldest[1]);
65
+ lastInvalidated.delete(oldest[0]);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Whether the key or any tag was invalidated after `startEpoch` (the epoch
71
+ * captured when fn started executing).
72
+ */
73
+ export function wasInvalidatedSince(startEpoch: number, key: string, tags: string[]): boolean {
74
+ return lastInvalidationEpochFor(key, tags) > startEpoch;
75
+ }
76
+
77
+ /**
78
+ * The highest epoch at which the key or any of the tags was invalidated
79
+ * (0 if never). Used to scope the singleflight key: callers arriving after
80
+ * an invalidation must not coalesce onto a flight that started before it —
81
+ * they would receive the pre-invalidation result the invalidation was
82
+ * issued to discard.
83
+ */
84
+ export function lastInvalidationEpochFor(key: string, tags: string[]): number {
85
+ let last = evictedEpochFloor;
86
+ const keyEpoch = lastInvalidated.get(`key:${key}`);
87
+ if (keyEpoch !== undefined && keyEpoch > last) last = keyEpoch;
88
+ for (const tag of tags) {
89
+ const tagEpoch = lastInvalidated.get(`tag:${tag}`);
90
+ if (tagEpoch !== undefined && tagEpoch > last) last = tagEpoch;
91
+ }
92
+ return last;
93
+ }
@@ -23,7 +23,9 @@ import type { CacheHandler } from './index';
23
23
  * expire: (k, s) => redis.expire(k, s),
24
24
  * del: (k) => redis.del(k),
25
25
  * sadd: (k, ...m) => redis.sadd(k, ...m),
26
+ * srem: (k, ...m) => redis.srem(k, ...m),
26
27
  * smembers: (k) => redis.smembers(k),
28
+ * ttl: (k) => redis.ttl(k),
27
29
  * };
28
30
  * ```
29
31
  *
@@ -38,7 +40,9 @@ import type { CacheHandler } from './index';
38
40
  * expire: (k, s) => redis.expire(k, s),
39
41
  * del: (k) => redis.del(k),
40
42
  * sadd: (k, ...m) => redis.sAdd(k, m),
43
+ * srem: (k, ...m) => redis.sRem(k, m),
41
44
  * smembers: (k) => redis.sMembers(k),
45
+ * ttl: (k) => redis.ttl(k),
42
46
  * };
43
47
  * ```
44
48
  *
@@ -54,7 +58,9 @@ import type { CacheHandler } from './index';
54
58
  * expire: (k, s) => redis.expire(k, s),
55
59
  * del: (k) => redis.del(k),
56
60
  * sadd: (k, ...m) => redis.sadd(k, m[0], ...m.slice(1)),
61
+ * srem: (k, ...m) => redis.srem(k, m[0], ...m.slice(1)),
57
62
  * smembers: (k) => redis.smembers(k),
63
+ * ttl: (k) => redis.ttl(k),
58
64
  * };
59
65
  * ```
60
66
  */
@@ -72,8 +78,15 @@ export interface RedisClient {
72
78
  del(key: string): Promise<unknown>;
73
79
  /** `SADD key member [member ...]` — add members to a set. */
74
80
  sadd(key: string, ...members: string[]): Promise<unknown>;
81
+ /** `SREM key member [member ...]` — remove members from a set. */
82
+ srem(key: string, ...members: string[]): Promise<unknown>;
75
83
  /** `SMEMBERS key` — all members of a set. */
76
84
  smembers(key: string): Promise<string[]>;
85
+ /**
86
+ * `TTL key` — remaining time-to-live in seconds. Returns -2 if the key does
87
+ * not exist, -1 if the key exists but has no expiry.
88
+ */
89
+ ttl(key: string): Promise<number>;
77
90
  }
78
91
 
79
92
  const KEY_PREFIX = 'timber:cache:';
@@ -119,7 +132,7 @@ export class RedisCacheHandler implements CacheHandler {
119
132
  async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
120
133
  const ck = this.cacheKey(key);
121
134
  const expiresAt = Date.now() + opts.ttl * 1000;
122
- const payload = JSON.stringify({ value, expiresAt });
135
+ const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
123
136
 
124
137
  // Redis TTL with generous margin beyond the logical TTL to allow SWR reads
125
138
  // on stale entries. The logical staleness is determined by expiresAt.
@@ -128,14 +141,32 @@ export class RedisCacheHandler implements CacheHandler {
128
141
  const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
129
142
  await this.client.set(ck, payload, redisTtlSeconds);
130
143
 
131
- // Track key membership in each tag set
144
+ // Track key membership in each tag set, and expire the set so it doesn't
145
+ // outlive all its members. Only extend the TTL, never shorten — a short-TTL
146
+ // entry must not shrink a tag set that already contains long-TTL entries,
147
+ // otherwise invalidate({tag}) would miss the long-lived entries.
132
148
  for (const tag of opts.tags) {
133
149
  await this.client.sadd(this.tagKey(tag), key);
150
+ const currentTtl = await this.client.ttl(this.tagKey(tag));
151
+ // Only extend, never shorten. -2 = key doesn't exist (rare race after
152
+ // sadd), -1 = no expiry (new set or legacy — set initial TTL), positive
153
+ // but shorter = extend. A longer current TTL means a longer-lived entry
154
+ // set it first — don't shorten.
155
+ if (currentTtl < redisTtlSeconds) {
156
+ await this.client.expire(this.tagKey(tag), redisTtlSeconds);
157
+ }
134
158
  }
135
159
  }
136
160
 
137
161
  async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
138
162
  if (opts.key) {
163
+ const raw = await this.client.get(this.cacheKey(opts.key));
164
+ if (raw !== null) {
165
+ const entry = JSON.parse(raw) as { tags?: string[] };
166
+ if (entry.tags) {
167
+ await Promise.all(entry.tags.map((tag) => this.client.srem(this.tagKey(tag), opts.key!)));
168
+ }
169
+ }
139
170
  await this.client.del(this.cacheKey(opts.key));
140
171
  }
141
172
 
@@ -1,12 +1,27 @@
1
1
  import type { CacheHandler, CacheOptions } from './index';
2
2
  import { stableStringify } from './stable-stringify';
3
3
  import { createSingleflight } from './singleflight';
4
+ import {
5
+ currentInvalidationEpoch,
6
+ lastInvalidationEpochFor,
7
+ wasInvalidatedSince,
8
+ } from './invalidation-epoch';
4
9
  import { addSpanEventSync } from '../server/tracing.js';
5
10
  import { logSwrRefetchFailed } from '../server/logger.js';
6
11
  import { getWaitUntil } from '../server/waituntil-bridge.js';
7
12
  import { fnv1aHash } from './fast-hash.js';
8
13
 
9
- const defaultSingleflight = createSingleflight();
14
+ let defaultSingleflight = createSingleflight();
15
+
16
+ /**
17
+ * Set the timeout for the module-level default singleflight.
18
+ * Called at framework boot with renderTimeoutMs from timber.config.ts so that
19
+ * cache calls without a per-call timeoutMs still get deadlock protection.
20
+ * See design/06-caching.md §"Singleflight Timeout — Deadlock Risk".
21
+ */
22
+ export function setDefaultSingleflightTimeout(timeoutMs: number): void {
23
+ defaultSingleflight = createSingleflight({ timeoutMs });
24
+ }
10
25
 
11
26
  /**
12
27
  * Generate a cache key from function identity and serialized args.
@@ -35,16 +50,59 @@ function resolveTags<Fn extends (...args: any[]) => any>(
35
50
  return opts.tags(...args);
36
51
  }
37
52
 
38
- let unstableIdCounter = 0;
39
- let hasWarnedUnstableId = false;
53
+ /** Per-process count of byte-identical fn sources seen by deriveFallbackFnId. */
54
+ const fallbackIdOccurrences = new Map<string, number>();
55
+ let hasWarnedFallbackId = false;
56
+
57
+ /**
58
+ * Derive a fallback fnId for callsites the timber-cache-transform Vite plugin
59
+ * could not statically detect (aliased bindings, user re-export wrappers).
60
+ *
61
+ * The base is fnv1a(fn.toString()) — deterministic across instances and cold
62
+ * starts of the same build, so two *different* functions can never swap fnIds
63
+ * by module-evaluation order (the TIM-997 poisoning scenario that the old
64
+ * process-local counter allowed; see TIM-1054). A per-process occurrence
65
+ * suffix disambiguates byte-identical duplicate sources. That residue is the
66
+ * one remaining hazard: closures produced by the same factory share source
67
+ * text but capture different values, and their occurrence order may differ
68
+ * across instances — such callsites need an explicit `key` option.
69
+ */
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ function deriveFallbackFnId(fn: (...args: any[]) => any): string {
72
+ const sourceHash = fnv1aHash(fn.toString());
73
+ const occurrence = fallbackIdOccurrences.get(sourceHash) ?? 0;
74
+ fallbackIdOccurrences.set(sourceHash, occurrence + 1);
75
+
76
+ if (!hasWarnedFallbackId && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {
77
+ hasWarnedFallbackId = true;
78
+ console.warn(
79
+ '[timber] cache() callsite has no build-time stable ID and no explicit `key` option — ' +
80
+ 'the call is not statically visible to the timber Vite plugin (e.g. an aliased ' +
81
+ 'binding like `const c = cache` or a re-export wrapper module). Falling back to a ' +
82
+ 'function-source hash for the cache key. With a shared CacheHandler (Redis/KV) this ' +
83
+ 'is safe for distinct functions, but byte-identical closures from a factory can swap ' +
84
+ 'cache entries across instances. Provide an explicit `key` option to make the cache ' +
85
+ 'key contract stable and auditable.'
86
+ );
87
+ }
88
+
89
+ return `timber-cache:fn:${sourceHash}:${occurrence}`;
90
+ }
40
91
 
41
92
  /**
42
93
  * Creates a cached wrapper around an async function.
43
94
  *
44
- * - SHA-256 default keys with normalized JSON args
45
- * - Singleflight: concurrent misses → single execution
95
+ * - FNV-1a default keys with normalized JSON args
96
+ * - Singleflight: concurrent misses → single execution AND single
97
+ * handler.set — the write happens inside the coalesced callback (TIM-1028)
98
+ * - Writes are skipped when the key/tags were invalidated while fn was in
99
+ * flight, so explicit invalidation is never silently undone (TIM-1028)
46
100
  * - SWR: serve stale immediately, background refetch
47
101
  * - Tags as string[] or function of args
102
+ * - The handler is resolved per invocation via `getHandler`, never captured
103
+ * at wrapper-creation time — module-scope cache() wrappers created before
104
+ * the framework's boot wiring calls setCacheHandler() still use the
105
+ * configured handler once it arrives (TIM-1029)
48
106
  * - No ALS dependency for correctness — cache() works outside a request
49
107
  * context. The waitUntil ALS bridge is read optionally so the SWR
50
108
  * background refetch survives handler return on Workers/Lambda.
@@ -56,44 +114,30 @@ let hasWarnedUnstableId = false;
56
114
  export function createCache<Fn extends (...args: any[]) => Promise<any>>(
57
115
  fn: Fn,
58
116
  opts: CacheOptions<Fn>,
59
- handler: CacheHandler,
117
+ getHandler: () => CacheHandler,
60
118
  stableId?: string
61
119
  ): Fn {
62
- let fnId: string;
63
- if (stableId) {
64
- fnId = stableId;
65
- } else if (opts.key) {
66
- // Explicit key function — fnId is only used for singleflight, not for
67
- // cache keys, so a counter is acceptable here.
68
- fnId = `timber-cache:${unstableIdCounter++}`;
69
- } else {
70
- fnId = `timber-cache:unstable:${unstableIdCounter++}`;
71
- if (
72
- !hasWarnedUnstableId &&
73
- typeof process !== 'undefined' &&
74
- process.env?.NODE_ENV !== 'test'
75
- ) {
76
- hasWarnedUnstableId = true;
77
- console.warn(
78
- '[timber] cache() called without a stable ID or explicit key function. ' +
79
- 'Cache keys will use a process-local counter which is unstable across ' +
80
- 'cold starts. Run through the timber Vite plugin or provide an explicit ' +
81
- '`key` option to fix this.'
82
- );
83
- }
84
- }
85
- const sf = opts.timeoutMs
86
- ? createSingleflight({ timeoutMs: opts.timeoutMs })
87
- : defaultSingleflight;
120
+ // With an explicit `key` option the fnId never reaches key generation, so
121
+ // no fallback derivation (or warning) is needed in that case.
122
+ const fnId = stableId ?? (opts.key ? undefined : deriveFallbackFnId(fn));
123
+
124
+ // Per-call timeoutMs gets a dedicated singleflight instance. Otherwise
125
+ // resolve defaultSingleflight per invocation (not captured at creation
126
+ // time) so wrappers created before boot pick up the timeout once
127
+ // setDefaultSingleflightTimeout is called (TIM-1032, same rationale as
128
+ // getCacheHandler for TIM-1029).
129
+ const perCallSf =
130
+ opts.timeoutMs !== undefined ? createSingleflight({ timeoutMs: opts.timeoutMs }) : undefined;
88
131
 
89
132
  // Cast to Fn to preserve the original function's generic call signature.
90
133
  // Without this, generic type parameters (e.g. <T> in apiFetch<T>) are
91
134
  // erased and callers lose type safety on the return type.
92
135
  return (async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {
93
- const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId, args);
136
+ const sf = perCallSf ?? defaultSingleflight;
137
+ const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId as string, args);
94
138
 
95
139
  const cacheStart = performance.now();
96
- const cached = await handler.get(key);
140
+ const cached = await getHandler().get(key);
97
141
 
98
142
  if (cached && !cached.stale) {
99
143
  // Record as OTEL span event on enclosing span (not a child span).
@@ -105,6 +149,30 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
105
149
  return cached.value as Awaited<ReturnType<Fn>>;
106
150
  }
107
151
 
152
+ // From here fn() will execute (SWR refetch or miss). Scope the
153
+ // singleflight key by the last invalidation epoch affecting this
154
+ // key/tags: a caller arriving after an invalidation must not coalesce
155
+ // onto a flight that started before it — it would receive the
156
+ // pre-invalidation result the invalidation was issued to discard.
157
+ // NUL separator: cannot collide with user-provided key strings.
158
+ const tags = resolveTags(opts, args);
159
+ const flightKey = `${key}\u0000${lastInvalidationEpochFor(key, tags)}`;
160
+
161
+ /**
162
+ * Run fn and store the result — exactly once per coalesced execution,
163
+ * inside the singleflight callback. The write is skipped when the key or
164
+ * any resolved tag was invalidated after fn started: storing would
165
+ * resurrect just-invalidated data as fresh for the full TTL.
166
+ */
167
+ const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {
168
+ const startEpoch = currentInvalidationEpoch();
169
+ const result = await fn(...args);
170
+ if (!wasInvalidatedSince(startEpoch, key, tags)) {
171
+ await getHandler().set(key, result, { ttl: opts.ttl, tags });
172
+ }
173
+ return result;
174
+ };
175
+
108
176
  if (cached && cached.stale && opts.staleWhileRevalidate) {
109
177
  // Record stale cache hit as OTEL span event (fire-and-forget).
110
178
  addSpanEventSync('timber.cache.hit', {
@@ -114,11 +182,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
114
182
  });
115
183
  // Serve stale immediately, trigger background refetch
116
184
  const refetch = sf
117
- .do(`swr:${key}`, async () => {
185
+ .do(`swr:${flightKey}`, async () => {
118
186
  try {
119
- const fresh = await fn(...args);
120
- const tags = resolveTags(opts, args);
121
- await handler.set(key, fresh, { ttl: opts.ttl, tags });
187
+ await executeAndStore();
122
188
  } catch (err) {
123
189
  // Failed refetch — stale entry continues to be served.
124
190
  logSwrRefetchFailed({ cacheKey: key, error: err });
@@ -137,10 +203,10 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
137
203
  return cached.value as Awaited<ReturnType<Fn>>;
138
204
  }
139
205
 
140
- // Cache miss (or stale without SWR) — execute with singleflight
141
- const result = await sf.do(key, () => fn(...args));
142
- const tags = resolveTags(opts, args);
143
- await handler.set(key, result, { ttl: opts.ttl, tags });
206
+ // Cache miss (or stale without SWR) — execute with singleflight.
207
+ // The handler.set lives inside the coalesced callback, so N concurrent
208
+ // misses produce exactly one write (TIM-1028).
209
+ const result = await sf.do(flightKey, executeAndStore);
144
210
 
145
211
  // Record cache miss as OTEL span event (fire-and-forget).
146
212
  addSpanEventSync('timber.cache.miss', {
@@ -148,16 +214,6 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
148
214
  duration_ms: Math.round(performance.now() - cacheStart),
149
215
  });
150
216
 
151
- return result as Awaited<ReturnType<Fn>>;
217
+ return result;
152
218
  }) as unknown as Fn;
153
219
  }
154
-
155
- /**
156
- * Invalidate cache entries by tag or key.
157
- */
158
- createCache.invalidate = async function invalidate(
159
- handler: CacheHandler,
160
- opts: { key?: string; tag?: string }
161
- ): Promise<void> {
162
- await handler.invalidate(opts);
163
- };