@timber-js/app 0.2.0-alpha.166 → 0.2.0-alpha.168

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 (188) hide show
  1. package/dist/_chunks/{actions-CDPfMp_I.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-CDPfMp_I.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-DygSeKCB.js → cache-api-B-lhk9p4.js} +107 -58
  4. package/dist/_chunks/cache-api-B-lhk9p4.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-EXGYPhI2.js → cli-schema-sync-B73L6pMq.js} +5 -3
  6. package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +1 -0
  7. package/dist/_chunks/cloudflare-AHoWYTYr.js +1188 -0
  8. package/dist/_chunks/cloudflare-AHoWYTYr.js.map +1 -0
  9. package/dist/_chunks/json-lossy-check-ClNvBM_3.js +63 -0
  10. package/dist/_chunks/json-lossy-check-ClNvBM_3.js.map +1 -0
  11. package/dist/_chunks/{logger-t3uxAmbX.js → logger-AWfuX-KJ.js} +2 -19
  12. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  13. package/dist/_chunks/{walkers-DBVzXuWc.js → walkers-CoOC8Hga.js} +2 -2
  14. package/dist/_chunks/{walkers-DBVzXuWc.js.map → walkers-CoOC8Hga.js.map} +1 -1
  15. package/dist/adapters/cloudflare-dev.js +1 -1
  16. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  17. package/dist/adapters/cloudflare-kv-cache.js +10 -3
  18. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  19. package/dist/adapters/cloudflare.d.ts +12 -1
  20. package/dist/adapters/cloudflare.d.ts.map +1 -1
  21. package/dist/adapters/cloudflare.js +2 -461
  22. package/dist/adapters/types.d.ts +2 -0
  23. package/dist/adapters/types.d.ts.map +1 -1
  24. package/dist/cache/cache-api.d.ts +33 -11
  25. package/dist/cache/cache-api.d.ts.map +1 -1
  26. package/dist/cache/index.d.ts +1 -1
  27. package/dist/cache/index.d.ts.map +1 -1
  28. package/dist/cache/index.js +1 -1
  29. package/dist/cache/json-lossy-check.d.ts +11 -0
  30. package/dist/cache/json-lossy-check.d.ts.map +1 -0
  31. package/dist/cache/redis-handler.d.ts +42 -0
  32. package/dist/cache/redis-handler.d.ts.map +1 -1
  33. package/dist/cache/singleflight.d.ts.map +1 -1
  34. package/dist/cache/stores/cloudflare-kv.d.ts +1 -1
  35. package/dist/cache/stores/cloudflare-kv.d.ts.map +1 -1
  36. package/dist/cache/stores/memory.d.ts +1 -1
  37. package/dist/cache/stores/memory.d.ts.map +1 -1
  38. package/dist/cache/stores/redis.d.ts +1 -1
  39. package/dist/cache/stores/redis.d.ts.map +1 -1
  40. package/dist/cache/stores/vercel.d.ts +1 -1
  41. package/dist/cache/stores/vercel.d.ts.map +1 -1
  42. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  43. package/dist/cache/timber-cache.d.ts.map +1 -1
  44. package/dist/cdn/cloudflare-purge.d.ts.map +1 -1
  45. package/dist/cdn/fastly-purge.d.ts.map +1 -1
  46. package/dist/cdn/workers-cache-purge.d.ts.map +1 -1
  47. package/dist/cli.d.ts +1 -1
  48. package/dist/cli.d.ts.map +1 -1
  49. package/dist/cli.js +3 -2
  50. package/dist/cli.js.map +1 -1
  51. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  52. package/dist/client/browser-entry/router-init.d.ts +1 -0
  53. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  54. package/dist/client/child-segment-context.d.ts +2 -2
  55. package/dist/client/child-segment-context.d.ts.map +1 -1
  56. package/dist/client/error-boundary.d.ts.map +1 -1
  57. package/dist/client/history.d.ts +10 -0
  58. package/dist/client/history.d.ts.map +1 -1
  59. package/dist/client/internal.js +141 -116
  60. package/dist/client/internal.js.map +1 -1
  61. package/dist/client/router.d.ts +6 -0
  62. package/dist/client/router.d.ts.map +1 -1
  63. package/dist/client/rsc-fetch.d.ts.map +1 -1
  64. package/dist/client/segment-cache.d.ts.map +1 -1
  65. package/dist/client/segment-update-context.d.ts +3 -3
  66. package/dist/client/segment-update-context.d.ts.map +1 -1
  67. package/dist/client/slot-outlet.d.ts +1 -1
  68. package/dist/codec.d.ts.map +1 -1
  69. package/dist/codec.js +2 -1
  70. package/dist/codec.js.map +1 -1
  71. package/dist/config-types.d.ts +16 -37
  72. package/dist/config-types.d.ts.map +1 -1
  73. package/dist/config-validation.d.ts.map +1 -1
  74. package/dist/dev-tools/instrumentation.d.ts.map +1 -1
  75. package/dist/fonts/pipeline.d.ts +19 -0
  76. package/dist/fonts/pipeline.d.ts.map +1 -1
  77. package/dist/fonts/transform.d.ts.map +1 -1
  78. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  79. package/dist/index.d.ts.map +1 -1
  80. package/dist/index.js +378 -109
  81. package/dist/index.js.map +1 -1
  82. package/dist/plugin-context.d.ts.map +1 -1
  83. package/dist/plugins/adapter-build.d.ts +1 -0
  84. package/dist/plugins/adapter-build.d.ts.map +1 -1
  85. package/dist/plugins/cache.d.ts +8 -8
  86. package/dist/plugins/cache.d.ts.map +1 -1
  87. package/dist/plugins/entries.d.ts +27 -3
  88. package/dist/plugins/entries.d.ts.map +1 -1
  89. package/dist/plugins/fonts.d.ts.map +1 -1
  90. package/dist/plugins/server-bundle.d.ts.map +1 -1
  91. package/dist/routing/index.js +2 -2
  92. package/dist/server/action-client.d.ts +1 -1
  93. package/dist/server/action-client.d.ts.map +1 -1
  94. package/dist/server/als-registry.d.ts +7 -0
  95. package/dist/server/als-registry.d.ts.map +1 -1
  96. package/dist/server/body-limits.d.ts.map +1 -1
  97. package/dist/server/deny-boundary.d.ts +3 -1
  98. package/dist/server/deny-boundary.d.ts.map +1 -1
  99. package/dist/server/form-data.d.ts.map +1 -1
  100. package/dist/server/html-injector-core.d.ts.map +1 -1
  101. package/dist/server/index.js +20 -3
  102. package/dist/server/index.js.map +1 -1
  103. package/dist/server/internal.js +28 -41
  104. package/dist/server/internal.js.map +1 -1
  105. package/dist/server/middleware-runner.d.ts +0 -17
  106. package/dist/server/middleware-runner.d.ts.map +1 -1
  107. package/dist/server/param-coercion.d.ts.map +1 -1
  108. package/dist/server/pipeline-phases.d.ts +6 -3
  109. package/dist/server/pipeline-phases.d.ts.map +1 -1
  110. package/dist/server/pipeline.d.ts +18 -0
  111. package/dist/server/pipeline.d.ts.map +1 -1
  112. package/dist/server/prebuilt/capture-state.d.ts.map +1 -1
  113. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  114. package/dist/server/primitives.d.ts.map +1 -1
  115. package/dist/server/render-timeout.d.ts.map +1 -1
  116. package/dist/server/route-element-builder.d.ts +9 -0
  117. package/dist/server/route-element-builder.d.ts.map +1 -1
  118. package/dist/server/rsc-entry/action-middleware-runner.d.ts +14 -14
  119. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/render-route.d.ts +1 -0
  121. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  122. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  123. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  124. package/dist/server/rsc-entry/rsc-stream.d.ts +8 -0
  125. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  126. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts +44 -74
  127. package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +1 -1
  128. package/dist/server/safe-load.d.ts.map +1 -1
  129. package/dist/server/ssr-entry.d.ts.map +1 -1
  130. package/dist/shared/redirect-type.d.ts +2 -2
  131. package/dist/shared/redirect-type.d.ts.map +1 -1
  132. package/dist/shims/font-google.d.ts.map +1 -1
  133. package/dist/shims/image.d.ts +120 -120
  134. package/dist/shims/image.d.ts.map +1 -1
  135. package/docs/api/32-api-cache.mdx +116 -4
  136. package/docs/api/34-api-config.mdx +0 -26
  137. package/docs/learn/09-caching.mdx +126 -10
  138. package/docs/learn/12-client-navigation.mdx +9 -1
  139. package/docs/learn/13-configuration.mdx +6 -8
  140. package/docs/learn/14-deploying.mdx +18 -18
  141. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  142. package/docs/more/04-metadata-and-fonts.mdx +1 -1
  143. package/docs/more/50-ai-agent-instructions.mdx +5 -5
  144. package/package.json +6 -5
  145. package/src/adapters/cloudflare-kv-cache.ts +27 -6
  146. package/src/adapters/cloudflare.ts +63 -25
  147. package/src/adapters/types.ts +2 -0
  148. package/src/cache/cache-api.ts +84 -84
  149. package/src/cache/index.ts +1 -1
  150. package/src/cache/json-lossy-check.ts +75 -0
  151. package/src/cache/redis-handler.ts +65 -14
  152. package/src/cache/timber-cache.ts +10 -2
  153. package/src/client/browser-entry/index.ts +2 -0
  154. package/src/client/browser-entry/post-hydration.ts +16 -9
  155. package/src/client/browser-entry/router-init.ts +2 -0
  156. package/src/client/history.ts +11 -0
  157. package/src/client/router.ts +224 -208
  158. package/src/codec.ts +3 -1
  159. package/src/config-types.ts +16 -37
  160. package/src/config-validation.ts +7 -5
  161. package/src/fonts/pipeline.ts +32 -0
  162. package/src/fonts/transform.ts +30 -24
  163. package/src/plugins/adapter-build.ts +132 -13
  164. package/src/plugins/cache.ts +45 -30
  165. package/src/plugins/entries.ts +40 -43
  166. package/src/plugins/fonts.ts +30 -0
  167. package/src/plugins/server-bundle.ts +7 -15
  168. package/src/routing/scanner.ts +7 -0
  169. package/src/server/action-client.ts +1 -1
  170. package/src/server/als-registry.ts +7 -0
  171. package/src/server/deny-boundary.ts +6 -3
  172. package/src/server/middleware-runner.ts +0 -44
  173. package/src/server/param-coercion.ts +1 -0
  174. package/src/server/pipeline-phases.ts +11 -13
  175. package/src/server/pipeline.ts +51 -3
  176. package/src/server/route-element-builder.ts +59 -9
  177. package/src/server/rsc-entry/action-middleware-runner.ts +14 -14
  178. package/src/server/rsc-entry/index.ts +30 -40
  179. package/src/server/rsc-entry/render-route.ts +6 -2
  180. package/src/server/rsc-entry/rsc-payload.ts +21 -4
  181. package/src/server/rsc-entry/rsc-stream.ts +8 -0
  182. package/src/server/rsc-entry/wrap-action-dispatch.ts +109 -372
  183. package/dist/_chunks/cache-api-DygSeKCB.js.map +0 -1
  184. package/dist/_chunks/cli-schema-sync-EXGYPhI2.js.map +0 -1
  185. package/dist/_chunks/logger-t3uxAmbX.js.map +0 -1
  186. package/dist/_chunks/tree-match-D2l830j2.js +0 -102
  187. package/dist/_chunks/tree-match-D2l830j2.js.map +0 -1
  188. package/dist/adapters/cloudflare.js.map +0 -1
@@ -6,102 +6,102 @@ import { recordInvalidation } from './invalidation-epoch';
6
6
  import { writeTombstonesForTag } from '../server/prebuilt/overlay.js';
7
7
  import { getCdnPurgeHandler } from '../cdn/purge-store.js';
8
8
 
9
+ const componentFallbackWarned = new WeakSet<object>();
10
+
9
11
  /**
10
- * Public caching API: `cache(fn, opts)`.
11
- *
12
- * Wraps an async function with cross-request caching. Uses the configured
13
- * cache handler (defaults to MemoryCacheHandler, overridable via timber.config.ts).
12
+ * Cache namespace — `cache.data`, `cache.component`, `cache.invalidate`.
14
13
  *
15
14
  * ```ts
16
15
  * import { cache } from '@timber-js/app/cache';
17
16
  *
18
- * const getUser = cache(
17
+ * const getUser = cache.data(
19
18
  * async (id: string) => db.users.findUnique({ where: { id } }),
20
19
  * { ttl: 60, tags: (id) => [`user:${id}`] }
21
20
  * );
22
21
  * ```
23
22
  */
24
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
- export function cache<Fn extends (...args: any[]) => Promise<any>>(
26
- fn: Fn,
27
- opts: CacheOptions<Fn>,
28
- stableId?: string
29
- ): Fn {
30
- // Pass the getter, not a resolved handler: the wrapper re-resolves it on
31
- // every call, so module-scope wrappers created before the framework's boot
32
- // wiring calls setCacheHandler() still pick up the configured handler
33
- // instead of permanently binding the in-memory fallback (TIM-1029).
34
- return createCache(fn, opts, getCacheHandler, stableId);
35
- }
23
+ export const cache = {
24
+ /**
25
+ * Wrap an async function with cross-request caching.
26
+ *
27
+ * The configured cache handler (defaults to MemoryCacheHandler, overridable
28
+ * via timber.config.ts) is re-resolved on every call, so module-scope
29
+ * wrappers created before `setCacheHandler()` still pick up the configured
30
+ * handler (TIM-1029).
31
+ */
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ data<Fn extends (...args: any[]) => Promise<any>>(
34
+ fn: Fn,
35
+ opts: CacheOptions<Fn>,
36
+ stableId?: string
37
+ ): Fn {
38
+ return createCache(fn, opts, getCacheHandler, stableId);
39
+ },
36
40
 
37
- /**
38
- * Invalidate cache entries by tag or key.
39
- *
40
- * ```ts
41
- * cache.invalidate({ tag: 'products' });
42
- * cache.invalidate({ key: 'user:abc' });
43
- * ```
44
- */
45
- cache.invalidate = async function invalidate(opts: { key?: string; tag?: string }): Promise<void> {
46
- // Record before the handler delete so a cached fn in flight skips its
47
- // post-resolve set instead of resurrecting the invalidated value (TIM-1028).
48
- recordInvalidation(opts);
49
- await getCacheHandler().invalidate(opts);
50
- // Write tombstones for build-seed entries carrying the tag (design/45
51
- // §ISR mechanics). Runs AFTER handler.invalidate so the tombstone
52
- // overwrites the cleared overlay slot. This covers ALL invalidation
53
- // paths — actions, webhooks, route handlers — not just executeAction.
54
- if (opts.tag) {
55
- await writeTombstonesForTag(opts.tag);
56
- }
57
- // Trigger CDN purge if a handler is configured (design/25 §Layer 2).
58
- // Purge is best-effort: a CDN API failure or timeout must not block
59
- // cache invalidation. 10s ceiling prevents a hung CDN API from stalling
60
- // the entire revalidateTag/action flow.
61
- if (opts.tag) {
62
- const purgeHandler = getCdnPurgeHandler();
63
- if (purgeHandler) {
64
- try {
65
- await Promise.race([
66
- purgeHandler.purgeTags([opts.tag]),
67
- new Promise<void>((_, reject) =>
68
- setTimeout(() => reject(new Error('CDN purge timed out (10s)')), 10_000)
69
- ),
70
- ]);
71
- } catch (err) {
72
- console.error('[timber] CDN purge failed for tag:', err);
41
+ /**
42
+ * Invalidate cache entries by tag or key.
43
+ *
44
+ * ```ts
45
+ * cache.invalidate({ tag: 'products' });
46
+ * cache.invalidate({ key: 'user:abc' });
47
+ * ```
48
+ */
49
+ async invalidate(opts: { key?: string; tag?: string }): Promise<void> {
50
+ // Record before the handler delete so a cached fn in flight skips its
51
+ // post-resolve set instead of resurrecting the invalidated value (TIM-1028).
52
+ recordInvalidation(opts);
53
+ await getCacheHandler().invalidate(opts);
54
+ // Write tombstones for build-seed entries carrying the tag (design/45
55
+ // §ISR mechanics). Runs AFTER handler.invalidate so the tombstone
56
+ // overwrites the cleared overlay slot. This covers ALL invalidation
57
+ // paths — actions, webhooks, route handlers — not just executeAction.
58
+ if (opts.tag) {
59
+ await writeTombstonesForTag(opts.tag);
60
+ }
61
+ // Trigger CDN purge if a handler is configured (design/25 §Layer 2).
62
+ // Purge is best-effort: a CDN API failure or timeout must not block
63
+ // cache invalidation. 10s ceiling prevents a hung CDN API from stalling
64
+ // the entire revalidateTag/action flow.
65
+ if (opts.tag) {
66
+ const purgeHandler = getCdnPurgeHandler();
67
+ if (purgeHandler) {
68
+ try {
69
+ await Promise.race([
70
+ purgeHandler.purgeTags([opts.tag]),
71
+ new Promise<void>((_, reject) =>
72
+ setTimeout(() => reject(new Error('CDN purge timed out (10s)')), 10_000)
73
+ ),
74
+ ]);
75
+ } catch (err) {
76
+ console.error('[timber] CDN purge failed for tag:', err);
77
+ }
73
78
  }
74
79
  }
75
- }
76
- };
77
-
78
- const componentFallbackWarned = new WeakSet<object>();
80
+ },
79
81
 
80
- /**
81
- * Runtime fallback for `cache.component(...)` callsites the timber-prebuilt
82
- * transform does not rewrite — nested/computed/argument-position forms (see
83
- * plugins/prebuilt.ts). Only module-scope `const X = cache.component(...)`
84
- * declarations are transformed and eligible for prebuilding; everything else
85
- * lands here, warns once per component, and renders dynamically. Same
86
- * safety-net pattern as the data cache's runtime fnId fallback (TIM-1054):
87
- * an untraceable callsite degrades in performance, never in correctness.
88
- *
89
- * The full public surface (types, docs, `timber` namespace) is TIM-1121;
90
- * design/45-cache-lifetimes.md specifies the options.
91
- */
92
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
93
- cache.component = function component<C extends (props: any) => unknown>(
94
- componentFn: C,
95
- _options?: PrebuiltComponentOptions
96
- ): C {
97
- if (!componentFallbackWarned.has(componentFn)) {
98
- componentFallbackWarned.add(componentFn);
99
- const name = componentFn.name || 'anonymous component';
100
- console.warn(
101
- `[timber] cache.component: "${name}" was not transformed at build time — ` +
102
- `rendering dynamically. Only module-scope declarations ` +
103
- `(const X = cache.component(...)) are prebuilt.`
104
- );
105
- }
106
- return componentFn;
82
+ /**
83
+ * Runtime fallback for `cache.component(...)` callsites the timber-prebuilt
84
+ * transform does not rewrite — nested/computed/argument-position forms (see
85
+ * plugins/prebuilt.ts). Only module-scope `const X = cache.component(...)`
86
+ * declarations are transformed and eligible for prebuilding; everything else
87
+ * lands here, warns once per component, and renders dynamically. Same
88
+ * safety-net pattern as the data cache's runtime fnId fallback (TIM-1054):
89
+ * an untraceable callsite degrades in performance, never in correctness.
90
+ */
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ component<C extends (props: any) => unknown>(
93
+ componentFn: C,
94
+ _options?: PrebuiltComponentOptions
95
+ ): C {
96
+ if (!componentFallbackWarned.has(componentFn)) {
97
+ componentFallbackWarned.add(componentFn);
98
+ const name = componentFn.name || 'anonymous component';
99
+ console.warn(
100
+ `[timber] cache.component: "${name}" was not transformed at build time — ` +
101
+ `rendering dynamically. Only module-scope declarations ` +
102
+ `(const X = cache.component(...)) are prebuilt.`
103
+ );
104
+ }
105
+ return componentFn;
106
+ },
107
107
  };
@@ -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)
@@ -38,9 +38,25 @@ export function setupPostHydration({
38
38
  // runPreHydration() — without them on the entry, the cached replay on
39
39
  // back-navigation would normalize params to {} and wipe every
40
40
  // useSegmentParams() consumer on the initial page (TIM-1037).
41
+ // Populate the segment cache from server-embedded segment metadata.
42
+ // This enables state tree diffing from the very first client navigation.
43
+ // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
44
+ const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
45
+ const initialSegmentInfo = Array.isArray(timberSegments)
46
+ ? (timberSegments as import('../segment-cache').SegmentInfo[])
47
+ : null;
48
+ if (initialSegmentInfo) {
49
+ router.initSegmentCache(initialSegmentInfo);
50
+ delete (self as unknown as Record<string, unknown>).__timber_segments;
51
+ }
52
+
53
+ // Store the initial page in the history stack so back-button works
54
+ // after the first navigation. Include segmentInfo so the segment
55
+ // cache is restored correctly on popstate cached replay.
41
56
  router.historyStack.push(window.location.pathname + window.location.search, {
42
57
  payload: initialElement,
43
58
  params: getNavigationState().params,
59
+ segmentInfo: initialSegmentInfo,
44
60
  });
45
61
 
46
62
  // Initialize scroll state for the initial entry.
@@ -52,15 +68,6 @@ export function setupPostHydration({
52
68
  window.history.replaceState({ timber: true, scrollY: 0 }, '');
53
69
  }
54
70
 
55
- // Populate the segment cache from server-embedded segment metadata.
56
- // This enables state tree diffing from the very first client navigation.
57
- // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
58
- const timberSegments = (self as unknown as Record<string, unknown>).__timber_segments;
59
- if (Array.isArray(timberSegments)) {
60
- router.initSegmentCache(timberSegments);
61
- delete (self as unknown as Record<string, unknown>).__timber_segments;
62
- }
63
-
64
71
  // Register popstate handler for back/forward navigation.
65
72
  // When Navigation API is active, the navigate event covers traversals —
66
73
  // popstate is a no-op. When unavailable, popstate handles back/forward.
@@ -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;
@@ -1,6 +1,8 @@
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 { SegmentInfo } from './segment-cache';
5
+
4
6
  // ─── Types ───────────────────────────────────────────────────────
5
7
 
6
8
  export interface HistoryEntry {
@@ -13,6 +15,15 @@ export interface HistoryEntry {
13
15
  * and revalidation overwrites preserve the current state (TIM-1037).
14
16
  */
15
17
  params?: Record<string, string | string[]> | null;
18
+ /**
19
+ * Segment metadata for this page's route. Restored into the segment cache
20
+ * on popstate cached replay so the next forward navigation computes a
21
+ * correct state tree. Without this, the segment cache retains the
22
+ * *previous* page's segments after back-button, causing the server to
23
+ * skip segments that aren't mounted — and the partial payload targets
24
+ * a non-existent outlet.
25
+ */
26
+ segmentInfo?: SegmentInfo[] | null;
16
27
  }
17
28
 
18
29
  // ─── History Stack ───────────────────────────────────────────────