@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
@@ -0,0 +1,233 @@
1
+ /**
2
+ * TagAwareCacheHandler — wraps any CacheStore and adds tag-based invalidation.
3
+ *
4
+ * Three strategies, selected by store capabilities (best available wins):
5
+ *
6
+ * 1. **Native** (store has setWithTags + invalidateTag): Delegates entirely.
7
+ * The store owns tag indexing. Used for Vercel Runtime Cache, DO-backed
8
+ * stores, or any platform with first-class tag support.
9
+ *
10
+ * 2. **Eager** (store has sadd/smembers/srem/sdel): Maintains an inverted
11
+ * index via atomic set operations. Invalidation enumerates the set and
12
+ * deletes entries. Used for Redis.
13
+ *
14
+ * 3. **Lazy** (bare get/set/del only): Version-stamps entries with tag
15
+ * counters. Invalidation bumps a counter (O(1)). Reads validate stored
16
+ * versions against current. Race-free, correct under eventual consistency.
17
+ * Used for Cloudflare KV.
18
+ *
19
+ * Implements the CacheHandler interface consumed by timber-cache.ts.
20
+ */
21
+
22
+ import type { CacheHandler } from './index';
23
+ import type { CacheStore } from './store';
24
+
25
+ const DATA_PREFIX = 'd:';
26
+ const TAG_SET_PREFIX = 'ts:';
27
+ const TAG_VERSION_PREFIX = 'tv:';
28
+
29
+ type Strategy = 'native' | 'eager' | 'lazy';
30
+
31
+ /** Stored entry format for eager strategy. */
32
+ interface EagerEntry {
33
+ value: unknown;
34
+ expiresAt: number;
35
+ tags: string[];
36
+ }
37
+
38
+ /** Stored entry format for lazy (version-stamp) strategy. */
39
+ interface LazyEntry {
40
+ value: unknown;
41
+ expiresAt: number;
42
+ tags: string[];
43
+ tagVersions: Record<string, number>;
44
+ }
45
+
46
+ function detectStrategy(store: CacheStore): Strategy {
47
+ if (store.setWithTags && store.invalidateTag) return 'native';
48
+ if (store.sadd && store.smembers && store.srem && store.sdel) return 'eager';
49
+ return 'lazy';
50
+ }
51
+
52
+ export class TagAwareCacheHandler implements CacheHandler {
53
+ private store: CacheStore;
54
+ private prefix: string;
55
+ private strategy: Strategy;
56
+
57
+ constructor(store: CacheStore, opts?: { prefix?: string }) {
58
+ this.store = store;
59
+ this.prefix = opts?.prefix ?? 'timber:';
60
+ this.strategy = detectStrategy(store);
61
+ }
62
+
63
+ get consistency(): 'strong' | 'eventual' | 'local' {
64
+ return this.store.consistency;
65
+ }
66
+
67
+ private dataKey(key: string): string {
68
+ return `${this.prefix}${DATA_PREFIX}${key}`;
69
+ }
70
+
71
+ /** Namespace tags for native stores so prefix isolates shared stores. */
72
+ private prefixTags(tags: string[]): string[] {
73
+ return tags.map((t) => `${this.prefix}${t}`);
74
+ }
75
+
76
+ private tagSetKey(tag: string): string {
77
+ return `${this.prefix}${TAG_SET_PREFIX}${tag}`;
78
+ }
79
+
80
+ private tagVersionKey(tag: string): string {
81
+ return `${this.prefix}${TAG_VERSION_PREFIX}${tag}`;
82
+ }
83
+
84
+ async get(key: string): Promise<{ value: unknown; stale: boolean } | null> {
85
+ const raw = await this.store.get(this.dataKey(key));
86
+ if (raw === null) return null;
87
+
88
+ if (this.strategy === 'native' || this.strategy === 'eager') {
89
+ const entry = JSON.parse(raw) as EagerEntry;
90
+ const stale = Date.now() > entry.expiresAt;
91
+ return { value: entry.value, stale };
92
+ }
93
+
94
+ // Lazy: validate tag versions before returning
95
+ const entry = JSON.parse(raw) as LazyEntry;
96
+ if (entry.tags.length > 0) {
97
+ const currentVersions = await this.getTagVersions(entry.tags);
98
+ for (const tag of entry.tags) {
99
+ const stored = entry.tagVersions[tag] ?? 0;
100
+ const current = currentVersions[tag] ?? 0;
101
+ if (current > stored) return null;
102
+ }
103
+ }
104
+ const stale = Date.now() > entry.expiresAt;
105
+ return { value: entry.value, stale };
106
+ }
107
+
108
+ async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
109
+ const physicalTtl = Math.max(opts.ttl * 2 + 60, 120);
110
+
111
+ if (this.strategy === 'native') {
112
+ const entry: EagerEntry = {
113
+ value,
114
+ expiresAt: Date.now() + opts.ttl * 1000,
115
+ tags: opts.tags,
116
+ };
117
+ const payload = JSON.stringify(entry);
118
+ if (opts.tags.length > 0) {
119
+ await this.store.setWithTags!(
120
+ this.dataKey(key),
121
+ payload,
122
+ physicalTtl,
123
+ this.prefixTags(opts.tags)
124
+ );
125
+ } else {
126
+ await this.store.set(this.dataKey(key), payload, physicalTtl);
127
+ }
128
+ return;
129
+ }
130
+
131
+ if (this.strategy === 'eager') {
132
+ const entry: EagerEntry = {
133
+ value,
134
+ expiresAt: Date.now() + opts.ttl * 1000,
135
+ tags: opts.tags,
136
+ };
137
+ await this.store.set(this.dataKey(key), JSON.stringify(entry), physicalTtl);
138
+ if (opts.tags.length > 0) {
139
+ await Promise.all(
140
+ opts.tags.map((tag) => this.store.sadd!(this.tagSetKey(tag), [key], physicalTtl))
141
+ );
142
+ }
143
+ return;
144
+ }
145
+
146
+ // Lazy: snapshot current tag versions into the entry
147
+ const tagVersions = opts.tags.length > 0 ? await this.getTagVersions(opts.tags) : {};
148
+ const entry: LazyEntry = {
149
+ value,
150
+ expiresAt: Date.now() + opts.ttl * 1000,
151
+ tags: opts.tags,
152
+ tagVersions,
153
+ };
154
+ await this.store.set(this.dataKey(key), JSON.stringify(entry), physicalTtl);
155
+ }
156
+
157
+ async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
158
+ if (opts.key) {
159
+ if (this.strategy === 'eager') {
160
+ const raw = await this.store.get(this.dataKey(opts.key));
161
+ if (raw !== null) {
162
+ const entry = JSON.parse(raw) as EagerEntry;
163
+ if (entry.tags.length > 0) {
164
+ await Promise.all(
165
+ entry.tags.map((tag) => this.store.srem!(this.tagSetKey(tag), [opts.key!]))
166
+ );
167
+ }
168
+ }
169
+ }
170
+ await this.store.del(this.dataKey(opts.key));
171
+ }
172
+
173
+ if (opts.tag) {
174
+ const tag = opts.tag;
175
+ if (this.strategy === 'native') {
176
+ await this.store.invalidateTag!(`${this.prefix}${tag}`);
177
+ } else if (this.strategy === 'eager') {
178
+ const members = await this.store.smembers!(this.tagSetKey(tag));
179
+ // Verify each member still carries the tag before deleting — the set
180
+ // can contain stale memberships from overwritten entries or LRU eviction.
181
+ // Remove only the snapshot members (via srem, not sdel) so concurrent
182
+ // writes that add new members between smembers() and cleanup are preserved.
183
+ await Promise.all(
184
+ members.map(async (k) => {
185
+ const raw = await this.store.get(this.dataKey(k));
186
+ if (raw === null) {
187
+ await this.store.srem!(this.tagSetKey(tag), [k]);
188
+ return;
189
+ }
190
+ const entry = JSON.parse(raw) as EagerEntry;
191
+ if (entry.tags.includes(tag)) {
192
+ await this.store.del(this.dataKey(k));
193
+ await this.store.srem!(this.tagSetKey(tag), [k]);
194
+ } else {
195
+ await this.store.srem!(this.tagSetKey(tag), [k]);
196
+ }
197
+ })
198
+ );
199
+ } else {
200
+ await this.bumpTagVersion(opts.tag);
201
+ }
202
+ }
203
+ }
204
+
205
+ // --- Lazy strategy helpers ---
206
+
207
+ private async getTagVersions(tags: string[]): Promise<Record<string, number>> {
208
+ const versions: Record<string, number> = {};
209
+ await Promise.all(
210
+ tags.map(async (tag) => {
211
+ const raw = await this.store.get(this.tagVersionKey(tag));
212
+ versions[tag] = raw !== null ? parseInt(raw, 10) : 0;
213
+ })
214
+ );
215
+ return versions;
216
+ }
217
+
218
+ private async bumpTagVersion(tag: string): Promise<void> {
219
+ const key = this.tagVersionKey(tag);
220
+ const raw = await this.store.get(key);
221
+ const stored = raw !== null ? parseInt(raw, 10) : 0;
222
+ // max(stored, now) + 1: strictly advancing even within the same
223
+ // millisecond (two bumps in 1ms produce N, N+1 not N, N). On
224
+ // eventually-consistent stores the read can be stale, but Date.now()
225
+ // provides a floor — regression only if BOTH the read is stale AND the
226
+ // clock is behind, bounded by the store's propagation window (~60s).
227
+ const version = Math.max(stored, Date.now()) + 1;
228
+ // Version keys must outlive all data entries stamped with them. Data
229
+ // entries use physical TTL of max(ttl*2+60, 120); 365 days exceeds any
230
+ // practical cache lifetime.
231
+ await this.store.set(key, String(version), 365 * 86400);
232
+ }
233
+ }
@@ -160,14 +160,24 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
160
160
 
161
161
  /**
162
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.
163
+ * inside the singleflight callback. The write is skipped when:
164
+ * - the key/tags were invalidated after fn started (TIM-1028), or
165
+ * - the singleflight timed out (signal aborted) — the timed-out
166
+ * flight's write could overwrite a newer value from a subsequent
167
+ * flight (TIM-1092).
168
+ *
169
+ * Residual race: if fn() completes within the timeout but
170
+ * handler.set() is slow enough that the timeout fires during the
171
+ * set, the write lands with stale data. This window is narrow
172
+ * (requires set latency to straddle the timeout boundary) and
173
+ * self-healing (next cache miss triggers a fresh write). Fully
174
+ * closing it requires CAS/versioned writes on CacheHandler —
175
+ * tracked in TIM-1158.
166
176
  */
167
- const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {
177
+ const executeAndStore = async (signal: AbortSignal): Promise<Awaited<ReturnType<Fn>>> => {
168
178
  const startEpoch = currentInvalidationEpoch();
169
179
  const result = await fn(...args);
170
- if (!wasInvalidatedSince(startEpoch, key, tags)) {
180
+ if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
171
181
  await getHandler().set(key, result, { ttl: opts.ttl, tags });
172
182
  }
173
183
  return result;
@@ -182,9 +192,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
182
192
  });
183
193
  // Serve stale immediately, trigger background refetch
184
194
  const refetch = sf
185
- .do(`swr:${flightKey}`, async () => {
195
+ .do(`swr:${flightKey}`, async (signal) => {
186
196
  try {
187
- await executeAndStore();
197
+ await executeAndStore(signal);
188
198
  } catch (err) {
189
199
  // Failed refetch — stale entry continues to be served.
190
200
  logSwrRefetchFailed({ cacheKey: key, error: err });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cloudflare CDN purge handler — purges by cache tag via the Cloudflare API.
3
+ *
4
+ * Requires Cloudflare Enterprise for tag-based purging.
5
+ * See https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/
6
+ */
7
+
8
+ import type { CdnPurgeHandler } from './purge-store';
9
+
10
+ export interface CloudflarePurgeOptions {
11
+ /** Cloudflare zone ID. */
12
+ zoneId: string;
13
+ /** API token with Cache Purge permission. */
14
+ apiToken: string;
15
+ /** Override fetch for testing. */
16
+ fetch?: typeof globalThis.fetch;
17
+ }
18
+
19
+ export class CloudflarePurge implements CdnPurgeHandler {
20
+ private readonly zoneId: string;
21
+ private readonly apiToken: string;
22
+ private readonly _fetch: typeof globalThis.fetch;
23
+
24
+ constructor(opts: CloudflarePurgeOptions) {
25
+ this.zoneId = opts.zoneId;
26
+ this.apiToken = opts.apiToken;
27
+ this._fetch = opts.fetch ?? globalThis.fetch;
28
+ }
29
+
30
+ async purgeTags(tags: string[]): Promise<void> {
31
+ if (tags.length === 0) return;
32
+
33
+ const res = await this._fetch(
34
+ `https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`,
35
+ {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Authorization': `Bearer ${this.apiToken}`,
39
+ 'Content-Type': 'application/json',
40
+ },
41
+ body: JSON.stringify({ tags }),
42
+ }
43
+ );
44
+
45
+ if (!res.ok) {
46
+ const body = await res.text();
47
+ throw new Error(`Cloudflare purge failed (${res.status}): ${body}`);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Fastly CDN purge handler — purges by surrogate key via the Fastly API.
3
+ *
4
+ * See https://www.fastly.com/documentation/reference/api/purging/
5
+ */
6
+
7
+ import type { CdnPurgeHandler } from './purge-store';
8
+
9
+ export interface FastlyPurgeOptions {
10
+ /** Fastly service ID. */
11
+ serviceId: string;
12
+ /** Fastly API token with purge permission. */
13
+ apiToken: string;
14
+ /** Use soft purge (marks as stale instead of immediate delete). Default: false. */
15
+ softPurge?: boolean;
16
+ /** Override fetch for testing. */
17
+ fetch?: typeof globalThis.fetch;
18
+ }
19
+
20
+ export class FastlyPurge implements CdnPurgeHandler {
21
+ private readonly serviceId: string;
22
+ private readonly apiToken: string;
23
+ private readonly softPurge: boolean;
24
+ private readonly _fetch: typeof globalThis.fetch;
25
+
26
+ constructor(opts: FastlyPurgeOptions) {
27
+ this.serviceId = opts.serviceId;
28
+ this.apiToken = opts.apiToken;
29
+ this.softPurge = opts.softPurge ?? false;
30
+ this._fetch = opts.fetch ?? globalThis.fetch;
31
+ }
32
+
33
+ async purgeTags(tags: string[]): Promise<void> {
34
+ if (tags.length === 0) return;
35
+
36
+ const results = await Promise.allSettled(
37
+ tags.map((tag) =>
38
+ this._fetch(
39
+ `https://api.fastly.com/service/${this.serviceId}/purge/${encodeURIComponent(tag)}`,
40
+ {
41
+ method: 'POST',
42
+ headers: {
43
+ 'Fastly-Key': this.apiToken,
44
+ ...(this.softPurge ? { 'Fastly-Soft-Purge': '1' } : {}),
45
+ },
46
+ }
47
+ ).then(async (res) => {
48
+ if (!res.ok) {
49
+ const body = await res.text();
50
+ throw new Error(`Fastly purge failed for tag "${tag}" (${res.status}): ${body}`);
51
+ }
52
+ })
53
+ )
54
+ );
55
+
56
+ const failures = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected');
57
+ if (failures.length > 0) {
58
+ throw new Error(
59
+ `Fastly purge failed for ${failures.length}/${tags.length} tags: ${failures.map((f) => f.reason?.message).join('; ')}`
60
+ );
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * CDN header helpers — pure functions that build Cache-Control,
3
+ * CDN-Cache-Control, Surrogate-Control, and Surrogate-Key header values.
4
+ *
5
+ * These are developer-elected utilities for wiring CDN caching in
6
+ * proxy.ts / middleware.ts. The framework never sets these automatically.
7
+ * See design/25-production-deployments.md §Layer 2.
8
+ */
9
+
10
+ export interface CacheControlOptions {
11
+ /** Mark the response as publicly cacheable (CDN, shared caches). */
12
+ public?: boolean;
13
+ /** Mark the response as private (browser cache only, not CDN). */
14
+ private?: boolean;
15
+ /** Max freshness in seconds for the browser/client. */
16
+ maxAge?: number;
17
+ /** Max freshness in seconds for shared caches (CDN, proxy). Overrides maxAge for CDNs. */
18
+ sMaxAge?: number;
19
+ /** Serve stale while revalidating in the background (seconds). */
20
+ staleWhileRevalidate?: number;
21
+ /** Serve stale on upstream error (seconds). */
22
+ staleIfError?: number;
23
+ /** Response must not be stored by any cache. */
24
+ noStore?: boolean;
25
+ /** Caches must revalidate before using a stored response. */
26
+ noCache?: boolean;
27
+ /** Caches must revalidate once stale — no serving stale without checking origin. */
28
+ mustRevalidate?: boolean;
29
+ /** Response will not change — allows aggressive caching. Pair with hashed URLs. */
30
+ immutable?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Build a `Cache-Control` header value.
35
+ *
36
+ * ```ts
37
+ * ctx.headers.set('Cache-Control', cacheControl({
38
+ * public: true,
39
+ * maxAge: 300,
40
+ * staleWhileRevalidate: 60,
41
+ * }));
42
+ * ```
43
+ */
44
+ export function cacheControl(opts: CacheControlOptions): string {
45
+ if (opts.public && opts.private) {
46
+ throw new Error('cacheControl: public and private are mutually exclusive');
47
+ }
48
+ if (opts.noStore && opts.maxAge !== undefined) {
49
+ throw new Error('cacheControl: noStore makes maxAge meaningless — remove one');
50
+ }
51
+
52
+ const parts: string[] = [];
53
+
54
+ parts.push(opts.public ? 'public' : 'private');
55
+
56
+ if (opts.noStore) parts.push('no-store');
57
+ if (opts.noCache) parts.push('no-cache');
58
+ if (opts.maxAge !== undefined) parts.push(`max-age=${opts.maxAge}`);
59
+ if (opts.sMaxAge !== undefined) parts.push(`s-maxage=${opts.sMaxAge}`);
60
+ if (opts.staleWhileRevalidate !== undefined)
61
+ parts.push(`stale-while-revalidate=${opts.staleWhileRevalidate}`);
62
+ if (opts.staleIfError !== undefined) parts.push(`stale-if-error=${opts.staleIfError}`);
63
+ if (opts.mustRevalidate) parts.push('must-revalidate');
64
+ if (opts.immutable) parts.push('immutable');
65
+
66
+ return parts.join(', ');
67
+ }
68
+
69
+ export interface SurrogateControlOptions {
70
+ /** Max freshness in seconds for the surrogate (CDN). */
71
+ maxAge?: number;
72
+ /** Do not store in surrogate cache. */
73
+ noStore?: boolean;
74
+ /** Serve stale while revalidating (seconds). */
75
+ staleWhileRevalidate?: number;
76
+ /** Serve stale on error (seconds). */
77
+ staleIfError?: number;
78
+ }
79
+
80
+ /**
81
+ * Build a `Surrogate-Control` header value (Fastly, Akamai, Varnish).
82
+ *
83
+ * ```ts
84
+ * ctx.headers.set('Surrogate-Control', surrogateControl({ maxAge: 3600 }));
85
+ * ```
86
+ */
87
+ export function surrogateControl(opts: SurrogateControlOptions): string {
88
+ const parts: string[] = [];
89
+ if (opts.noStore) parts.push('no-store');
90
+ if (opts.maxAge !== undefined) parts.push(`max-age=${opts.maxAge}`);
91
+ if (opts.staleWhileRevalidate !== undefined)
92
+ parts.push(`stale-while-revalidate=${opts.staleWhileRevalidate}`);
93
+ if (opts.staleIfError !== undefined) parts.push(`stale-if-error=${opts.staleIfError}`);
94
+ return parts.join(', ');
95
+ }
96
+
97
+ /**
98
+ * Build a `CDN-Cache-Control` header value (Cloudflare).
99
+ * Same directive syntax as Surrogate-Control but recognized by Cloudflare.
100
+ *
101
+ * ```ts
102
+ * ctx.headers.set('CDN-Cache-Control', cdnCacheControl({ maxAge: 3600 }));
103
+ * ```
104
+ */
105
+ export function cdnCacheControl(opts: SurrogateControlOptions): string {
106
+ return surrogateControl(opts);
107
+ }
108
+
109
+ /**
110
+ * Build a `Surrogate-Key` header value — space-separated tags for CDN
111
+ * tag-based purging (Fastly, Cloudflare Enterprise, Akamai).
112
+ *
113
+ * Tags are deduplicated. Tags must not contain spaces or double quotes
114
+ * (these are separators/delimiters in the header format).
115
+ *
116
+ * ```ts
117
+ * ctx.headers.set('Surrogate-Key', surrogateKey(['products', `product:${id}`]));
118
+ * ```
119
+ */
120
+ export function surrogateKey(tags: string[]): string {
121
+ for (const tag of tags) {
122
+ if (tag.includes(' ') || tag.includes('"')) {
123
+ throw new Error(`surrogateKey: tag "${tag}" must not contain spaces or double quotes`);
124
+ }
125
+ }
126
+ return [...new Set(tags)].join(' ');
127
+ }
@@ -0,0 +1,16 @@
1
+ // @timber-js/app/cdn — CDN integration helpers
2
+ //
3
+ // Header utilities and purge handler for CDN caching. Developer-elected,
4
+ // never automatic. See design/25-production-deployments.md §Layer 2.
5
+
6
+ export { cacheControl, surrogateKey, surrogateControl, cdnCacheControl } from './headers';
7
+ export type { CacheControlOptions, SurrogateControlOptions } from './headers';
8
+
9
+ export { setCdnPurgeHandler, getCdnPurgeHandler } from './purge-store';
10
+ export type { CdnPurgeHandler } from './purge-store';
11
+
12
+ // Platform-specific purge handlers are at their own entry points:
13
+ // @timber-js/app/cdn/cloudflare → CloudflarePurge
14
+ // @timber-js/app/cdn/fastly → FastlyPurge
15
+ // Same pattern as @timber-js/app/cache/stores/* — keeps platform
16
+ // dependencies out of the main CDN import.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * CDN purge handler singleton — same pattern as cache/handler-store.ts.
3
+ *
4
+ * Set via timber.config.ts or setCdnPurgeHandler() at boot. When set,
5
+ * cache.invalidate({ tag }) also triggers CDN purge for the invalidated
6
+ * tags, so revalidateTag() composes with CDN caching out of the box.
7
+ *
8
+ * See design/25-production-deployments.md §Layer 2.
9
+ */
10
+
11
+ export interface CdnPurgeHandler {
12
+ /**
13
+ * Purge the given cache tags from the CDN.
14
+ * Called by cache.invalidate when a tag is invalidated.
15
+ */
16
+ purgeTags(tags: string[]): Promise<void>;
17
+ }
18
+
19
+ let handler: CdnPurgeHandler | undefined;
20
+
21
+ /** Install a CDN purge handler. Called by the framework at boot from timber.config.ts. */
22
+ export function setCdnPurgeHandler(h: CdnPurgeHandler): void {
23
+ handler = h;
24
+ }
25
+
26
+ /** Get the current CDN purge handler, if configured. */
27
+ export function getCdnPurgeHandler(): CdnPurgeHandler | undefined {
28
+ return handler;
29
+ }
30
+
31
+ /** @internal — reset for tests only. */
32
+ export function _resetCdnPurgeHandler(): void {
33
+ handler = undefined;
34
+ }
@@ -18,9 +18,6 @@ import { setServerCallback, encodeReply, createFromFetch } from '../../rsc-runti
18
18
  import { getRouter, getRouterOrNull } from '#client-internal';
19
19
  import { setHardNavigating } from '../navigation-root.js';
20
20
  import { getClientDeploymentId, DEPLOYMENT_ID_HEADER, RELOAD_HEADER } from '../rsc-fetch.js';
21
- import { decodeHeadHeader, HEAD_HEADER } from '../../shared/head-header.js';
22
- import type { HeadElement } from '../head.js';
23
-
24
21
  export function setupServerActions(): void {
25
22
  setServerCallback(async (id: string, args: unknown[]) => {
26
23
  const body = await encodeReply(args);
@@ -30,7 +27,6 @@ export function setupServerActions(): void {
30
27
  // consumes the body stream.
31
28
  let hasRevalidation = false;
32
29
  let hasRedirect = false;
33
- let headElementsJson: string | null = null;
34
30
 
35
31
  // Build action request headers. Include deployment ID for version
36
32
  // skew detection (TIM-446) — the server rejects stale actions gracefully.
@@ -56,7 +52,6 @@ export function setupServerActions(): void {
56
52
  }
57
53
  hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
58
54
  hasRedirect = res.headers.get('X-Timber-Redirect') != null;
59
- headElementsJson = res.headers.get(HEAD_HEADER);
60
55
  return res;
61
56
  });
62
57
 
@@ -104,11 +99,7 @@ export function setupServerActions(): void {
104
99
  const router = getRouterOrNull();
105
100
  if (router) {
106
101
  try {
107
- // Percent-encoded JSON — same wire format as the navigation path.
108
- // A malformed header decodes to null (head update skipped), it
109
- // does not fail the revalidation. See TIM-1058.
110
- const headElements = decodeHeadHeader(headElementsJson) as HeadElement[] | null;
111
- router.applyRevalidation(wrapper._tree, headElements);
102
+ router.applyRevalidation(wrapper._tree);
112
103
  } catch (e) {
113
104
  console.error('[timber] applyRevalidation failed after server action', e);
114
105
  }
@@ -40,7 +40,6 @@ export function setupPostHydration({
40
40
  // useSegmentParams() consumer on the initial page (TIM-1037).
41
41
  router.historyStack.push(window.location.pathname + window.location.search, {
42
42
  payload: initialElement,
43
- headElements: null, // SSR already set the correct head
44
43
  params: getNavigationState().params,
45
44
  });
46
45
 
@@ -12,7 +12,6 @@ import { createFromFetch } from '../../rsc-runtime/browser.js';
12
12
  import { createRouter, setGlobalRouter } from '#client-internal';
13
13
  import type { RouterDeps, RouterInstance } from '#client-internal';
14
14
  import type { NavigationState } from '#client-internal';
15
- import { applyHeadElements } from '../head.js';
16
15
  import { TimberNuqsAdapter } from '../nuqs-adapter.js';
17
16
  import {
18
17
  NavigationProvider,
@@ -243,10 +242,6 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
243
242
  requestAnimationFrame(callback);
244
243
  });
245
244
  },
246
-
247
- // Apply resolved head elements (title, meta tags) to the DOM after
248
- // SPA navigation. See design/16-metadata.md.
249
- applyHead: applyHeadElements,
250
245
  };
251
246
 
252
247
  const router = createRouter(deps);
@@ -1,15 +1,11 @@
1
1
  // History Stack — stores RSC payloads by URL for instant back/forward navigation
2
2
  // See design/19-client-navigation.md § History Stack
3
3
 
4
- import type { HeadElement } from './head';
5
-
6
4
  // ─── Types ───────────────────────────────────────────────────────
7
5
 
8
6
  export interface HistoryEntry {
9
7
  /** The complete segment tree payload at the time of navigation */
10
8
  payload: unknown;
11
- /** Resolved head elements for this page (title, meta tags). Null for SSR'd initial page. */
12
- headElements?: HeadElement[] | null;
13
9
  /**
14
10
  * Route params for this page (for useSegmentParams). Every entry that can
15
11
  * be replayed must carry them — the initial SSR entry gets them from the