@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
@@ -36,7 +36,7 @@ export const getUser = cache(async (id: string) => {
36
36
  });
37
37
 
38
38
  // Caches across requests for 60 seconds
39
- export const getPopularProducts = timberCache(
39
+ export const getPopularProducts = timberCache.data(
40
40
  async () => {
41
41
  return db.products.findPopular();
42
42
  },
@@ -44,7 +44,7 @@ export const getPopularProducts = timberCache(
44
44
  );
45
45
  ```
46
46
 
47
- ## `timber.cache()` Options
47
+ ## `cache.data()` Options
48
48
 
49
49
  ```ts
50
50
  import { cache } from '@timber-js/app/cache';
@@ -53,7 +53,7 @@ declare const db: {
53
53
  users: { findUnique(opts: { where: { id: string } }): Promise<{ id: string; name: string }> };
54
54
  };
55
55
 
56
- const getUser = cache(
56
+ const getUser = cache.data(
57
57
  async (userId: string) => {
58
58
  return db.users.findUnique({ where: { id: userId } });
59
59
  },
@@ -74,7 +74,7 @@ Without an explicit `key`, the cache key is derived from the call site plus a de
74
74
 
75
75
  ### Content-Derived Cache Identities
76
76
 
77
- The call-site identity is content-based — derived from the source code of the `cache()` call, not its position in the file. This means:
77
+ The call-site identity is content-based — derived from the source code of the `cache.data()` call, not its position in the file. This means:
78
78
 
79
79
  - **Unchanged code = cache survives deploys.** If you redeploy without changing a cached function, its entries remain valid on persistent handlers like Redis or KV.
80
80
  - **Changed code = automatic invalidation.** Editing the wrapped function or its options changes the cache identity, which invalidates old entries without manual cache busting.
@@ -86,7 +86,7 @@ Object key order doesn't matter — `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produc
86
86
  Arguments that can't be serialized faithfully — functions, symbols, or class instances whose data lives behind getters with no `toJSON()` — throw a `TypeError` instead of silently colliding. If you hit this, pass an explicit `key`:
87
87
 
88
88
  ```ts
89
- cache(fn, { ttl: 60, key: (session) => `events:${session.userId}` });
89
+ cache.data(fn, { ttl: 60, key: (session) => `events:${session.userId}` });
90
90
  ```
91
91
 
92
92
  ## Invalidation
@@ -117,16 +117,132 @@ On serverless platforms (Cloudflare Workers, Lambda), the background refetch is
117
117
 
118
118
  When multiple concurrent requests miss the cache for the same key, only one executes the underlying function. The rest wait for the result. The result is written to the cache exactly once, by the execution that ran the function. This is built in — no opt-in needed.
119
119
 
120
+ ## Caching Components
121
+
122
+ `cache.data` caches a function's return value. `cache.component` caches a component's **rendered output** — the RSC flight payload — so re-renders skip the entire component tree:
123
+
124
+ ```tsx title="app/blog/[slug]/page.tsx"
125
+ import { cache } from '@timber-js/app/cache';
126
+ import { getSegmentParams } from '@timber-js/app/server';
127
+
128
+ const PostBody = cache.component(
129
+ async function PostBody() {
130
+ const { slug } = getSegmentParams();
131
+ const post = await getPost(slug);
132
+ return <article><h1>{post.title}</h1><PostContent content={post.content} /></article>;
133
+ },
134
+ { prerender: true }
135
+ );
136
+
137
+ export async function generateStaticSegmentParams() {
138
+ return allPosts.map((p) => ({ slug: p.slug }));
139
+ }
140
+
141
+ export default async function BlogPost() {
142
+ return <PostBody />;
143
+ }
144
+ ```
145
+
146
+ The same API gives you three tiers of freshness, controlled by two options:
147
+
148
+ ### Static (build-time only)
149
+
150
+ Rendered at build for each set of params from `generateStaticSegmentParams`. Changes only on redeploy. Request context (`getCookieJar()`, `getHeaders()`) is a build error.
151
+
152
+ ```tsx
153
+ const BlogBody = cache.component(Body, { prerender: true });
154
+ ```
155
+
156
+ ### Cached (runtime only)
157
+
158
+ Rendered on first request, stored with a TTL. Invalidatable by tag. May read request context (with a warning — be careful not to leak per-user data into a shared cache).
159
+
160
+ ```tsx
161
+ const Pricing = cache.component(PricingTable, {
162
+ ttl: 300,
163
+ tags: ['pricing'],
164
+ staleWhileRevalidate: true,
165
+ });
166
+ ```
167
+
168
+ ### ISR (build-seeded, runtime-refreshable)
169
+
170
+ Rendered at build, but refreshable via `revalidateTag` without a redeploy. Unenumerated params fill in on first request.
171
+
172
+ ```tsx
173
+ const DocsPage = cache.component(DocsBody, {
174
+ prerender: true,
175
+ ttl: 3600,
176
+ tags: ['docs'],
177
+ });
178
+ ```
179
+
180
+ ### Mixing Tiers on One Page
181
+
182
+ A page can combine all three — a dynamic shell with cached islands:
183
+
184
+ ```tsx title="app/blog/[slug]/page.tsx"
185
+ import { getCookieJar } from '@timber-js/app/server';
186
+
187
+ export default async function BlogPost() {
188
+ const user = await getUser(getCookieJar());
189
+ return (
190
+ <>
191
+ <Nav user={user} /> {/* dynamic — personalized */}
192
+ <PostBody /> {/* prerendered — from build artifact */}
193
+ <Suspense fallback={<CommentsSkeleton />}>
194
+ <Comments /> {/* dynamic — streams per-request */}
195
+ </Suspense>
196
+ </>
197
+ );
198
+ }
199
+ ```
200
+
201
+ ### Route-Level Shorthand
202
+
203
+ For fully static routes, export `prerender` instead of wrapping the component manually:
204
+
205
+ ```ts title="app/about/page.tsx"
206
+ export const prerender = true;
207
+ ```
208
+
209
+ This desugars to `cache.component(...)` around the page's default export. For dynamic routes (`[slug]`), pair it with `generateStaticSegmentParams` so the build knows which params to prerender:
210
+
211
+ ```ts title="app/docs/[slug]/page.tsx"
212
+ export const prerender = { ttl: 3600, tags: ['docs'] };
213
+
214
+ export async function generateStaticSegmentParams() {
215
+ return allDocs.map((d) => ({ slug: d.slug }));
216
+ }
217
+ ```
218
+
219
+ Layout `prerender` cascades to descendant pages unless a closer layout or page overrides it.
220
+
221
+ ### Slots: Dynamic Children Through a Cached Shell
222
+
223
+ When the expensive part is a wrapper (nav, sidebar, layout chrome) but its children are dynamic, use `slots` to cache the shell with holes for live content:
224
+
225
+ ```tsx
226
+ const Shell = cache.component(ExpensiveWrapper, {
227
+ ttl: 3600,
228
+ tags: ['marketing'],
229
+ slots: ['children'],
230
+ });
231
+
232
+ // The shell serves from cache; children render live per request.
233
+ <Shell theme="dark"><LiveUserFeed /></Shell>
234
+ ```
235
+
236
+ Slot props are excluded from the cache key. Slots are runtime tier only — `prerender` is ignored when `slots` is active.
237
+
120
238
  ## Cache Handlers
121
239
 
122
- The default handler is in-memory. Configure a different backend in `timber.config.ts`:
240
+ The default handler is in-memory. Configure a different backend in `timber.cache.ts`:
123
241
 
124
- ```ts title="timber.config.ts"
242
+ ```ts title="timber.cache.ts"
125
243
  import { MemoryCacheHandler } from '@timber-js/app/cache';
126
244
 
127
- export default {
128
- cacheHandler: new MemoryCacheHandler(),
129
- };
245
+ export default new MemoryCacheHandler();
130
246
  ```
131
247
 
132
248
  Implement the `CacheHandler` interface for Redis, Cloudflare KV, or any other backend. See the [Cache API Reference](/docs/api-cache).
@@ -108,7 +108,13 @@ export function NavItem({ href, children }: { href: string; children: React.Reac
108
108
 
109
109
  ## Segment Cache
110
110
 
111
- The client maintains a mirror of the server's segment tree. On navigation, only changed segments are re-fetched:
111
+ Opt-in via `clientSegmentCache: true` in `timber.config.ts`. When enabled, the client maintains a mirror of the server's segment tree. On navigation, only changed segments are re-fetched:
112
+
113
+ ```ts title="timber.config.ts"
114
+ export default {
115
+ clientSegmentCache: true,
116
+ };
117
+ ```
112
118
 
113
119
  ```
114
120
  /dashboard/settings → /dashboard/team
@@ -121,6 +127,8 @@ The client maintains a mirror of the server's segment tree. On navigation, only
121
127
 
122
128
  Sync layouts stay cached while mounted. Async layouts always re-render. Pages always re-render. Client component state in shared layouts is preserved — counters, form inputs, scroll positions survive navigation.
123
129
 
130
+ When disabled (the default), every client navigation gets a full RSC payload from the server. This is simpler and avoids edge cases with stale cached layouts, at the cost of slightly larger payloads on navigation.
131
+
124
132
  Back/forward navigation replays cached RSC payloads instantly — no server roundtrip.
125
133
 
126
134
  ## Scroll Restoration
@@ -81,16 +81,15 @@ export default {
81
81
 
82
82
  ## Cache Handler
83
83
 
84
- ```ts title="timber.config.ts"
84
+ Cache handler configuration lives in a separate `timber.cache.ts` file (not `timber.config.ts`). This keeps runtime cache instances separate from build-time config, preventing build dependencies from leaking into the server bundle.
85
+
86
+ ```ts title="timber.cache.ts"
85
87
  import { MemoryCacheHandler } from '@timber-js/app/cache';
86
88
 
87
- export default {
88
- output: 'server',
89
- cacheHandler: new MemoryCacheHandler(),
90
- };
89
+ export default new MemoryCacheHandler();
91
90
  ```
92
91
 
93
- The default is in-memory. See [Caching](/docs/caching) for Redis, KV, and custom handlers.
92
+ The default export is the `CacheHandler` instance. Export `cdnPurge` as a named export for CDN purge handlers. The default handler is in-memory. See [Caching](/docs/caching) for Redis, KV, and custom handlers.
94
93
 
95
94
  ## `clientJavascript`
96
95
 
@@ -129,8 +128,6 @@ Server actions still work — HTML forms submit natively via POST without JavaSc
129
128
  | `buildDir` | `string` | `'.timber/dist'` | Build output directory |
130
129
  | `clientJavascript` | `boolean \| object` | `true` | Control client-side JS |
131
130
  | `adapter` | `TimberPlatformAdapter` | — | Deployment adapter |
132
- | `cacheHandler` | `CacheHandler` | `MemoryCacheHandler` | Cache backend |
133
- | `cdnPurge` | `CdnPurgeHandler` | — | CDN purge on revalidation |
134
131
  | `serverTiming` | `'detailed' \| 'total' \| false` | `'detailed'` / `'total'` | Server-Timing header |
135
132
  | `allowedOrigins` | `string[]` | — | CORS / CSRF allowed origins |
136
133
  | `csrf` | `boolean` | `true` | CSRF protection |
@@ -148,6 +145,7 @@ Server actions still work — HTML forms submit natively via POST without JavaSc
148
145
  | `actionEncryption` | `object` | — | Server action bound args encryption |
149
146
  | `reactCompiler` | `boolean \| object` | `false` | React Compiler auto-memoization |
150
147
  | `sitemap` | `object` | — | Auto-generated sitemap.xml |
148
+ | `clientSegmentCache`| `boolean` | `false` | Opt-in client segment cache for partial nav |
151
149
  | `topLoader` | `object` | enabled | Navigation progress bar |
152
150
 
153
151
  For the full type definition, see the [Config API Reference](/docs/api-config).
@@ -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.
@@ -80,14 +80,14 @@ export const searchParams = defineSearchParams({
80
80
 
81
81
  **Next.js:** Implicit fetch caching, `unstable_cache`, ISR with `revalidate`.
82
82
 
83
- **timber:** No implicit caching. No ISR. Explicit `timber.cache()` with TTL and tags:
83
+ **timber:** No implicit caching. No ISR. Explicit `cache.data()` with TTL and tags:
84
84
 
85
85
  ```ts
86
86
  import { cache } from '@timber-js/app/cache';
87
87
 
88
88
  declare const db: { products: { findPopular(): Promise<{ id: string; name: string }[]> } };
89
89
 
90
- const getProducts = cache(
90
+ const getProducts = cache.data(
91
91
  async () => db.products.findPopular(),
92
92
  { ttl: 300, tags: ['products'] }
93
93
  );
@@ -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
 
@@ -53,8 +53,8 @@ These are the things you will get wrong if you assume Next.js behavior.
53
53
  | Concept | Next.js | timber.js |
54
54
  | --- | --- | --- |
55
55
  | Default component type | Client components need `'use client'` | **All components are server components** by default. Only add `'use client'` when you need browser APIs or hooks. |
56
- | Fetch caching | `fetch()` is patched with implicit caching | `fetch()` is **never patched**. Use `cache()` from `@timber-js/app/cache` explicitly. |
57
- | ISR | `revalidate` option | **Does not exist.** Use `cache()` with TTL and tags. |
56
+ | Fetch caching | `fetch()` is patched with implicit caching | `fetch()` is **never patched**. Use `cache.data()` from `@timber-js/app/cache` explicitly. |
57
+ | ISR | `revalidate` option | **Does not exist.** Use `cache.data()` with TTL and tags. |
58
58
  | `loading.tsx` | Convention for auto-Suspense | **Does not exist.** Use `<Suspense>` explicitly. |
59
59
  | Middleware | Single global `middleware.ts` with matchers | Per-segment `middleware.ts` files + global `proxy.ts`. One-arg signature: `middleware(ctx)`. |
60
60
  | `notFound()` | `notFound()` from `next/navigation` | `deny(404)` from `@timber-js/app/server`. Sends a real HTTP 404. |
@@ -104,11 +104,11 @@ content-collections.ts # Collection definitions (optional)
104
104
 
105
105
  2. **Adding `'use client'` to everything** — components are server components by default. Only add `'use client'` when you need browser APIs, event handlers, or React hooks like `useState`/`useEffect`.
106
106
 
107
- 3. **Using `fetch()` and expecting caching** — timber never patches `fetch`. Wrap data-fetching functions in `cache()` from `@timber-js/app/cache` when you want caching.
107
+ 3. **Using `fetch()` and expecting caching** — timber never patches `fetch`. Wrap data-fetching functions in `cache.data()` from `@timber-js/app/cache` when you want caching.
108
108
 
109
109
  4. **Creating `loading.tsx` files** — this convention does not exist. Use `<Suspense>` with an explicit fallback where you want streaming.
110
110
 
111
- 5. **Using ISR patterns** (`revalidate`, `unstable_cache`) — ISR does not exist. Use `cache()` with `{ ttl: seconds, tags: ['tag'] }`.
111
+ 5. **Using ISR patterns** (`revalidate`, `unstable_cache`) — ISR does not exist. Use `cache.data()` with `{ ttl: seconds, tags: ['tag'] }`.
112
112
 
113
113
  6. **Calling `notFound()`** — use `deny(404)` from `@timber-js/app/server` instead. It sends a real HTTP 404 status code.
114
114
 
@@ -123,7 +123,7 @@ content-collections.ts # Collection definitions (optional)
123
123
  ```tsx
124
124
  import { cache } from '@timber-js/app/cache';
125
125
 
126
- const getProducts = cache(
126
+ const getProducts = cache.data(
127
127
  async () => db.products.findMany(),
128
128
  { ttl: 300, tags: ['products'] }
129
129
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.166",
3
+ "version": "0.2.0-alpha.168",
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,12 +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
+ "srvx": "^0.11.22"
167
168
  },
168
169
  "peerDependencies": {
169
170
  "@content-collections/core": "^0.14.0 || ^0.15.0",
@@ -175,7 +176,7 @@
175
176
  "nuqs": "^2.0.0",
176
177
  "react": "19.2.7",
177
178
  "react-dom": "19.2.7",
178
- "vite": "^8.1.0",
179
+ "vite": "8.1.0",
179
180
  "wrangler": "^4.0.0",
180
181
  "zod": "^3.22.0 || ^4.0.0"
181
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
  }
@@ -3,9 +3,10 @@
3
3
  // Primary deployment target. Generates a Workers-compatible entry point
4
4
  // and wrangler.jsonc configuration. See design/11-platform.md §"Cloudflare Workers".
5
5
 
6
- import { writeFile, readFile, cp, rm, readdir } from 'node:fs/promises';
6
+ import { writeFile, readFile, cp, rm, readdir, access } from 'node:fs/promises';
7
7
  import { execFile } from 'node:child_process';
8
8
  import { join, relative } from 'node:path';
9
+ import { parse as parseJsonc } from 'jsonc-parser';
9
10
  import { AsyncLocalStorage } from 'node:async_hooks';
10
11
  import type { TimberPlatformAdapter, TimberConfig } from './types';
11
12
  import { runSharedBuildSteps } from './build-output-helper.js';
@@ -158,6 +159,12 @@ export interface CloudflareBindings {
158
159
 
159
160
  /** Options for the Cloudflare Workers adapter. */
160
161
  export interface CloudflareAdapterOptions {
162
+ /**
163
+ * Worker name used in the generated wrangler.jsonc.
164
+ * @default 'timber-app'
165
+ */
166
+ name?: string;
167
+
161
168
  /**
162
169
  * Cloudflare compatibility date.
163
170
  * @default Current date in YYYY-MM-DD format at build time.
@@ -291,8 +298,10 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): TimberPlatfo
291
298
  const workerEntry = generateWorkerEntry(outDir, outDir, hasManifestInit, hasWorkerHandlers);
292
299
  await writeFile(join(outDir, '_worker.js'), workerEntry);
293
300
 
294
- // Generate wrangler.jsonc
295
- const wranglerConfig = generateWranglerConfig(config, options);
301
+ // Generate wrangler.jsonc — merges user's wrangler config from disk if present
302
+ const root = config.root ?? process.cwd();
303
+ const userConfig = await readUserWranglerConfig(root);
304
+ const wranglerConfig = generateWranglerConfig(config, options, userConfig);
296
305
  await writeFile(join(outDir, 'wrangler.jsonc'), JSON.stringify(wranglerConfig, null, 2));
297
306
  },
298
307
 
@@ -682,22 +691,40 @@ function isPlainObject(val: unknown): val is Record<string, unknown> {
682
691
  return val !== null && typeof val === 'object' && !Array.isArray(val);
683
692
  }
684
693
 
694
+ const WRANGLER_CONFIG_FILES = ['wrangler.jsonc', 'wrangler.json'] as const;
695
+
696
+ /**
697
+ * Read the user's wrangler config from the project root, if one exists.
698
+ * Checks wrangler.jsonc first, then wrangler.json.
699
+ * @internal Exported for testing.
700
+ */
701
+ export async function readUserWranglerConfig(
702
+ root: string
703
+ ): Promise<Record<string, unknown> | null> {
704
+ for (const filename of WRANGLER_CONFIG_FILES) {
705
+ const filepath = join(root, filename);
706
+ try {
707
+ await access(filepath);
708
+ } catch {
709
+ continue;
710
+ }
711
+ const content = await readFile(filepath, 'utf-8');
712
+ return parseJsonc(content) as Record<string, unknown>;
713
+ }
714
+ return null;
715
+ }
716
+
685
717
  /** @internal Exported for testing. */
686
718
  export function generateWranglerConfig(
687
719
  config: TimberConfig,
688
- options: CloudflareAdapterOptions
720
+ options: CloudflareAdapterOptions,
721
+ userConfig?: Record<string, unknown> | null
689
722
  ): Record<string, unknown> {
690
- const compatDate = options.compatibilityDate ?? new Date().toISOString().slice(0, 10);
691
-
692
- const flags = options.compatibilityFlags ?? ['nodejs_compat'];
693
-
694
723
  const base: Record<string, unknown> = {
695
724
  name: 'timber-app',
696
725
  main: '_worker.js',
697
- compatibility_date: compatDate,
698
- compatibility_flags: flags,
699
- // The build output is already fully bundled by Vite — skip wrangler's
700
- // esbuild pass to avoid issues with top-level await and module format.
726
+ compatibility_date: new Date().toISOString().slice(0, 10),
727
+ compatibility_flags: ['nodejs_compat'],
701
728
  no_bundle: true,
702
729
  find_additional_modules: true,
703
730
  rules: [{ type: 'ESModule', globs: ['**/*.js'] }],
@@ -705,29 +732,40 @@ export function generateWranglerConfig(
705
732
  directory: './static',
706
733
  binding: 'ASSETS',
707
734
  },
708
- // Native trace destination — required for timber's pipeline spans
709
- // (emitted via tracing.enterSpan in the generated _worker.js) to appear
710
- // in the Cloudflare Observability dashboard. Gates traces only; does
711
- // not enable Workers Logs. Override via the `wrangler` escape hatch:
712
- // wrangler: { observability: { traces: { enabled: false } } }.
713
735
  observability: {
714
736
  traces: { enabled: true },
715
737
  },
716
738
  };
717
739
 
718
- // Layer 1: merge bindings-generated sections into base
740
+ // Layer 1: user's wrangler.jsonc / wrangler.json from disk
741
+ let merged: Record<string, unknown> = userConfig ? shallowDeepMerge(base, userConfig) : base;
742
+
743
+ // Layer 2: explicit adapter options override disk config
744
+ const explicit: Record<string, unknown> = {};
745
+ if (options.name != null) explicit.name = options.name;
746
+ if (options.compatibilityDate != null) explicit.compatibility_date = options.compatibilityDate;
747
+ if (options.compatibilityFlags != null) explicit.compatibility_flags = options.compatibilityFlags;
748
+ if (Object.keys(explicit).length > 0) {
749
+ merged = { ...merged, ...explicit };
750
+ }
751
+
752
+ // Layer 3: declarative bindings
719
753
  const bindingsConfig = generateBindingsConfig(options.bindings);
720
- const merged = { ...base, ...bindingsConfig };
754
+ merged = shallowDeepMerge(merged, bindingsConfig as Record<string, unknown>);
721
755
 
722
- // Layer 2: wrangler escape hatch with deep merge for nested plain objects.
723
- // A shallow spread would replace entire sections like durable_objects and
724
- // queues — e.g. adding migrations via wrangler would drop the generated
725
- // durable_objects.bindings. Deep merge preserves generated keys while
726
- // letting wrangler override on actual key-level conflicts.
756
+ // Layer 4: wrangler escape hatch — highest priority, deep merge.
727
757
  if (options.wrangler) {
728
- return shallowDeepMerge(merged, options.wrangler);
758
+ merged = shallowDeepMerge(merged, options.wrangler);
729
759
  }
730
760
 
761
+ // Generated config targets a single environment — env blocks can override
762
+ // main/assets per-environment and break the build output.
763
+ delete merged.env;
764
+
765
+ // These fields are required for the build output to work correctly.
766
+ merged.main = '_worker.js';
767
+ merged.find_additional_modules = true;
768
+
731
769
  return merged;
732
770
  }
733
771
 
@@ -9,6 +9,8 @@
9
9
  * A subset of the resolved timber.config.ts relevant to adapters.
10
10
  */
11
11
  export interface TimberConfig {
12
+ /** Absolute path to the project root (Vite root). */
13
+ root?: string;
12
14
  output: 'server' | 'static';
13
15
  clientJavascriptDisabled?: boolean;
14
16
  /**