@timber-js/app 0.2.0-alpha.167 → 0.2.0-alpha.169

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 (152) hide show
  1. package/dist/_chunks/{actions-TSxpXLHJ.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-TSxpXLHJ.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DzpQQOEx.js → cache-api-B-lhk9p4.js} +56 -11
  4. package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-wX-i90Og.js → cli-schema-sync-B73L6pMq.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-wX-i90Og.js.map → cli-schema-sync-B73L6pMq.js.map} +1 -1
  7. package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
  8. package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
  9. package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
  10. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  11. package/dist/_chunks/{walkers-Cfwvl-UC.js → walkers-CoOC8Hga.js} +2 -2
  12. package/dist/_chunks/{walkers-Cfwvl-UC.js.map → walkers-CoOC8Hga.js.map} +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  14. package/dist/adapters/cloudflare-kv-cache.js +9 -2
  15. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  16. package/dist/cache/cache-api.d.ts.map +1 -1
  17. package/dist/cache/index.d.ts +1 -1
  18. package/dist/cache/index.d.ts.map +1 -1
  19. package/dist/cache/index.js +1 -1
  20. package/dist/cache/json-lossy-check.d.ts +11 -0
  21. package/dist/cache/json-lossy-check.d.ts.map +1 -0
  22. package/dist/cache/redis-handler.d.ts +42 -0
  23. package/dist/cache/redis-handler.d.ts.map +1 -1
  24. package/dist/cache/singleflight.d.ts.map +1 -1
  25. package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
  26. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
  27. package/dist/cache/stores/memory.d.ts +1 -1
  28. package/dist/cache/stores/memory.d.ts.map +1 -1
  29. package/dist/cache/stores/redis.d.ts +1 -1
  30. package/dist/cache/stores/redis.d.ts.map +1 -1
  31. package/dist/cache/stores/vercel.d.ts +1 -1
  32. package/dist/cache/stores/vercel.d.ts.map +1 -1
  33. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  34. package/dist/cache/timber-cache.d.ts.map +1 -1
  35. package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
  36. package/dist/cdn/fastly-purge.d.ts.map +1 -1
  37. package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
  38. package/dist/cli.d.ts +1 -1
  39. package/dist/cli.d.ts.map +1 -1
  40. package/dist/cli.js +3 -2
  41. package/dist/cli.js.map +1 -1
  42. package/dist/client/browser-entry/router-init.d.ts +1 -0
  43. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  44. package/dist/client/child-segment-context.d.ts +2 -2
  45. package/dist/client/child-segment-context.d.ts.map +1 -1
  46. package/dist/client/error-boundary.d.ts.map +1 -1
  47. package/dist/client/history.d.ts.map +1 -1
  48. package/dist/client/internal.js +4 -3
  49. package/dist/client/internal.js.map +1 -1
  50. package/dist/client/router.d.ts +6 -0
  51. package/dist/client/router.d.ts.map +1 -1
  52. package/dist/client/rsc-fetch.d.ts.map +1 -1
  53. package/dist/client/segment-cache.d.ts.map +1 -1
  54. package/dist/client/segment-update-context.d.ts +3 -3
  55. package/dist/client/segment-update-context.d.ts.map +1 -1
  56. package/dist/client/slot-outlet.d.ts +1 -1
  57. package/dist/codec.d.ts.map +1 -1
  58. package/dist/codec.js +2 -1
  59. package/dist/codec.js.map +1 -1
  60. package/dist/config-types.d.ts +16 -37
  61. package/dist/config-types.d.ts.map +1 -1
  62. package/dist/config-validation.d.ts.map +1 -1
  63. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  64. package/dist/fonts/pipeline.d.ts +19 -0
  65. package/dist/fonts/pipeline.d.ts.map +1 -1
  66. package/dist/fonts/transform.d.ts.map +1 -1
  67. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +344 -84
  70. package/dist/index.js.map +1 -1
  71. package/dist/plugin-context.d.ts.map +1 -1
  72. package/dist/plugins/adapter-build.d.ts +1 -0
  73. package/dist/plugins/adapter-build.d.ts.map +1 -1
  74. package/dist/plugins/entries.d.ts +27 -3
  75. package/dist/plugins/entries.d.ts.map +1 -1
  76. package/dist/plugins/fonts.d.ts.map +1 -1
  77. package/dist/plugins/server-bundle.d.ts.map +1 -1
  78. package/dist/routing/index.js +2 -2
  79. package/dist/server/action-client.d.ts +1 -1
  80. package/dist/server/action-client.d.ts.map +1 -1
  81. package/dist/server/body-limits.d.ts.map +1 -1
  82. package/dist/server/form-data.d.ts.map +1 -1
  83. package/dist/server/html-injector-core.d.ts.map +1 -1
  84. package/dist/server/index.js +20 -3
  85. package/dist/server/index.js.map +1 -1
  86. package/dist/server/internal.js +24 -40
  87. package/dist/server/internal.js.map +1 -1
  88. package/dist/server/middleware-runner.d.ts +0 -17
  89. package/dist/server/middleware-runner.d.ts.map +1 -1
  90. package/dist/server/param-coercion.d.ts.map +1 -1
  91. package/dist/server/pipeline-phases.d.ts +6 -3
  92. package/dist/server/pipeline-phases.d.ts.map +1 -1
  93. package/dist/server/pipeline.d.ts +18 -0
  94. package/dist/server/pipeline.d.ts.map +1 -1
  95. package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
  96. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  97. package/dist/server/primitives.d.ts.map +1 -1
  98. package/dist/server/render-timeout.d.ts.map +1 -1
  99. package/dist/server/route-element-builder.d.ts.map +1 -1
  100. package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
  101. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  102. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  103. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  104. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  105. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
  106. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  107. package/dist/server/safe-load.d.ts.map +1 -1
  108. package/dist/server/ssr-entry.d.ts.map +1 -1
  109. package/dist/shared/redirect-type.d.ts +2 -2
  110. package/dist/shared/redirect-type.d.ts.map +1 -1
  111. package/dist/shims/font-google.d.ts.map +1 -1
  112. package/dist/shims/image.d.ts +120 -120
  113. package/dist/shims/image.d.ts.map +1 -1
  114. package/docs/api/32-api-cache.mdx +112 -0
  115. package/docs/api/34-api-config.mdx +0 -26
  116. package/docs/learn/09-caching.mdx +121 -5
  117. package/docs/learn/12-client-navigation.mdx +9 -1
  118. package/docs/learn/13-configuration.mdx +6 -8
  119. package/docs/learn/14-deploying.mdx +18 -18
  120. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  121. package/package.json +6 -6
  122. package/src/adapters/cloudflare-kv-cache.ts +27 -6
  123. package/src/cache/index.ts +1 -1
  124. package/src/cache/json-lossy-check.ts +75 -0
  125. package/src/cache/redis-handler.ts +65 -14
  126. package/src/cache/timber-cache.ts +10 -2
  127. package/src/client/browser-entry/index.ts +2 -0
  128. package/src/client/browser-entry/router-init.ts +2 -0
  129. package/src/client/router.ts +13 -3
  130. package/src/codec.ts +3 -1
  131. package/src/config-types.ts +16 -37
  132. package/src/config-validation.ts +7 -5
  133. package/src/fonts/pipeline.ts +32 -0
  134. package/src/fonts/transform.ts +30 -24
  135. package/src/plugins/adapter-build.ts +131 -13
  136. package/src/plugins/entries.ts +40 -43
  137. package/src/plugins/fonts.ts +30 -0
  138. package/src/plugins/server-bundle.ts +7 -15
  139. package/src/server/action-client.ts +1 -1
  140. package/src/server/middleware-runner.ts +0 -44
  141. package/src/server/param-coercion.ts +1 -0
  142. package/src/server/pipeline-phases.ts +11 -13
  143. package/src/server/pipeline.ts +51 -3
  144. package/src/server/route-element-builder.ts +10 -1
  145. package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
  146. package/src/server/rsc-entry/index.ts +30 -40
  147. package/src/server/rsc-entry/render-route.ts +3 -1
  148. package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
  149. package/dist/_chunks/cache-api-DzpQQOEx.js.map +0 -1
  150. package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
  151. package/dist/_chunks/tree-match-D2l830j2.js +0 -102
  152. package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
@@ -65,15 +65,19 @@ For caching, use `CloudflareKVCacheHandler`:
65
65
 
66
66
  ```ts title="timber.config.ts"
67
67
  import { cloudflare } from '@timber-js/app/adapters/cloudflare';
68
- import { CloudflareKVCacheHandler } from '@timber-js/app/adapters/cloudflare/cache';
69
68
 
70
69
  export default {
71
70
  output: 'server',
72
71
  adapter: cloudflare(),
73
- cacheHandler: new CloudflareKVCacheHandler({ bindingName: 'TIMBER_CACHE' }),
74
72
  };
75
73
  ```
76
74
 
75
+ ```ts title="timber.cache.ts"
76
+ import { CloudflareKVCacheHandler } from '@timber-js/app/adapters/cloudflare/cache';
77
+
78
+ export default new CloudflareKVCacheHandler({ bindingName: 'TIMBER_CACHE' });
79
+ ```
80
+
77
81
  **Constraints:** no file system, 10ms/30s CPU time limits, 10MB/25MB bundle size.
78
82
 
79
83
  ## Node.js & Docker
@@ -226,30 +230,26 @@ When using the Cloudflare adapter, CDN purge is **automatic** — no configurati
226
230
 
227
231
  For non-Workers Cloudflare zones (Enterprise), or other CDNs, configure a purge handler manually:
228
232
 
229
- ```ts title="timber.config.ts"
233
+ ```ts title="timber.cache.ts"
230
234
  import { CloudflarePurge } from '@timber-js/app/cdn/cloudflare';
231
235
 
232
- export default {
233
- // Only needed for non-Workers Cloudflare zones (Enterprise)
234
- cdnPurge: new CloudflarePurge({
235
- zoneId: process.env.CF_ZONE_ID!,
236
- apiToken: process.env.CF_API_TOKEN!,
237
- }),
238
- };
236
+ // Only needed for non-Workers Cloudflare zones (Enterprise)
237
+ export const cdnPurge = new CloudflarePurge({
238
+ zoneId: process.env.CF_ZONE_ID!,
239
+ apiToken: process.env.CF_API_TOKEN!,
240
+ });
239
241
  ```
240
242
 
241
243
  Fastly:
242
244
 
243
- ```ts title="timber.config.ts"
245
+ ```ts title="timber.cache.ts"
244
246
  import { FastlyPurge } from '@timber-js/app/cdn/fastly';
245
247
 
246
- export default {
247
- cdnPurge: new FastlyPurge({
248
- serviceId: process.env.FASTLY_SERVICE_ID!,
249
- apiToken: process.env.FASTLY_API_TOKEN!,
250
- softPurge: true,
251
- }),
252
- };
248
+ export const cdnPurge = new FastlyPurge({
249
+ serviceId: process.env.FASTLY_SERVICE_ID!,
250
+ apiToken: process.env.FASTLY_API_TOKEN!,
251
+ softPurge: true,
252
+ });
253
253
  ```
254
254
 
255
255
  When `revalidateTag('products')` fires, it clears the application cache **and** calls the CDN purge handler. CDN purge is best-effort — failures are logged but don't break cache invalidation.
@@ -87,7 +87,7 @@ Special files in your route tree generate metadata endpoints:
87
87
 
88
88
  When `opengraph-image.tsx` exists in a segment, timber automatically emits both `<meta property="og:image">` and `<meta name="twitter:image">` in `<head>` — no need to declare them in your `metadata` export. The auto-linked URL includes a cache-busting hash so social platforms pick up changes on redeploy.
89
89
 
90
- If you declare `openGraph.images` in your metadata, the auto-linked tags are suppressed (user-declared metadata wins).
90
+ If you declare `openGraph.images` in your metadata, both auto-linked tags are suppressed. If you declare only `twitter.images`, the auto-linked `twitter:image` is suppressed but `og:image` is still emitted. User-declared metadata always wins.
91
91
 
92
92
  `favicon.tsx` lets you generate favicons dynamically (e.g. tenant branding). The URL stays `/favicon.ico` for browser compatibility.
93
93
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.167",
3
+ "version": "0.2.0-alpha.169",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -158,13 +158,13 @@
158
158
  },
159
159
  "dependencies": {
160
160
  "@opentelemetry/api": "^1.9.1",
161
- "@opentelemetry/context-async-hooks": "^2.8.0",
162
- "@opentelemetry/sdk-trace-base": "^2.8.0",
161
+ "@opentelemetry/context-async-hooks": "^2.9.0",
162
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
163
163
  "cookie": "^1.1.1",
164
+ "jsonc-parser": "^3.3.1",
164
165
  "magic-string": "^0.30.21",
165
166
  "nitro": "3.0.260610-beta",
166
- "srvx": "^0.11.17",
167
- "jsonc-parser": "^3.3.1"
167
+ "srvx": "^0.11.22"
168
168
  },
169
169
  "peerDependencies": {
170
170
  "@content-collections/core": "^0.14.0 || ^0.15.0",
@@ -176,7 +176,7 @@
176
176
  "nuqs": "^2.0.0",
177
177
  "react": "19.2.7",
178
178
  "react-dom": "19.2.7",
179
- "vite": "^8.1.0",
179
+ "vite": "8.1.0",
180
180
  "wrangler": "^4.0.0",
181
181
  "zod": "^3.22.0 || ^4.0.0"
182
182
  },
@@ -1,5 +1,6 @@
1
1
  import type { CacheHandler } from '../cache/index';
2
2
  import { getCloudflareBindings } from './cloudflare';
3
+ import { warnLossyJsonValue } from '../cache/json-lossy-check.js';
3
4
 
4
5
  /**
5
6
  * Cloudflare Workers KV interface — the subset of KVNamespace we depend on.
@@ -117,6 +118,7 @@ export class CloudflareKVCacheHandler implements CacheHandler {
117
118
  value: unknown,
118
119
  opts: { ttl: number; tags: string[]; generation?: number }
119
120
  ): Promise<void> {
121
+ warnLossyJsonValue(value, key);
120
122
  const kv = this.getKV();
121
123
  const entry: KVCacheEntry = {
122
124
  value,
@@ -127,12 +129,16 @@ export class CloudflareKVCacheHandler implements CacheHandler {
127
129
  // KV expirationTtl minimum is 60s. Use 2× TTL (min 60s) so stale entries
128
130
  // survive for staleWhileRevalidate background refetches.
129
131
  const kvTtl = Math.max(opts.ttl * 2, 60);
130
- await kv.put(this.dataKey(key), JSON.stringify(entry), { expirationTtl: kvTtl });
131
132
 
132
- // Update tag indexes — prune physically expired members on each write so
133
- // indexes don't grow unboundedly. Uses the physical KV expiry (2× TTL) as
134
- // the deadline, not the logical expiresAt, so stale entries in the SWR
135
- // window remain in the index for invalidate({tag}) to find.
133
+ // Serialize upfront so BigInt/non-serializable errors throw before any
134
+ // writes — prevents tag indexes pointing at stale data from a prior set().
135
+ const payload = JSON.stringify(entry);
136
+
137
+ // Tag indexes are written BEFORE the data key so that a partial network
138
+ // failure on the data write never leaves an entry invisible to
139
+ // invalidate({tag}). A dangling tag-index reference to a non-existent
140
+ // data key is harmless — invalidate() re-checks stored tags and cleans
141
+ // up on the next pass.
136
142
  const now = Date.now();
137
143
  const kvExpiresAt = now + kvTtl * 1000;
138
144
  await Promise.all(
@@ -145,6 +151,8 @@ export class CloudflareKVCacheHandler implements CacheHandler {
145
151
  });
146
152
  })
147
153
  );
154
+
155
+ await kv.put(this.dataKey(key), payload, { expirationTtl: kvTtl });
148
156
  }
149
157
 
150
158
  async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
@@ -175,7 +183,20 @@ export class CloudflareKVCacheHandler implements CacheHandler {
175
183
  if (opts.tag) {
176
184
  const entries = parseTagIndex(await kv.get(this.tagKey(opts.tag), { type: 'json' }));
177
185
  if (entries.length > 0) {
178
- await Promise.all(entries.map((e) => kv.delete(this.dataKey(e.key))));
186
+ // Re-check each member's stored tags before deleting — the tag index
187
+ // can contain stale references from partial writes or entries whose
188
+ // tags changed since they were indexed. Mirrors Redis handler behavior.
189
+ await Promise.all(
190
+ entries.map(async (e) => {
191
+ const raw = (await kv.get(this.dataKey(e.key), {
192
+ type: 'json',
193
+ })) as KVCacheEntry | null;
194
+ if (!raw) return;
195
+ if (raw.tags?.includes(opts.tag!)) {
196
+ await kv.delete(this.dataKey(e.key));
197
+ }
198
+ })
199
+ );
179
200
  }
180
201
  await kv.delete(this.tagKey(opts.tag));
181
202
  }
@@ -167,7 +167,7 @@ export class MemoryCacheHandler implements CacheHandler {
167
167
  }
168
168
 
169
169
  export { RedisCacheHandler } from './redis-handler';
170
- export type { RedisClient } from './redis-handler';
170
+ export type { RedisClient, RedisWriteOp } from './redis-handler';
171
171
  export { cache } from './cache-api';
172
172
  export { setCacheHandler, getCacheHandler } from './handler-store';
173
173
  export { setDefaultSingleflightTimeout } from './timber-cache';
@@ -0,0 +1,75 @@
1
+ const LOSSY_TYPES = ['Date', 'Map', 'Set', 'BigInt'] as const;
2
+
3
+ let hasWarned = false;
4
+
5
+ /**
6
+ * Dev-mode warning when a cache value contains types that lose fidelity
7
+ * through JSON round-trip. Only runs in development — no-op in production.
8
+ *
9
+ * Detects: Date (→ ISO string), Map/Set (→ {}), BigInt (→ throws).
10
+ * Emits once per process to avoid log spam.
11
+ */
12
+ export function warnLossyJsonValue(value: unknown, cacheKey: string): void {
13
+ if (hasWarned) return;
14
+ if (process.env.NODE_ENV === 'production') return;
15
+
16
+ const lossy = findLossyTypes(value, new Set(), 0);
17
+ if (lossy.size === 0) return;
18
+
19
+ hasWarned = true;
20
+ const types = [...lossy].join(', ');
21
+ console.warn(
22
+ `[timber] cache value for key "${cacheKey}" contains ${types} — ` +
23
+ 'these types lose fidelity through JSON serialization in Redis/KV cache handlers. ' +
24
+ 'Date becomes an ISO string, Map/Set become {}, BigInt throws. ' +
25
+ 'The MemoryCacheHandler preserves them. Consider transforming values ' +
26
+ 'to JSON-safe types before caching, or use MemoryCacheHandler for ' +
27
+ 'values that require type preservation.'
28
+ );
29
+ }
30
+
31
+ function findLossyTypes(value: unknown, seen: Set<unknown>, depth: number): Set<string> {
32
+ const lossy = new Set<string>();
33
+ if (depth > 8 || value === null || value === undefined) return lossy;
34
+ if (typeof value === 'bigint') {
35
+ lossy.add('BigInt');
36
+ return lossy;
37
+ }
38
+ if (typeof value !== 'object' && typeof value !== 'function') return lossy;
39
+ if (seen.has(value)) return lossy;
40
+ seen.add(value);
41
+
42
+ if (value instanceof Date) {
43
+ lossy.add('Date');
44
+ return lossy;
45
+ }
46
+ if (value instanceof Map) {
47
+ lossy.add('Map');
48
+ return lossy;
49
+ }
50
+ if (value instanceof Set) {
51
+ lossy.add('Set');
52
+ return lossy;
53
+ }
54
+
55
+ if (Array.isArray(value)) {
56
+ for (const item of value) {
57
+ for (const t of findLossyTypes(item, seen, depth + 1)) lossy.add(t);
58
+ if (lossy.size === LOSSY_TYPES.length) return lossy;
59
+ }
60
+ return lossy;
61
+ }
62
+
63
+ for (const k of Object.keys(value as Record<string, unknown>)) {
64
+ for (const t of findLossyTypes((value as Record<string, unknown>)[k], seen, depth + 1)) {
65
+ lossy.add(t);
66
+ }
67
+ if (lossy.size === LOSSY_TYPES.length) return lossy;
68
+ }
69
+ return lossy;
70
+ }
71
+
72
+ /** Reset warning state — for tests only. */
73
+ export function _resetLossyWarning(): void {
74
+ hasWarned = false;
75
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CacheHandler } from './index';
2
+ import { warnLossyJsonValue } from './json-lossy-check.js';
2
3
 
3
4
  /**
4
5
  * Minimal Redis client contract for `RedisCacheHandler`.
@@ -27,6 +28,19 @@ import type { CacheHandler } from './index';
27
28
  * srem: (k, ...m) => redis.srem(k, ...m),
28
29
  * smembers: (k) => redis.smembers(k),
29
30
  * ttl: (k) => redis.ttl(k),
31
+ * execMulti: async (ops) => {
32
+ * const p = redis.multi();
33
+ * for (const op of ops) {
34
+ * switch (op.op) {
35
+ * case 'set': p.set(op.key, op.value, 'EX', op.ttlSeconds); break;
36
+ * case 'sadd': p.sadd(op.key, ...op.members); break;
37
+ * case 'expireNX': p.expire(op.key, op.seconds, 'NX'); break;
38
+ * case 'expireGT': p.expire(op.key, op.seconds, 'GT'); break;
39
+ * case 'del': p.del(op.key); break;
40
+ * }
41
+ * }
42
+ * await p.exec();
43
+ * },
30
44
  * };
31
45
  * ```
32
46
  *
@@ -67,6 +81,13 @@ import type { CacheHandler } from './index';
67
81
  * };
68
82
  * ```
69
83
  */
84
+ export type RedisWriteOp =
85
+ | { op: 'set'; key: string; value: string; ttlSeconds: number }
86
+ | { op: 'sadd'; key: string; members: string[] }
87
+ | { op: 'expireNX'; key: string; seconds: number }
88
+ | { op: 'expireGT'; key: string; seconds: number }
89
+ | { op: 'del'; key: string };
90
+
70
91
  export interface RedisClient {
71
92
  /** `GET key` — the stored string, or null when absent. */
72
93
  get(key: string): Promise<string | null>;
@@ -99,6 +120,13 @@ export interface RedisClient {
99
120
  * not exist, -1 if the key exists but has no expiry.
100
121
  */
101
122
  ttl(key: string): Promise<number>;
123
+ /**
124
+ * Execute a batch of write operations atomically via `MULTI/EXEC`.
125
+ * When provided, `set()` uses this to make tag-index writes and the data
126
+ * write atomic — no `invalidate({tag})` can interleave. Optional: when
127
+ * absent, operations run sequentially (correct but not race-free).
128
+ */
129
+ execMulti?(ops: RedisWriteOp[]): Promise<void>;
102
130
  }
103
131
 
104
132
  const KEY_PREFIX = 'timber:cache:';
@@ -146,8 +174,12 @@ export class RedisCacheHandler implements CacheHandler {
146
174
  value: unknown,
147
175
  opts: { ttl: number; tags: string[]; generation?: number }
148
176
  ): Promise<void> {
177
+ warnLossyJsonValue(value, key);
149
178
  const ck = this.cacheKey(key);
150
179
  const expiresAt = Date.now() + opts.ttl * 1000;
180
+
181
+ // Serialize upfront so BigInt/non-serializable errors throw before any
182
+ // writes — prevents tag indexes pointing at stale data from a prior set().
151
183
  const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
152
184
 
153
185
  // Redis TTL with generous margin beyond the logical TTL to allow SWR reads
@@ -155,22 +187,41 @@ export class RedisCacheHandler implements CacheHandler {
155
187
  // We use 2x TTL + 60s as the Redis expiry so stale entries remain
156
188
  // available for SWR background refetches.
157
189
  const redisTtlSeconds = Math.max(opts.ttl * 2 + 60, 120);
158
- await this.client.set(ck, payload, redisTtlSeconds);
159
190
 
160
- // Track key membership in each tag set, and expire the set so it doesn't
161
- // outlive all its members. Only extend the TTL, never shorten — a short-TTL
162
- // entry must not shrink a tag set that already contains long-TTL entries,
163
- // otherwise invalidate({tag}) would miss the long-lived entries.
191
+ const writeOps: RedisWriteOp[] = [];
164
192
  for (const tag of opts.tags) {
165
- await this.client.sadd(this.tagKey(tag), key);
166
- // Two atomic EXPIRE calls cover both the initial-set and extend cases:
167
- // - NX: sets the TTL only if the key has no expiry (new tag set from SADD)
168
- // - GT: extends the TTL only if the new value is greater than current
169
- // Together they close the race where concurrent workers could shrink the
170
- // tag set lifetime (TIM-1093), while ensuring new tag sets always get an
171
- // initial expiry (without NX, GT is a no-op on non-volatile keys).
172
- await this.client.expireNX(this.tagKey(tag), redisTtlSeconds);
173
- await this.client.expireGT(this.tagKey(tag), redisTtlSeconds);
193
+ writeOps.push({ op: 'sadd', key: this.tagKey(tag), members: [key] });
194
+ writeOps.push({ op: 'expireNX', key: this.tagKey(tag), seconds: redisTtlSeconds });
195
+ writeOps.push({ op: 'expireGT', key: this.tagKey(tag), seconds: redisTtlSeconds });
196
+ }
197
+ writeOps.push({ op: 'set', key: ck, value: payload, ttlSeconds: redisTtlSeconds });
198
+
199
+ if (this.client.execMulti) {
200
+ await this.client.execMulti(writeOps);
201
+ } else {
202
+ for (const op of writeOps) {
203
+ await this.execOp(op);
204
+ }
205
+ }
206
+ }
207
+
208
+ private async execOp(op: RedisWriteOp): Promise<void> {
209
+ switch (op.op) {
210
+ case 'set':
211
+ await this.client.set(op.key, op.value, op.ttlSeconds);
212
+ break;
213
+ case 'sadd':
214
+ await this.client.sadd(op.key, ...op.members);
215
+ break;
216
+ case 'expireNX':
217
+ await this.client.expireNX(op.key, op.seconds);
218
+ break;
219
+ case 'expireGT':
220
+ await this.client.expireGT(op.key, op.seconds);
221
+ break;
222
+ case 'del':
223
+ await this.client.del(op.key);
224
+ break;
174
225
  }
175
226
  }
176
227
 
@@ -7,7 +7,7 @@ import {
7
7
  wasInvalidatedSince,
8
8
  } from './invalidation-epoch';
9
9
  import { addSpanEventSync } from '../server/tracing.js';
10
- import { logSwrRefetchFailed } from '../server/logger.js';
10
+ import { logSwrRefetchFailed, swallow } from '../server/logger.js';
11
11
  import { getWaitUntil } from '../server/waituntil-bridge.js';
12
12
  import { fnv1aHash } from './fast-hash.js';
13
13
 
@@ -189,7 +189,15 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
189
189
  const generation = nextGeneration(key);
190
190
  const result = await fn(...args);
191
191
  if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
192
- await getHandler().set(key, result, { ttl: opts.ttl, tags, generation });
192
+ try {
193
+ await getHandler().set(key, result, { ttl: opts.ttl, tags, generation });
194
+ } catch (err) {
195
+ // Cache-write failure must not fail the request (TIM-1046).
196
+ // JSON-serializing handlers (Redis, KV) throw on BigInt; network
197
+ // errors can surface from any shared handler. The result is valid
198
+ // — serve it uncached rather than rejecting a successful fn().
199
+ swallow(err, `cache set failed for key "${key}"`);
200
+ }
193
201
  }
194
202
  return result;
195
203
  };
@@ -76,6 +76,8 @@ function bootstrap(runtimeConfig: typeof config): void {
76
76
  // Step 3: Create router + Navigation API integration
77
77
  const { router, navApiController } = createTimberRouter({
78
78
  initialElement: rscResult?.element ?? undefined,
79
+ clientSegmentCache:
80
+ ((runtimeConfig as Record<string, unknown>).clientSegmentCache as boolean) ?? false,
79
81
  });
80
82
 
81
83
  // Step 4: Pre-hydration — set params + navigation state (MUST run after router init)
@@ -29,6 +29,7 @@ import { getScrollY, scrollToHashTarget } from './scroll.js';
29
29
 
30
30
  export interface RouterInitOptions {
31
31
  initialElement?: unknown;
32
+ clientSegmentCache?: boolean;
32
33
  }
33
34
 
34
35
  export interface RouterInitResult {
@@ -136,6 +137,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
136
137
  };
137
138
 
138
139
  const deps: RouterDeps = {
140
+ clientSegmentCache: options?.clientSegmentCache ?? false,
139
141
  fetch: (url, init) => window.fetch(url, init),
140
142
  pushState: (data, unused, url) => {
141
143
  gFlags[ROUTER_HISTORY_FLAG] = true;
@@ -155,6 +155,13 @@ export interface RouterDeps {
155
155
  * as a full page load with an unknown fragment landing at the top.
156
156
  */
157
157
  scrollToHash?: (hash: string) => boolean;
158
+
159
+ /**
160
+ * Whether the client segment cache is enabled. When false (the default),
161
+ * the router does not send X-Timber-State-Tree headers and does not
162
+ * populate the segment cache. Every navigation gets a full RSC payload.
163
+ */
164
+ clientSegmentCache?: boolean;
158
165
  }
159
166
 
160
167
  export interface RouterInstance {
@@ -292,6 +299,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
292
299
 
293
300
  /** Update the segment cache from server-provided segment metadata. */
294
301
  function updateSegmentCache(segmentInfo: SegmentInfo[] | null | undefined): void {
302
+ if (!deps.clientSegmentCache) return;
295
303
  if (!segmentInfo || segmentInfo.length === 0) return;
296
304
  const tree = buildSegmentTree(segmentInfo);
297
305
  if (tree) {
@@ -540,7 +548,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
540
548
  if (result === undefined) {
541
549
  // Fetch RSC payload with state tree for partial rendering.
542
550
  // Send current URL for intercepting route resolution (modal pattern).
543
- const stateTree = segmentCache.serializeStateTree();
551
+ const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
544
552
  const rawCurrentUrl = deps.getCurrentUrl();
545
553
  const currentUrl = rawCurrentUrl.startsWith('http')
546
554
  ? new URL(rawCurrentUrl).pathname
@@ -734,7 +742,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
734
742
  url,
735
743
  async (navAbort) => {
736
744
  await renderViaTransition(url, async () => {
737
- const stateTree = segmentCache.serializeStateTree();
745
+ const stateTree = deps.clientSegmentCache
746
+ ? segmentCache.serializeStateTree()
747
+ : undefined;
738
748
  const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
739
749
  const payload = await resolveForFallback(result.payload);
740
750
  const navState = commitNavigation(url, {
@@ -767,7 +777,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
767
777
  if (historyStack.has(fetchUrl)) return;
768
778
 
769
779
  // Fire-and-forget fetch
770
- const stateTree = segmentCache.serializeStateTree();
780
+ const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
771
781
  void fetchRscPayload(fetchUrl, deps, stateTree).then(
772
782
  (result) => {
773
783
  result.decodePromise?.catch(() => {});
package/src/codec.ts CHANGED
@@ -80,8 +80,10 @@ export const codec = {
80
80
  integer: {
81
81
  parse(value: string | string[] | undefined): number {
82
82
  const str = Array.isArray(value) ? value[0] : value;
83
+ if (!str || !/^(?:0|-?[1-9]\d*)$/.test(str))
84
+ throw new Error(`Expected integer, got '${str}'`);
83
85
  const n = Number(str);
84
- if (!Number.isInteger(n)) throw new Error(`Expected integer, got '${str}'`);
86
+ if (!Number.isSafeInteger(n)) throw new Error(`Expected integer, got '${str}'`);
85
87
  return n;
86
88
  },
87
89
  serialize(value: number): string | null {
@@ -46,9 +46,6 @@ export interface TimberUserConfig {
46
46
  */
47
47
  clientJavascript?: boolean | ClientJavascriptConfig;
48
48
  adapter?: unknown;
49
- cacheHandler?: unknown;
50
- /** CDN purge handler — called on revalidateTag/cache.invalidate to purge CDN-cached responses. */
51
- cdnPurge?: unknown;
52
49
  allowedOrigins?: string[];
53
50
  csrf?: boolean;
54
51
  limits?: {
@@ -56,40 +53,6 @@ export interface TimberUserConfig {
56
53
  uploadBodySize?: string;
57
54
  maxFields?: number;
58
55
  };
59
- /**
60
- * Server-action form handling.
61
- *
62
- * See design/08-forms-and-actions.md §"Validation errors" and
63
- * design/13-security.md §"Sensitive field stripping".
64
- */
65
- /**
66
- * Server action runtime behavior.
67
- *
68
- * See design/08-forms-and-actions.md §"Middleware for Server Actions".
69
- */
70
- actions?: {
71
- /**
72
- * Run `middleware.ts` on server action requests before dispatching.
73
- *
74
- * **Default: `true`** — middleware runs on every action POST so
75
- * authentication, rate limiting, tenant isolation, IP allow-listing,
76
- * and request-header injection apply uniformly to page renders, route
77
- * handlers, and server actions. This closes the auth-bypass class of
78
- * issue identified by Next.js CVE-2025-29927: developers can put a
79
- * single auth check in `middleware.ts` and trust that it gates every
80
- * unsafe-method request.
81
- *
82
- * Set to `false` to restore the legacy behavior where actions skip
83
- * middleware entirely. This is **not recommended** outside of niche
84
- * cases (e.g. middleware that rewrites POST bodies and would corrupt
85
- * action submissions). When false, you are responsible for placing
86
- * auth, rate limiting, and other cross-cutting checks inside every
87
- * action — typically via `createActionClient({ middleware: ... })`.
88
- *
89
- * See TIM-871.
90
- */
91
- runMiddleware?: boolean;
92
- };
93
56
  forms?: {
94
57
  /**
95
58
  * Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
@@ -286,6 +249,22 @@ export interface TimberUserConfig {
286
249
  * ```
287
250
  */
288
251
  buildDir?: string;
252
+ /**
253
+ * Enable the client-side segment cache for partial navigation.
254
+ *
255
+ * When enabled, the router serializes the mounted segment tree into an
256
+ * `X-Timber-State-Tree` header on every client navigation. The server
257
+ * skips re-rendering unchanged sync layouts and sends a partial RSC
258
+ * payload. The client merges the partial payload with its cached segments.
259
+ *
260
+ * When disabled (the default), every navigation gets a full RSC payload.
261
+ *
262
+ * This is a performance optimization only — not a security boundary.
263
+ * The server always runs all `access.ts` files regardless.
264
+ *
265
+ * See design/19-client-navigation.md §"X-Timber-State-Tree Header"
266
+ */
267
+ clientSegmentCache?: boolean;
289
268
  topLoader?: {
290
269
  /** Whether the top-loader is enabled. Default: true. */
291
270
  enabled?: boolean;
@@ -30,12 +30,9 @@ const KNOWN_CONFIG_KEYS = {
30
30
  debug: true,
31
31
  clientJavascript: true,
32
32
  adapter: true,
33
- cacheHandler: true,
34
- cdnPurge: true,
35
33
  allowedOrigins: true,
36
34
  csrf: true,
37
35
  limits: true,
38
- actions: true,
39
36
  forms: true,
40
37
  pageExtensions: true,
41
38
  slowRequestMs: true,
@@ -49,6 +46,7 @@ const KNOWN_CONFIG_KEYS = {
49
46
  reactCompiler: true,
50
47
  sitemap: true,
51
48
  buildDir: true,
49
+ clientSegmentCache: true,
52
50
  topLoader: true,
53
51
  budget: true,
54
52
  } satisfies Record<keyof TimberUserConfig, true>;
@@ -197,12 +195,16 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
197
195
  }
198
196
  }
199
197
 
198
+ const MOVED_TO_CACHE_FILE = new Set(['cacheHandler', 'cdnPurge']);
199
+
200
200
  for (const key of Object.keys(config)) {
201
201
  if (!knownKeys.has(key)) {
202
202
  errors.push({
203
203
  field: key,
204
204
  message: `Unknown config option: "${key}".`,
205
- suggestion: `Check for typos. Known options: ${[...knownKeys].sort().join(', ')}.`,
205
+ suggestion: MOVED_TO_CACHE_FILE.has(key)
206
+ ? `"${key}" has moved to timber.cache.ts. Export cacheHandler as default, cdnPurge as a named export.`
207
+ : `Check for typos. Known options: ${[...knownKeys].sort().join(', ')}.`,
206
208
  });
207
209
  }
208
210
  }
@@ -248,7 +250,7 @@ const VIRTUAL_MODULE_NAMES: Record<string, string> = {
248
250
  'virtual:timber-config': 'Runtime config (timber.config.ts)',
249
251
  'virtual:timber-route-manifest': 'Route manifest (app/ file tree)',
250
252
  'virtual:timber-instrumentation': 'Instrumentation (instrumentation.ts)',
251
- 'virtual:timber-cache-handler': 'Cache handler (cacheHandler config)',
253
+ 'virtual:timber-cache-handler': 'Cache handler (timber.cache.ts)',
252
254
  'virtual:timber-build-manifest': 'Build manifest (asset mapping)',
253
255
  };
254
256
 
@@ -208,6 +208,38 @@ export class FontPipeline {
208
208
  }
209
209
  }
210
210
 
211
+ /** True if at least one entry is registered under `importer`. */
212
+ hasImporter(importer: string): boolean {
213
+ for (const entry of this._entries.values()) {
214
+ if (entry.importer === importer) return true;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ /**
220
+ * Drop **every** registry entry that originated from `importer`,
221
+ * regardless of family.
222
+ *
223
+ * Called at the start of the transform hook before re-registering the
224
+ * current extraction set. This handles the cases `pruneFor` misses:
225
+ *
226
+ * - **Family rename** (Inter → Roboto): the old family's entries are
227
+ * cleared because we prune by importer, not by (importer, family).
228
+ * - **Font call deleted**: the old entries are cleared even though no
229
+ * new call provides a family to prune against.
230
+ *
231
+ * Other importers' fonts are untouched.
232
+ *
233
+ * See TIM-1087.
234
+ */
235
+ pruneImporter(importer: string): void {
236
+ for (const [id, entry] of this._entries) {
237
+ if (entry.importer === importer) {
238
+ this._entries.delete(id);
239
+ }
240
+ }
241
+ }
242
+
211
243
  /** Drop every registered font. Used by tests and rebuild flows. */
212
244
  clear(): void {
213
245
  this._entries.clear();