@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
@@ -16,9 +16,17 @@ interface CacheHandlerLike {
16
16
 
17
17
  let handler: CacheHandlerLike | null = null;
18
18
 
19
+ let explicitlyConfigured = false;
20
+
19
21
  /** Replace the active cache handler. Called by the framework at boot. */
20
22
  export function setCacheHandler(h: CacheHandlerLike): void {
21
23
  handler = h;
24
+ explicitlyConfigured = true;
25
+ }
26
+
27
+ /** Whether the handler was explicitly configured (vs the default in-memory fallback). */
28
+ export function isCacheHandlerConfigured(): boolean {
29
+ return explicitlyConfigured;
22
30
  }
23
31
 
24
32
  /**
@@ -151,6 +151,10 @@ export { setDefaultSingleflightTimeout } from './timber-cache';
151
151
  // NOTE: registerCachedFunction (runtime for 'use cache' directive) removed.
152
152
  // Future feature pending design doc. See design/06-caching.md.
153
153
  export { estimateByteSize } from './sizeof';
154
+
155
+ // --- Cache Store layering (design/46) ---
156
+ export { TagAwareCacheHandler } from './tag-aware-handler';
157
+ export type { CacheStore } from './store';
154
158
  // stableStringify, createSingleflight, and SingleflightTimeoutError are
155
159
  // internal utilities used by the cache implementation. They are not
156
160
  // re-exported here — import directly from the source files if needed
@@ -20,7 +20,8 @@ import type { CacheHandler } from './index';
20
20
  * const client: RedisClient = {
21
21
  * get: (k) => redis.get(k),
22
22
  * set: (k, v, ttl) => redis.set(k, v, 'EX', ttl),
23
- * expire: (k, s) => redis.expire(k, s),
23
+ * expireNX: (k, s) => redis.expire(k, s, 'NX'),
24
+ * expireGT: (k, s) => redis.expire(k, s, 'GT'),
24
25
  * del: (k) => redis.del(k),
25
26
  * sadd: (k, ...m) => redis.sadd(k, ...m),
26
27
  * srem: (k, ...m) => redis.srem(k, ...m),
@@ -37,7 +38,8 @@ import type { CacheHandler } from './index';
37
38
  * const client: RedisClient = {
38
39
  * get: (k) => redis.get(k),
39
40
  * set: (k, v, ttl) => redis.set(k, v, { EX: ttl }),
40
- * expire: (k, s) => redis.expire(k, s),
41
+ * expireNX: (k, s) => redis.expire(k, s, 'NX'),
42
+ * expireGT: (k, s) => redis.expire(k, s, 'GT'),
41
43
  * del: (k) => redis.del(k),
42
44
  * sadd: (k, ...m) => redis.sAdd(k, m),
43
45
  * srem: (k, ...m) => redis.sRem(k, m),
@@ -55,7 +57,8 @@ import type { CacheHandler } from './index';
55
57
  * const client: RedisClient = {
56
58
  * get: (k) => redis.get(k),
57
59
  * set: (k, v, ttl) => redis.set(k, v, { ex: ttl }),
58
- * expire: (k, s) => redis.expire(k, s),
60
+ * expireNX: (k, s) => redis.expire(k, s, 'NX'),
61
+ * expireGT: (k, s) => redis.expire(k, s, 'GT'),
59
62
  * del: (k) => redis.del(k),
60
63
  * sadd: (k, ...m) => redis.sadd(k, m[0], ...m.slice(1)),
61
64
  * srem: (k, ...m) => redis.srem(k, m[0], ...m.slice(1)),
@@ -72,8 +75,17 @@ export interface RedisClient {
72
75
  * required positional argument so no adapter can forget it.
73
76
  */
74
77
  set(key: string, value: string, ttlSeconds: number): Promise<unknown>;
75
- /** `EXPIRE key seconds` — set/refresh a key's TTL (used for tag sets). */
76
- expire(key: string, seconds: number): Promise<unknown>;
78
+ /**
79
+ * `EXPIRE key seconds NX` — set the TTL only if the key has no existing
80
+ * expiry (Redis 7.0+). Used to set the initial TTL on new tag sets.
81
+ */
82
+ expireNX(key: string, seconds: number): Promise<unknown>;
83
+ /**
84
+ * `EXPIRE key seconds GT` — update the TTL only if the new value is greater
85
+ * than the current one (Redis 7.0+). Used to extend tag set TTLs without
86
+ * allowing a short-TTL writer to shrink them.
87
+ */
88
+ expireGT(key: string, seconds: number): Promise<unknown>;
77
89
  /** `DEL key` — delete a single key. Multi-key deletes loop per key. */
78
90
  del(key: string): Promise<unknown>;
79
91
  /** `SADD key member [member ...]` — add members to a set. */
@@ -147,14 +159,14 @@ export class RedisCacheHandler implements CacheHandler {
147
159
  // otherwise invalidate({tag}) would miss the long-lived entries.
148
160
  for (const tag of opts.tags) {
149
161
  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
- }
162
+ // Two atomic EXPIRE calls cover both the initial-set and extend cases:
163
+ // - NX: sets the TTL only if the key has no expiry (new tag set from SADD)
164
+ // - GT: extends the TTL only if the new value is greater than current
165
+ // Together they close the race where concurrent workers could shrink the
166
+ // tag set lifetime (TIM-1093), while ensuring new tag sets always get an
167
+ // initial expiry (without NX, GT is a no-op on non-volatile keys).
168
+ await this.client.expireNX(this.tagKey(tag), redisTtlSeconds);
169
+ await this.client.expireGT(this.tagKey(tag), redisTtlSeconds);
158
170
  }
159
171
  }
160
172
 
@@ -17,7 +17,7 @@ export interface SingleflightOptions {
17
17
  }
18
18
 
19
19
  export interface Singleflight {
20
- do<T>(key: string, fn: () => Promise<T>): Promise<T>;
20
+ do<T>(key: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T>;
21
21
  }
22
22
 
23
23
  /**
@@ -36,25 +36,25 @@ export function createSingleflight(opts?: SingleflightOptions): Singleflight {
36
36
  const timeoutMs = opts?.timeoutMs;
37
37
 
38
38
  return {
39
- do<T>(key: string, fn: () => Promise<T>): Promise<T> {
39
+ do<T>(key: string, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
40
40
  const existing = inflight.get(key);
41
41
  if (existing) return existing as Promise<T>;
42
42
 
43
+ const ac = new AbortController();
43
44
  let promise: Promise<T>;
44
45
 
45
46
  if (timeoutMs != null && timeoutMs > 0) {
46
47
  // Race fn() against a timeout to prevent hung calls from
47
48
  // permanently blocking the key. See TIM-518.
49
+ // Abort the signal on timeout so the callback can skip
50
+ // side effects (e.g. cache writes). See TIM-1092.
48
51
  promise = new Promise<T>((resolve, reject) => {
49
- const timer = setTimeout(
50
- () => reject(new SingleflightTimeoutError(key, timeoutMs)),
51
- timeoutMs
52
- );
53
- // Wrap in try/catch so a synchronous throw from fn()
54
- // (e.g. argument validation) still clears the timer.
55
- // Without this, the timer leaks until expiry.
52
+ const timer = setTimeout(() => {
53
+ ac.abort();
54
+ reject(new SingleflightTimeoutError(key, timeoutMs));
55
+ }, timeoutMs);
56
56
  try {
57
- fn().then(
57
+ fn(ac.signal).then(
58
58
  (value) => {
59
59
  clearTimeout(timer);
60
60
  resolve(value);
@@ -70,7 +70,7 @@ export function createSingleflight(opts?: SingleflightOptions): Singleflight {
70
70
  }
71
71
  });
72
72
  } else {
73
- promise = fn();
73
+ promise = fn(ac.signal);
74
74
  }
75
75
 
76
76
  const tracked = promise.finally(() => {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * CacheStore — the contract cache backends implement.
3
+ *
4
+ * Three tiers of tag capability, auto-detected by TagAwareCacheHandler:
5
+ *
6
+ * 1. **Native tags** (store implements setWithTags + invalidateTag): The store
7
+ * handles tag indexing and invalidation internally. The handler delegates
8
+ * directly. Examples: Vercel Runtime Cache, Durable Object–backed stores.
9
+ *
10
+ * 2. **Set primitives** (store implements sadd/smembers/srem/sdel): The handler
11
+ * maintains an inverted index using the store's atomic set operations.
12
+ * Eager invalidation — enumerate and delete. Example: Redis.
13
+ *
14
+ * 3. **Bare KV** (only get/set/del): The handler uses version-stamping. Entries
15
+ * record tag versions at write time; reads validate against current versions.
16
+ * Invalidation bumps a counter — O(1), race-free. Example: Cloudflare KV.
17
+ *
18
+ * Implementors pick their tier by which methods they provide. The handler
19
+ * selects the best available strategy automatically.
20
+ */
21
+
22
+ export interface CacheStore {
23
+ // --- Required: every store must implement these ---
24
+
25
+ /** Read a key. Returns null if absent or physically expired. */
26
+ get(key: string): Promise<string | null>;
27
+
28
+ /** Write a key with a physical TTL in seconds. Overwrites if exists. */
29
+ set(key: string, value: string, ttlSeconds: number): Promise<void>;
30
+
31
+ /** Delete a key. No-op if absent. */
32
+ del(key: string): Promise<void>;
33
+
34
+ // --- Tier 1: Native tag support ---
35
+ // If implemented, the handler delegates tag operations entirely to the store.
36
+
37
+ /**
38
+ * Write a value with associated tags. The store handles tag indexing
39
+ * internally. If provided, the handler will call this instead of plain
40
+ * set() for tagged entries.
41
+ */
42
+ setWithTags?(key: string, value: string, ttlSeconds: number, tags: string[]): Promise<void>;
43
+
44
+ /**
45
+ * Invalidate all entries associated with a tag. The store handles
46
+ * the lookup and deletion/marking internally.
47
+ */
48
+ invalidateTag?(tag: string): Promise<void>;
49
+
50
+ // --- Tier 2: Set primitives (enables eager inverted-index strategy) ---
51
+
52
+ /**
53
+ * Atomically add members to a named set and ensure the set has at least
54
+ * the given TTL.
55
+ */
56
+ sadd?(key: string, members: string[], ttlSeconds: number): Promise<void>;
57
+
58
+ /** Remove members from a named set. */
59
+ srem?(key: string, members: string[]): Promise<void>;
60
+
61
+ /** Return all members of a named set. */
62
+ smembers?(key: string): Promise<string[]>;
63
+
64
+ /** Delete a named set entirely. */
65
+ sdel?(key: string): Promise<void>;
66
+
67
+ // --- Metadata ---
68
+
69
+ /** Consistency model — used for framework build diagnostics. */
70
+ readonly consistency: 'strong' | 'eventual' | 'local';
71
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Cloudflare Workers KV CacheStore — eventually-consistent, no set operations.
3
+ *
4
+ * The TagAwareCacheHandler uses the lazy (version-stamp) strategy for this
5
+ * store: tag invalidation bumps a version counter (O(1) write), and reads
6
+ * validate stored tag versions against current. No enumeration, no
7
+ * read-modify-write races, correct under eventual consistency.
8
+ *
9
+ * Consistency: KV propagates writes in ~60s. A tag version bump propagates
10
+ * like any other write — during the window, some reads may still validate
11
+ * against the old version and serve stale data. This is acceptable for KV's
12
+ * consistency model (use short TTLs for security-critical invalidation).
13
+ */
14
+
15
+ import type { CacheStore } from '../store';
16
+
17
+ export interface CloudflareKVNamespace {
18
+ get(key: string, options?: { type?: string }): Promise<string | null>;
19
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
20
+ delete(key: string): Promise<void>;
21
+ }
22
+
23
+ export class CloudflareKVStore implements CacheStore {
24
+ private kv: CloudflareKVNamespace | (() => CloudflareKVNamespace);
25
+ readonly consistency = 'eventual' as const;
26
+
27
+ /**
28
+ * @param kv - Either a KVNamespace instance or a factory function that
29
+ * resolves it from the current request context (for Workers where bindings
30
+ * are per-request).
31
+ */
32
+ constructor(kv: CloudflareKVNamespace | (() => CloudflareKVNamespace)) {
33
+ this.kv = kv;
34
+ }
35
+
36
+ private getKV(): CloudflareKVNamespace {
37
+ return typeof this.kv === 'function' ? this.kv() : this.kv;
38
+ }
39
+
40
+ async get(key: string): Promise<string | null> {
41
+ return this.getKV().get(key);
42
+ }
43
+
44
+ async set(key: string, value: string, ttlSeconds: number): Promise<void> {
45
+ // KV expirationTtl minimum is 60s
46
+ await this.getKV().put(key, value, { expirationTtl: Math.max(ttlSeconds, 60) });
47
+ }
48
+
49
+ async del(key: string): Promise<void> {
50
+ await this.getKV().delete(key);
51
+ }
52
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * In-memory CacheStore — local consistency only.
3
+ *
4
+ * LRU eviction by entry count and optional byte budget. The TagAwareCacheHandler
5
+ * uses the Set-based strategy here (we implement sadd/smembers/srem/sdel with
6
+ * plain Maps — atomic within a single process).
7
+ *
8
+ * Tag sets may accumulate stale memberships when data entries expire via TTL or
9
+ * LRU eviction (the store layer cannot know which sets reference a given key).
10
+ * This is harmless: TagAwareCacheHandler's eager invalidation path reads each
11
+ * member before deleting and prunes stale entries. The cost is proportional to
12
+ * set size on invalidation, not to memory — stale memberships are just strings.
13
+ */
14
+
15
+ import type { CacheStore } from '../store';
16
+ import { estimateByteSize } from '../sizeof.js';
17
+
18
+ export interface MemoryStoreOptions {
19
+ maxEntries?: number;
20
+ maxBytes?: number;
21
+ maxEntryBytes?: number;
22
+ }
23
+
24
+ export class MemoryCacheStore implements CacheStore {
25
+ private store = new Map<string, { value: string; expiresAt: number; byteSize: number }>();
26
+ private sets = new Map<string, Set<string>>();
27
+ private maxEntries: number;
28
+ private maxBytes: number | undefined;
29
+ private maxEntryBytes: number | undefined;
30
+ private currentBytes = 0;
31
+ private readonly trackBytes: boolean;
32
+ readonly consistency = 'local' as const;
33
+
34
+ constructor(opts?: MemoryStoreOptions) {
35
+ this.maxEntries = Math.max(opts?.maxEntries ?? 1000, 1);
36
+ this.maxBytes = opts?.maxBytes;
37
+ this.maxEntryBytes = opts?.maxEntryBytes;
38
+ this.trackBytes = this.maxBytes !== undefined || this.maxEntryBytes !== undefined;
39
+ }
40
+
41
+ async get(key: string): Promise<string | null> {
42
+ const entry = this.store.get(key);
43
+ if (!entry) return null;
44
+ if (Date.now() > entry.expiresAt) {
45
+ this.removeEntry(key);
46
+ return null;
47
+ }
48
+ // LRU: move to end
49
+ this.store.delete(key);
50
+ this.store.set(key, entry);
51
+ return entry.value;
52
+ }
53
+
54
+ async set(key: string, value: string, ttlSeconds: number): Promise<void> {
55
+ const byteSize = this.trackBytes ? estimateByteSize(value) : 0;
56
+
57
+ if (this.maxEntryBytes !== undefined && byteSize > this.maxEntryBytes) return;
58
+
59
+ if (this.store.has(key)) this.removeEntry(key);
60
+
61
+ while (this.store.size >= this.maxEntries) this.evictOldest();
62
+
63
+ if (this.maxBytes !== undefined) {
64
+ while (this.currentBytes + byteSize > this.maxBytes && this.store.size > 0) {
65
+ this.evictOldest();
66
+ }
67
+ if (this.currentBytes + byteSize > this.maxBytes) return;
68
+ }
69
+
70
+ this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000, byteSize });
71
+ this.currentBytes += byteSize;
72
+ }
73
+
74
+ async del(key: string): Promise<void> {
75
+ this.removeEntry(key);
76
+ }
77
+
78
+ // Set operations — trivial in-process implementations.
79
+
80
+ async sadd(key: string, members: string[], _ttlSeconds: number): Promise<void> {
81
+ let set = this.sets.get(key);
82
+ if (!set) {
83
+ set = new Set();
84
+ this.sets.set(key, set);
85
+ }
86
+ for (const m of members) set.add(m);
87
+ }
88
+
89
+ async srem(key: string, members: string[]): Promise<void> {
90
+ const set = this.sets.get(key);
91
+ if (!set) return;
92
+ for (const m of members) set.delete(m);
93
+ if (set.size === 0) this.sets.delete(key);
94
+ }
95
+
96
+ async smembers(key: string): Promise<string[]> {
97
+ const set = this.sets.get(key);
98
+ return set ? [...set] : [];
99
+ }
100
+
101
+ async sdel(key: string): Promise<void> {
102
+ this.sets.delete(key);
103
+ }
104
+
105
+ get size(): number {
106
+ return this.store.size;
107
+ }
108
+
109
+ get bytes(): number {
110
+ return this.currentBytes;
111
+ }
112
+
113
+ private removeEntry(key: string): void {
114
+ const entry = this.store.get(key);
115
+ if (entry) {
116
+ this.currentBytes -= entry.byteSize;
117
+ this.store.delete(key);
118
+ }
119
+ }
120
+
121
+ private evictOldest(): void {
122
+ const oldest = this.store.keys().next().value;
123
+ if (oldest !== undefined) this.removeEntry(oldest);
124
+ }
125
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Redis CacheStore — strongly-consistent, supports native Set operations.
3
+ *
4
+ * The TagAwareCacheHandler will use the Set-based strategy automatically
5
+ * because this store provides sadd/smembers/srem/sdel.
6
+ */
7
+
8
+ import type { CacheStore } from '../store';
9
+
10
+ /**
11
+ * Minimal Redis client contract. Same philosophy as the old RedisClient
12
+ * interface — deliberately NOT the native shape of any library. TTL is a
13
+ * required positional argument so no adapter can forget it.
14
+ *
15
+ * @example ioredis
16
+ * ```ts
17
+ * import Redis from 'ioredis';
18
+ * const redis = new Redis(process.env.REDIS_URL);
19
+ * const client: RedisStoreClient = {
20
+ * get: (k) => redis.get(k),
21
+ * set: (k, v, ttl) => redis.set(k, v, 'EX', ttl),
22
+ * del: (k) => redis.del(k).then(() => {}),
23
+ * sadd: (k, ...m) => redis.sadd(k, ...m).then(() => {}),
24
+ * srem: (k, ...m) => redis.srem(k, ...m).then(() => {}),
25
+ * smembers: (k) => redis.smembers(k),
26
+ * expireNX: (k, s) => redis.expire(k, s, 'NX').then(() => {}),
27
+ * expireGT: (k, s) => redis.expire(k, s, 'GT').then(() => {}),
28
+ * };
29
+ * ```
30
+ *
31
+ * @example @upstash/redis
32
+ * ```ts
33
+ * import { Redis } from '@upstash/redis';
34
+ * const redis = new Redis({ url, token, automaticDeserialization: false });
35
+ * const client: RedisStoreClient = {
36
+ * get: (k) => redis.get(k),
37
+ * set: (k, v, ttl) => redis.set(k, v, { ex: ttl }).then(() => {}),
38
+ * del: (k) => redis.del(k).then(() => {}),
39
+ * sadd: (k, ...m) => redis.sadd(k, ...m).then(() => {}),
40
+ * srem: (k, ...m) => redis.srem(k, ...m).then(() => {}),
41
+ * smembers: (k) => redis.smembers(k),
42
+ * expireNX: (k, s) => redis.expire(k, s, 'NX').then(() => {}),
43
+ * expireGT: (k, s) => redis.expire(k, s, 'GT').then(() => {}),
44
+ * };
45
+ * ```
46
+ */
47
+ export interface RedisStoreClient {
48
+ get(key: string): Promise<string | null>;
49
+ set(key: string, value: string, ttlSeconds: number): Promise<unknown>;
50
+ del(key: string): Promise<unknown>;
51
+ sadd(key: string, ...members: string[]): Promise<unknown>;
52
+ srem(key: string, ...members: string[]): Promise<unknown>;
53
+ smembers(key: string): Promise<string[]>;
54
+ /** `EXPIRE key seconds NX` — set TTL only if the key has no existing expiry (Redis 7.0+). */
55
+ expireNX(key: string, seconds: number): Promise<unknown>;
56
+ /** `EXPIRE key seconds GT` — extend TTL only if new value is greater (Redis 7.0+). */
57
+ expireGT(key: string, seconds: number): Promise<unknown>;
58
+ }
59
+
60
+ export class RedisCacheStore implements CacheStore {
61
+ private client: RedisStoreClient;
62
+ readonly consistency = 'strong' as const;
63
+
64
+ constructor(client: RedisStoreClient) {
65
+ this.client = client;
66
+ }
67
+
68
+ async get(key: string): Promise<string | null> {
69
+ return this.client.get(key);
70
+ }
71
+
72
+ async set(key: string, value: string, ttlSeconds: number): Promise<void> {
73
+ await this.client.set(key, value, ttlSeconds);
74
+ }
75
+
76
+ async del(key: string): Promise<void> {
77
+ await this.client.del(key);
78
+ }
79
+
80
+ async sadd(key: string, members: string[], ttlSeconds: number): Promise<void> {
81
+ await this.client.sadd(key, ...members);
82
+ await this.client.expireNX(key, ttlSeconds);
83
+ await this.client.expireGT(key, ttlSeconds);
84
+ }
85
+
86
+ async srem(key: string, members: string[]): Promise<void> {
87
+ await this.client.srem(key, ...members);
88
+ }
89
+
90
+ async smembers(key: string): Promise<string[]> {
91
+ return this.client.smembers(key);
92
+ }
93
+
94
+ async sdel(key: string): Promise<void> {
95
+ await this.client.del(key);
96
+ }
97
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Vercel Runtime Cache CacheStore — native tag support.
3
+ *
4
+ * Delegates tag operations to Vercel's infrastructure via their Runtime Cache
5
+ * API (getCache/expireTag). The TagAwareCacheHandler detects setWithTags +
6
+ * invalidateTag and delegates entirely — no inverted index, no version stamps.
7
+ *
8
+ * Vercel handles tag purging across CDN, Runtime Cache, and Data Cache layers
9
+ * simultaneously. Available on all plans.
10
+ */
11
+
12
+ import type { CacheStore } from '../store';
13
+
14
+ /**
15
+ * Vercel Runtime Cache contract — the subset we depend on.
16
+ * Resolved from `@vercel/functions` at runtime.
17
+ */
18
+ export interface VercelCache {
19
+ get(key: string): Promise<string | undefined>;
20
+ set(key: string, value: string, options: { ttl: number; tags?: string[] }): Promise<void>;
21
+ delete(key: string): Promise<void>;
22
+ expireTag(tag: string): Promise<void>;
23
+ }
24
+
25
+ export class VercelCacheStore implements CacheStore {
26
+ private cache: VercelCache | (() => VercelCache);
27
+ readonly consistency = 'strong' as const;
28
+
29
+ constructor(cache: VercelCache | (() => VercelCache)) {
30
+ this.cache = cache;
31
+ }
32
+
33
+ private getCache(): VercelCache {
34
+ return typeof this.cache === 'function' ? this.cache() : this.cache;
35
+ }
36
+
37
+ async get(key: string): Promise<string | null> {
38
+ const result = await this.getCache().get(key);
39
+ return result ?? null;
40
+ }
41
+
42
+ async set(key: string, value: string, ttlSeconds: number): Promise<void> {
43
+ await this.getCache().set(key, value, { ttl: ttlSeconds });
44
+ }
45
+
46
+ async del(key: string): Promise<void> {
47
+ await this.getCache().delete(key);
48
+ }
49
+
50
+ // --- Native tag support ---
51
+
52
+ async setWithTags(key: string, value: string, ttlSeconds: number, tags: string[]): Promise<void> {
53
+ await this.getCache().set(key, value, { ttl: ttlSeconds, tags });
54
+ }
55
+
56
+ async invalidateTag(tag: string): Promise<void> {
57
+ await this.getCache().expireTag(tag);
58
+ }
59
+ }