@timber-js/app 0.2.0-alpha.161 → 0.2.0-alpha.164

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 (113) hide show
  1. package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
  2. package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
  4. package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
  5. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
  7. package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
  8. package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
  9. package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
  10. package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
  11. package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
  12. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  13. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  14. package/dist/adapters/nitro.d.ts +11 -0
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +77 -64
  17. package/dist/adapters/nitro.js.map +1 -1
  18. package/dist/cache/index.d.ts +3 -0
  19. package/dist/cache/index.d.ts.map +1 -1
  20. package/dist/cache/index.js +1 -1
  21. package/dist/cache/redis-handler.d.ts +1 -0
  22. package/dist/cache/redis-handler.d.ts.map +1 -1
  23. package/dist/cache/tag-aware-handler.d.ts +1 -0
  24. package/dist/cache/tag-aware-handler.d.ts.map +1 -1
  25. package/dist/cache/timber-cache.d.ts.map +1 -1
  26. package/dist/cli.js +2 -2
  27. package/dist/client/internal.js +1 -2
  28. package/dist/client/internal.js.map +1 -1
  29. package/dist/client/segment-cache.d.ts.map +1 -1
  30. package/dist/client/slot-context.d.ts +29 -0
  31. package/dist/client/slot-context.d.ts.map +1 -0
  32. package/dist/client/slot-outlet.d.ts +16 -0
  33. package/dist/client/slot-outlet.d.ts.map +1 -0
  34. package/dist/client/slot-provider.d.ts +20 -0
  35. package/dist/client/slot-provider.d.ts.map +1 -0
  36. package/dist/config-types.d.ts +2 -1
  37. package/dist/config-types.d.ts.map +1 -1
  38. package/dist/dev-tools/logs.d.ts.map +1 -1
  39. package/dist/index.js +293 -124
  40. package/dist/index.js.map +1 -1
  41. package/dist/plugin-context.d.ts +27 -0
  42. package/dist/plugin-context.d.ts.map +1 -1
  43. package/dist/plugins/cache.d.ts.map +1 -1
  44. package/dist/plugins/client-chunks.d.ts.map +1 -1
  45. package/dist/plugins/dev-server.d.ts.map +1 -1
  46. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  47. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  48. package/dist/plugins/prebuilt.d.ts.map +1 -1
  49. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  50. package/dist/routing/index.js +2 -2
  51. package/dist/server/html-injector-core.d.ts +30 -9
  52. package/dist/server/html-injector-core.d.ts.map +1 -1
  53. package/dist/server/html-injectors.d.ts.map +1 -1
  54. package/dist/server/index.js +1 -1
  55. package/dist/server/internal.js +346 -51
  56. package/dist/server/internal.js.map +1 -1
  57. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  58. package/dist/server/pipeline-phases.d.ts.map +1 -1
  59. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  60. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  61. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  62. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  63. package/dist/server/prebuilt/slots.d.ts +74 -0
  64. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  65. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  66. package/dist/server/prebuilt-runtime.d.ts +12 -2
  67. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  68. package/dist/server/route-element-builder.d.ts.map +1 -1
  69. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  70. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  71. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  72. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  73. package/dist/server/state-tree-diff.d.ts +26 -3
  74. package/dist/server/state-tree-diff.d.ts.map +1 -1
  75. package/docs/api/34-api-config.mdx +165 -3
  76. package/docs/learn/00-introduction.mdx +78 -44
  77. package/docs/learn/13-configuration.mdx +27 -8
  78. package/package.json +3 -2
  79. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  80. package/src/adapters/nitro.ts +82 -64
  81. package/src/cache/index.ts +25 -2
  82. package/src/cache/redis-handler.ts +27 -7
  83. package/src/cache/tag-aware-handler.ts +5 -1
  84. package/src/cache/timber-cache.ts +21 -10
  85. package/src/client/segment-cache.ts +4 -5
  86. package/src/client/slot-context.ts +48 -0
  87. package/src/client/slot-outlet.tsx +22 -0
  88. package/src/client/slot-provider.tsx +25 -0
  89. package/src/config-types.ts +2 -1
  90. package/src/dev-tools/logs.ts +7 -0
  91. package/src/plugin-context.ts +54 -0
  92. package/src/plugins/cache.ts +1 -2
  93. package/src/plugins/client-chunks.ts +42 -1
  94. package/src/plugins/dev-server.ts +12 -69
  95. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  96. package/src/plugins/prebuilt.ts +82 -127
  97. package/src/plugins/prerender-sugar.ts +1 -2
  98. package/src/server/html-injector-core.ts +85 -27
  99. package/src/server/html-injectors.ts +5 -1
  100. package/src/server/node-stream-transforms.ts +6 -1
  101. package/src/server/pipeline-phases.ts +4 -1
  102. package/src/server/prebuilt/cache-key.ts +74 -0
  103. package/src/server/prebuilt/key-discipline.ts +53 -0
  104. package/src/server/prebuilt/slots.ts +167 -0
  105. package/src/server/prebuilt-builder.ts +57 -23
  106. package/src/server/prebuilt-runtime.ts +144 -60
  107. package/src/server/route-element-builder.ts +82 -73
  108. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  109. package/src/server/rsc-entry/helpers.ts +12 -10
  110. package/src/server/rsc-entry/index.ts +16 -70
  111. package/src/server/state-tree-diff.ts +49 -4
  112. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  113. package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
@@ -9,68 +9,102 @@ notAI: true
9
9
 
10
10
  ## Basics
11
11
 
12
- Timber is a react-based web framework built on vite and server components. It is inspired by next.js, tanstack start, and remix however it brings an independent approach built on a more modern foundation.
12
+ Timber is a react-based web framework built on Vite and Server Components. It is inspired by next.js, tanstack start, and remix however it brings an independent approach built on a modern and flexible foundation.
13
13
 
14
- It is built on web standards and attempts to use as little framework magic as possible. We feel it unlocks both the most flexible UX and DX possible. Rather than using heuristics to adjust how the framework operates, it gives you the knobs to fine-tune your website to your liking.
14
+ It is designed to align with web standards and attempts to use as little framework magic as possible. We feel it unlocks both the most flexible UX and DX possible. Rather than using heuristics to adjust how the framework operates, it gives you the knobs to fine-tune your website to your liking.
15
15
 
16
- That means it is entirely possible to write an inefficient website with timber, however, you will also _understand_ exactly what is happening and have clear eyes on how to fix it.
16
+ ---
17
+
18
+ Here are some of the ideas that drive Timber.
19
+
20
+ ### Architectural Optionality
21
+
22
+ Timber is a framework that aims to bring architectural optionality. All websites run in three environments:
23
+
24
+ * The Server
25
+ * The Server and the Client (often called SSR or pre-rendering)
26
+ * The Client
27
+
28
+ The server brings forth access control, privacy, and performance and sets up the initial response to the user.
29
+
30
+ The client unlocks interactivity, dynamic rendering, and cross-route state.
31
+
32
+ SSR and pre-rendering bridge the two.
33
+
34
+ Server Components unlock each environment to its fullest capacity, while allowing you to step through them via one unified component tree. And timber embraces them fully.
35
+
36
+ ### Upside-Down Astro
37
+
38
+ Astro is a framework built for content and it's philsophy is: static first, islands of interactivity and dynamic content.
39
+
40
+ Timber is the inverse of this approach: dynamic first, with islands of static content. In our experience, most webpages require some form of dynamic rendering. Expensive stuff, cached stuff, is usually on the leafier side – a component tree here and there. Only caching subsets of your tree gives you powerful performance without sacrificing the full server functionality (access control, middleware, logging, and more).
41
+
42
+ We also find this to be a simpler mental model. Requests flow through the server – subsets of the page are cached. Interactivity and state exists throughout the tree and across routes.
43
+
44
+ ### Serialized Building Blocks: Cookies, Search Params, Segment Params
45
+
46
+ Timber takes all of the building blocks of the web that are effectively: serialize data to a string and back and it gives you the guard rails to properly type and validate them. Things like cookies, search params, and segment params.
47
+
48
+ Everything is driven by standard schemas (zod, valibot, nuqs, custom, etc.) and you create factory functions to access that data on the server and the client.
49
+
50
+ ```tsx
51
+ import { defineSearchParams } from '@timber-js/app/search-params';
52
+ import { z } from 'zod/v4';
53
+
54
+ export const searchParams = defineSearchParams({
55
+ month: z.string().optional(),
56
+ day: z.string().optional(),
57
+ slug: z.string().optional(),
58
+ });
59
+
60
+ // later on
61
+ const sp = searchParams.get();
62
+
63
+ // or on the client
64
+
65
+ const [sp, setSearchParams] = searchParams.useQueryState();
66
+ ```
17
67
 
18
- We believe that the best UX is often invisible, but the best DX is _transparent_. You shouldn't ever be confused about where something is running and why.
68
+ ### Typed Routes
19
69
 
20
- Timber is not a framework that comes with the simplest mental model – rather it embraces the _inherent complexity_ of the web and gives you the tools to define your boundaries as you see fit. Server and client, static and dynamic, build-time and run-time. So on and so forth. There is no one true _best_ place to put these boundaries, so it is incumbent on the developer to _choose_ where to place them.
70
+ All routing in timber is fully typed and integrated. So you can write a `<Link>` as:
21
71
 
22
- Timber is a tool for frontend professionals. Though a beginner could pick it up and learn both the framework and the platform (the web) incrementally, it is best utilized by people who have a deep understanding of both the web and building websites.
72
+ ```tsx
73
+ <Link href="/blog/[slug]" segmentParams={{ slug: 'a-post' }} searchParams={{ foo: 'bar' }}>Page</Link>
74
+ ```
23
75
 
24
- Think of it as a flexible toolbelt of tools: the mental model to understand each of these tools and how to use them is high, but once you do, the framework clicks into place. A simpler mental model may come with it an easier skill curve, but at the sacrifice of both user experience and developer experience.
76
+ Or just normal `<a>` tags work too.
25
77
 
26
- Timber is understandable, but you'll have to be patient in understanding it. We feel it's worth the investment.
78
+ ### Middleware and Access Control
27
79
 
28
- ## Philosophy
80
+ Timber's routing and layouting system is almost 1:1 inspired by next.js. This file based routing structure lends itself to a compositional architecture, where you can build up layers of functionality as you go deeper into the route tree.
29
81
 
30
- All web frameworks are created out of some basic philosophies of how its creators feel websites should be built. Timber is no exception.
82
+ The filesystem maps directly to the route tree. On top of that, because our architecture is dynamic first, we can give you: proper middleware and `access.ts` access control.
31
83
 
32
- Here's ours:
84
+ When you hit a route, every segment's `middleware.ts` and `access.ts` is executed in series. Though some would call this slow, with a proper data architecture, we find that this is rarely the bottleneck to building a fast and performant website.
33
85
 
34
- A good web architecture _embraces_ the complexity of building websites. Since building a website is inherently complex, a framework that embraces that complexity will inevitably feel overwhelming at first. But I'd implore you to imagine it as a toolbelt that gives you access to each tool in the toolkit. As you understand the tools, the toolbelt falls into place.
86
+ And the DX clarity and functionality gains are immense. Give it a try, you'll be surprised how nice it feels.
35
87
 
36
- So while a beginner could pick up this framework, and it could incrementally teach them each component of building a powerful website, this framework is _not_ designed to be the simplest mental model. This is a framework for the frontend professional. Or a team building a complex and powerful web experience.
88
+ ### Forms
37
89
 
38
- ------
90
+ Forms have long been the biggest pain point of building React apps. They are chonky, buggy, and painful to write. Timber flips forms on their head and make the whole process feel a lot more like _react_.
39
91
 
40
- This all sounds like a lot. I'm not here to say it isn't. But if you're building a serious website, if you work on a team of experienced engineers, an architecture that sits across these complexities and doesn't abstract them gives you the power to define your boundaries: server and client, static and dynamic, client js and not.
92
+ Here are our rules for forms:
41
93
 
42
- To learn the inherent complexity of these ideas, you internalize an architecture that can scale up and down. One that gives you access to both UX and DX that is unparalleled and not limited. Timber does not dumb down our abstractions to make it easier to _understand_, because that comes at the cost of optionality.
94
+ 1. Forms must work consistently with or without javascript
95
+ 2. Inputs must _own their own state_ or be uncontrolled – no more hoisting your logic and context above the form. This makes sure forms are composable.
96
+ 3. You should be able to have global validation and input-level validation
97
+ 4. Validation should be possible to run on either the server, or the client
98
+ 5. Forms should maintain their data if submission fails (whether you are on the JS or non-JS track)
43
99
 
44
- Other frameworks that have pursued this have either abstracted away too much, leaned on too much magic, or ignored web standards. Timber is _not_ smarter than you. It doesn't try to be. You can write an inefficient website in Timber, and it won't stop you. But it also won't project onto you _how_ to build a website. It just gives you the foundation to control it yourself.
100
+ Now you might say, wow, these are a lot of rules – even though they all sound good. But to handle all of these features, you'd need way more code.
45
101
 
46
- Again, if you're new to building websites, this is a tall task.
102
+ Turns out – the opposite. It's way less code. It's more functional, more flexible, and you write less code to handle it all. Does the science back it up? Let's see:
47
103
 
48
- But the good news is, you don't have to understand this all immediately. You can layer on your understanding one by one. And I promise, in the long-term it will click. But you may have to touch it yourself one by one.
104
+ ### Developer Clarity
49
105
 
50
- {/* Here are our basic philosophies:
106
+ We believe the good UX is invisible, but good DX is _transparent_. You should always be able to see what's going on. No caching is implicit.
51
107
 
52
- - All websites sit somewhere across three environments. Embracing these three as first-party targets is a bid for developer autonomy, not confusion.
53
- - Server
54
- - Client
55
- - Isomorphic (Universal SSR)
56
- - Serverless is a flawed paradigm for rendering
57
- - Though it can be useful at times, owning your _own_ server (and CPU cycles) will always be more performant and cost effective at anything beyond a basic scale
58
- - An inefficiency in serverless means an unbounded/infinite bill, on a server it only means degraded performance
59
- - A server means your database can live _next_ to your rendering layer, which will always outperform _holistic_ edge rendering in generic real-world scenarios
60
- - Your framework should never be smarter than you
61
- - Good UX is invisible, good DX is transparent
62
- - It's better to be understandable than magic, which means performance is fine tunable – rather than automatic
63
- - The web constantly serializes and deserializes strings, the framework should provide typing and parsing to those edges
64
- - Search Params
65
- - Cookies
66
- - Segment URL Params
67
- - and more...
68
- - A good web-framework scales horizontally across your surface API (routes)
69
- - Adding more routes or components shouldn't affect performance on others
70
- - A good web framework has finely-tuned control over static (build-time) and dynamic (run-time) rendering
71
- - A good web framework has the ability to scale up and down functionally and with developer clarity, which means deploying a website that:
72
- - Can ship zero client javascript
73
- - Can ship static content
74
- - And can scale up to a completely dynamic website */}
108
+ -----
75
109
 
76
- More here soon...
110
+ These are the core tenants of Timber. It's the framework I wish existed. Thanks for taking the time to look into it.
@@ -122,14 +122,33 @@ Server actions still work — HTML forms submit natively via POST without JavaSc
122
122
 
123
123
  ## All Options
124
124
 
125
- | Option | Type | Default | Description |
126
- | ------------------ | ----------------------- | ---------------------------- | ------------------------- |
127
- | `output` | `'server' \| 'static'` | `'server'` | Output mode |
128
- | `adapter` | `TimberPlatformAdapter` | — | Deployment adapter |
129
- | `cacheHandler` | `CacheHandler` | `MemoryCacheHandler` | Cache backend |
130
- | `clientJavascript` | `boolean \| object` | `true` | Control client-side JS |
131
- | `pageExtensions` | `string[]` | `['tsx', 'ts', 'jsx', 'js']` | File extensions for pages |
132
- | `mdx` | `object` | — | MDX remark/rehype plugins |
125
+ | Option | Type | Default | Description |
126
+ | ------------------- | --------------------------- | ---------------------------- | ---------------------------------------- |
127
+ | `output` | `'server' \| 'static'` | `'server'` | Output mode |
128
+ | `debug` | `boolean` | `false` | Enable timber debug logging in prod |
129
+ | `buildDir` | `string` | `'.timber/dist'` | Build output directory |
130
+ | `clientJavascript` | `boolean \| object` | `true` | Control client-side JS |
131
+ | `adapter` | `TimberPlatformAdapter` | — | Deployment adapter |
132
+ | `cacheHandler` | `CacheHandler` | `MemoryCacheHandler` | Cache backend |
133
+ | `cdnPurge` | `CdnPurgeHandler` | — | CDN purge on revalidation |
134
+ | `serverTiming` | `'detailed' \| 'total' \| false` | `'detailed'` / `'total'` | Server-Timing header |
135
+ | `allowedOrigins` | `string[]` | — | CORS / CSRF allowed origins |
136
+ | `csrf` | `boolean` | `true` | CSRF protection |
137
+ | `limits` | `object` | — | Request body size limits |
138
+ | `actions` | `object` | — | Server action behavior |
139
+ | `forms` | `object` | — | Form handling (sensitive field stripping) |
140
+ | `pageExtensions` | `string[]` | `['tsx', 'ts', 'jsx', 'js']` | File extensions for pages |
141
+ | `slowRequestMs` | `number` | `3000` | Slow request warning threshold (ms) |
142
+ | `renderTimeoutMs` | `number` | `30000` | Render abort timeout (ms) |
143
+ | `devBrowserLogs` | `string` | `'warn'` | Forward browser console to server in dev |
144
+ | `dev` | `object` | — | Dev-mode options |
145
+ | `budget` | `object` | — | Build-time performance budgets |
146
+ | `appDir` | `string` | auto-detected | Override app directory location |
147
+ | `mdx` | `object` | — | MDX remark/rehype plugins |
148
+ | `actionEncryption` | `object` | — | Server action bound args encryption |
149
+ | `reactCompiler` | `boolean \| object` | `false` | React Compiler auto-memoization |
150
+ | `sitemap` | `object` | — | Auto-generated sitemap.xml |
151
+ | `topLoader` | `object` | enabled | Navigation progress bar |
133
152
 
134
153
  For the full type definition, see the [Config API Reference](/docs/api-config).
135
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.161",
3
+ "version": "0.2.0-alpha.164",
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",
@@ -156,7 +156,8 @@
156
156
  "@opentelemetry/sdk-trace-base": "^2.8.0",
157
157
  "cookie": "^1.1.1",
158
158
  "magic-string": "^0.30.21",
159
- "nitro": "3.0.260610-beta"
159
+ "nitro": "3.0.260610-beta",
160
+ "srvx": "^0.11.17"
160
161
  },
161
162
  "peerDependencies": {
162
163
  "@content-collections/core": "^0.14.0 || ^0.15.0",
@@ -112,7 +112,11 @@ export class CloudflareKVCacheHandler implements CacheHandler {
112
112
  return { value: entry.value, stale };
113
113
  }
114
114
 
115
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
115
+ async set(
116
+ key: string,
117
+ value: unknown,
118
+ opts: { ttl: number; tags: string[]; generation?: number }
119
+ ): Promise<void> {
116
120
  const kv = this.getKV();
117
121
  const entry: KVCacheEntry = {
118
122
  value,
@@ -195,11 +195,14 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
195
195
  publicDirName: 'public',
196
196
  });
197
197
 
198
- // Write the compression helper module for runtime use.
198
+ // Write runtime helper modules used by the preview server.
199
199
  // See design/25-production-deployments.md — self-hosted deployments
200
200
  // need application-level compression (Cloudflare handles it at the edge).
201
201
  await writeFile(join(outDir, '_compress.mjs'), await generateCompressModule());
202
202
 
203
+ // Web→Node response bridge with backpressure. See TIM-1154.
204
+ await writeFile(join(outDir, '_send-response.mjs'), generateSendResponseModule());
205
+
203
206
  // Prepend the manifest assignment directly into the RSC entry so
204
207
  // globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.
205
208
  // This must be top-level code, not an import, because rollup tree-shakes
@@ -378,6 +381,9 @@ const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}'
378
381
  // Import compression helper for self-hosted response compression.
379
382
  const { compressResponse } = await import('./_compress.mjs');
380
383
 
384
+ // Web→Node response bridge with backpressure (TIM-1154).
385
+ const { sendNodeResponse } = await import('./_send-response.mjs');
386
+
381
387
  const MIME_TYPES = {
382
388
  '.html': 'text/html',
383
389
  '.js': 'application/javascript',
@@ -414,7 +420,7 @@ const publicDir = join(__dirname, '${publicDir}');
414
420
  const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
415
421
  const portIsExplicit = envPort != null && Number.isFinite(envPort) && envPort > 0;
416
422
  const startPort = portIsExplicit ? envPort : 3000;
417
- const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
423
+ const host = process.env.HOST || 'localhost';
418
424
 
419
425
  // Set after listenWithBump() resolves so request handlers can build
420
426
  // absolute URLs from the actual bound port (which may differ from
@@ -501,68 +507,11 @@ const server = createServer(async (req, res) => {
501
507
  // Compress the response for self-hosted deployments.
502
508
  const webResponse = compressResponse(webRequest, rawResponse);
503
509
 
504
- // Write the response back to the Node response.
505
- //
506
- // Set-Cookie needs special handling: Headers.entries() joins multiple
507
- // Set-Cookie values with ", " into a single entry, but each cookie must
508
- // be its own header per RFC 6265 §4.1. Object.fromEntries() would then
509
- // collapse them into one malformed Set-Cookie header that browsers reject
510
- // (or only honor partially). Use getSetCookie() to preserve individual
511
- // values, and pass them as an array — Node's writeHead accepts
512
- // Record<string, string | string[]> and emits one header per array entry.
513
- //
514
- // Without this, EVERY cookie set by the framework on Node—/Nitro deployments
515
- // is silently dropped: defineCookie().setCookie() in actions, middleware
516
- // cookie writes, the framework's own session cookies, all of it. See
517
- // LOCAL-741.
518
- const responseHeaders = {};
519
- webResponse.headers.forEach((value, key) => {
520
- if (key.toLowerCase() !== 'set-cookie') {
521
- responseHeaders[key] = value;
522
- }
523
- });
524
- const setCookies = webResponse.headers.getSetCookie();
525
- if (setCookies.length > 0) {
526
- responseHeaders['set-cookie'] = setCookies;
527
- }
528
- res.writeHead(webResponse.status, responseHeaders);
529
-
530
- if (webResponse.body) {
531
- const reader = webResponse.body.getReader();
532
-
533
- // Cancel the reader when the client disconnects. This causes any
534
- // pending reader.read() to reject, breaking the pump loop. Critical
535
- // for SSE and other infinite streams — without this, disconnected
536
- // clients leak readers.
537
- let clientDisconnected = false;
538
- const onClose = () => {
539
- clientDisconnected = true;
540
- reader.cancel('Client disconnected').catch(() => {});
541
- };
542
- res.on('close', onClose);
543
-
544
- try {
545
- while (true) {
546
- const { done, value } = await reader.read();
547
- if (done) break;
548
- res.write(value);
549
- }
550
- } catch (err) {
551
- // reader.cancel() from the close handler causes read() to reject.
552
- // This is expected on client disconnect — not an error.
553
- if (!clientDisconnected) {
554
- throw err;
555
- }
556
- } finally {
557
- res.off('close', onClose);
558
- reader.releaseLock();
559
- if (!res.writableEnded) {
560
- res.end();
561
- }
562
- }
563
- } else {
564
- res.end();
565
- }
510
+ // Write the response to Node's ServerResponse. sendNodeResponse handles
511
+ // status, headers (including Set-Cookie splitting via [...headers]),
512
+ // body streaming with backpressure, and client disconnect cleanup.
513
+ // See TIM-1154.
514
+ await sendNodeResponse(res, webResponse);
566
515
  } catch (err) {
567
516
  console.error('[timber preview] Request error:', err);
568
517
  if (!res.headersSent) {
@@ -702,6 +651,75 @@ function spawnNitroPreview(command: string, args: string[], cwd: string): Promis
702
651
  });
703
652
  }
704
653
 
654
+ // ─── Send Response Module ───────────────────────────────────────────────────
655
+
656
+ /**
657
+ * Generate a standalone ESM module that exports sendNodeResponse.
658
+ *
659
+ * Mirrors srvx/node's sendNodeResponse: status + headers via writeHead,
660
+ * body streaming with backpressure (waits for drain on write() === false),
661
+ * and client disconnect cleanup. Written to `_send-response.mjs` during
662
+ * buildOutput for use by the preview server script. See TIM-1154.
663
+ *
664
+ * @internal Exported for testing.
665
+ */
666
+ export function generateSendResponseModule(): string {
667
+ return `// Generated by @timber-js/app — Web→Node response bridge.
668
+ // Do not edit — this file is regenerated on each build.
669
+ // Mirrors srvx/node's sendNodeResponse with backpressure support.
670
+
671
+ export function sendNodeResponse(nodeRes, webRes) {
672
+ if (!webRes) {
673
+ nodeRes.statusCode = 500;
674
+ return new Promise((resolve) => nodeRes.end(resolve));
675
+ }
676
+ const rawHeaders = [...webRes.headers];
677
+ const writeHeaders = rawHeaders.flat();
678
+ if (!nodeRes.headersSent) {
679
+ if (nodeRes.req?.httpVersion === '2.0') {
680
+ nodeRes.writeHead(webRes.status, writeHeaders);
681
+ } else {
682
+ nodeRes.writeHead(webRes.status, webRes.statusText, writeHeaders);
683
+ }
684
+ }
685
+ if (!webRes.body) {
686
+ return new Promise((resolve) => nodeRes.end(resolve));
687
+ }
688
+ // Stream with backpressure: pause reading when the kernel buffer is
689
+ // full (write() returns false) and resume on 'drain'.
690
+ if (nodeRes.destroyed) {
691
+ webRes.body.cancel();
692
+ return;
693
+ }
694
+ const reader = webRes.body.getReader();
695
+ function streamCancel(error) {
696
+ reader.cancel(error).catch(() => {});
697
+ if (error) nodeRes.destroy(error);
698
+ }
699
+ function streamHandle({ done, value }) {
700
+ try {
701
+ if (done) {
702
+ nodeRes.end();
703
+ } else if (nodeRes.write(value)) {
704
+ reader.read().then(streamHandle, streamCancel);
705
+ } else {
706
+ nodeRes.once('drain', () => reader.read().then(streamHandle, streamCancel));
707
+ }
708
+ } catch (error) {
709
+ streamCancel(error instanceof Error ? error : undefined);
710
+ }
711
+ }
712
+ nodeRes.on('close', streamCancel);
713
+ nodeRes.on('error', streamCancel);
714
+ reader.read().then(streamHandle, streamCancel);
715
+ return reader.closed.catch(streamCancel).finally(() => {
716
+ nodeRes.off('close', streamCancel);
717
+ nodeRes.off('error', streamCancel);
718
+ });
719
+ }
720
+ `;
721
+ }
722
+
705
723
  // ─── Helpers ─────────────────────────────────────────────────────────────────
706
724
 
707
725
  /**
@@ -4,7 +4,11 @@ import { estimateByteSize } from './sizeof.js';
4
4
 
5
5
  export interface CacheHandler {
6
6
  get(key: string): Promise<{ value: unknown; stale: boolean } | null>;
7
- set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void>;
7
+ set(
8
+ key: string,
9
+ value: unknown,
10
+ opts: { ttl: number; tags: string[]; generation?: number }
11
+ ): Promise<void>;
8
12
  invalidate(opts: { key?: string; tag?: string }): Promise<void>;
9
13
  }
10
14
 
@@ -38,6 +42,7 @@ export class MemoryCacheHandler implements CacheHandler {
38
42
  string,
39
43
  { value: unknown; expiresAt: number; tags: string[]; byteSize: number }
40
44
  >();
45
+ private generations = new Map<string, number>();
41
46
  private maxEntries: number;
42
47
  private maxBytes: number | undefined;
43
48
  private maxEntryBytes: number | undefined;
@@ -64,7 +69,17 @@ export class MemoryCacheHandler implements CacheHandler {
64
69
  return { value: entry.value, stale };
65
70
  }
66
71
 
67
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }) {
72
+ async set(
73
+ key: string,
74
+ value: unknown,
75
+ opts: { ttl: number; tags: string[]; generation?: number }
76
+ ) {
77
+ // CAS guard: skip write if a newer generation has already been written
78
+ if (opts.generation !== undefined) {
79
+ const current = this.generations.get(key) ?? 0;
80
+ if (opts.generation < current) return;
81
+ }
82
+
68
83
  const byteSize = this._trackBytes ? estimateByteSize(value) : 0;
69
84
 
70
85
  // Reject entries exceeding per-entry byte limit
@@ -95,6 +110,11 @@ export class MemoryCacheHandler implements CacheHandler {
95
110
  }
96
111
  }
97
112
 
113
+ // Record generation only after all admission checks pass
114
+ if (opts.generation !== undefined) {
115
+ this.generations.set(key, opts.generation);
116
+ }
117
+
98
118
  this.store.set(key, {
99
119
  value,
100
120
  expiresAt: Date.now() + opts.ttl * 1000,
@@ -111,12 +131,14 @@ export class MemoryCacheHandler implements CacheHandler {
111
131
  this.currentBytes -= entry.byteSize;
112
132
  this.store.delete(opts.key);
113
133
  }
134
+ this.generations.delete(opts.key);
114
135
  }
115
136
  if (opts.tag) {
116
137
  for (const [key, entry] of this.store) {
117
138
  if (entry.tags.includes(opts.tag)) {
118
139
  this.currentBytes -= entry.byteSize;
119
140
  this.store.delete(key);
141
+ this.generations.delete(key);
120
142
  }
121
143
  }
122
144
  }
@@ -139,6 +161,7 @@ export class MemoryCacheHandler implements CacheHandler {
139
161
  const entry = this.store.get(oldest)!;
140
162
  this.currentBytes -= entry.byteSize;
141
163
  this.store.delete(oldest);
164
+ this.generations.delete(oldest);
142
165
  }
143
166
  }
144
167
  }
@@ -141,7 +141,11 @@ export class RedisCacheHandler implements CacheHandler {
141
141
  return { value: entry.value, stale };
142
142
  }
143
143
 
144
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
144
+ async set(
145
+ key: string,
146
+ value: unknown,
147
+ opts: { ttl: number; tags: string[]; generation?: number }
148
+ ): Promise<void> {
145
149
  const ck = this.cacheKey(key);
146
150
  const expiresAt = Date.now() + opts.ttl * 1000;
147
151
  const payload = JSON.stringify({ value, expiresAt, tags: opts.tags });
@@ -186,13 +190,29 @@ export class RedisCacheHandler implements CacheHandler {
186
190
  const tk = this.tagKey(opts.tag);
187
191
  const keys = await this.client.smembers(tk);
188
192
 
189
- // One del per key: the only multi-key shape every client accepts.
190
- // ioredis takes del(...keys), node-redis takes del(key | key[]), and
191
- // @upstash/redis takes del(...keys) but breaks on an array argument.
192
- await Promise.all(keys.map((k) => this.client.del(this.cacheKey(k))));
193
+ // Re-check each member before deleting — the tag set can contain stale
194
+ // memberships from entries whose tags changed since they were added.
195
+ // Mirrors TagAwareCacheHandler's eager strategy (tag-aware-handler.ts:183-198).
196
+ await Promise.all(
197
+ keys.map(async (k) => {
198
+ const raw = await this.client.get(this.cacheKey(k));
199
+ if (raw === null) {
200
+ await this.client.srem(tk, k);
201
+ return;
202
+ }
203
+ const entry = JSON.parse(raw) as { tags?: string[] };
204
+ if (entry.tags?.includes(opts.tag!)) {
205
+ await this.client.del(this.cacheKey(k));
206
+ }
207
+ await this.client.srem(tk, k);
208
+ })
209
+ );
193
210
 
194
- // Clean up the tag set itself
195
- await this.client.del(tk);
211
+ // Clean up the now-empty tag set so it doesn't consume memory
212
+ const remaining = await this.client.smembers(tk);
213
+ if (remaining.length === 0) {
214
+ await this.client.del(tk);
215
+ }
196
216
  }
197
217
  }
198
218
  }
@@ -105,7 +105,11 @@ export class TagAwareCacheHandler implements CacheHandler {
105
105
  return { value: entry.value, stale };
106
106
  }
107
107
 
108
- async set(key: string, value: unknown, opts: { ttl: number; tags: string[] }): Promise<void> {
108
+ async set(
109
+ key: string,
110
+ value: unknown,
111
+ opts: { ttl: number; tags: string[]; generation?: number }
112
+ ): Promise<void> {
109
113
  const physicalTtl = Math.max(opts.ttl * 2 + 60, 120);
110
114
 
111
115
  if (this.strategy === 'native') {
@@ -13,6 +13,22 @@ import { fnv1aHash } from './fast-hash.js';
13
13
 
14
14
  let defaultSingleflight = createSingleflight();
15
15
 
16
+ /**
17
+ * Per-key monotonic generation counter for CAS writes (TIM-1158).
18
+ *
19
+ * Unbounded, like the singleflight map — the key space is the same set of
20
+ * cache keys the handler already tracks. Pruning was removed because it
21
+ * desyncs from MemoryCacheHandler.generations: a pruned key restarts at 1
22
+ * while the handler still holds the old higher generation, causing fresh
23
+ * writes to be rejected as stale.
24
+ */
25
+ const writeGenerations = new Map<string, number>();
26
+ function nextGeneration(key: string): number {
27
+ const gen = (writeGenerations.get(key) ?? 0) + 1;
28
+ writeGenerations.set(key, gen);
29
+ return gen;
30
+ }
31
+
16
32
  /**
17
33
  * Set the timeout for the module-level default singleflight.
18
34
  * Called at framework boot with renderTimeoutMs from timber.config.ts so that
@@ -164,21 +180,16 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
164
180
  * - the key/tags were invalidated after fn started (TIM-1028), or
165
181
  * - the singleflight timed out (signal aborted) — the timed-out
166
182
  * flight's write could overwrite a newer value from a subsequent
167
- * flight (TIM-1092).
168
- *
169
- * Residual race: if fn() completes within the timeout but
170
- * handler.set() is slow enough that the timeout fires during the
171
- * set, the write lands with stale data. This window is narrow
172
- * (requires set latency to straddle the timeout boundary) and
173
- * self-healing (next cache miss triggers a fresh write). Fully
174
- * closing it requires CAS/versioned writes on CacheHandler —
175
- * tracked in TIM-1158.
183
+ * flight (TIM-1092), or
184
+ * - the handler rejects the write because a newer generation has
185
+ * already been stored (TIM-1158 CAS guard).
176
186
  */
177
187
  const executeAndStore = async (signal: AbortSignal): Promise<Awaited<ReturnType<Fn>>> => {
178
188
  const startEpoch = currentInvalidationEpoch();
189
+ const generation = nextGeneration(key);
179
190
  const result = await fn(...args);
180
191
  if (!signal.aborted && !wasInvalidatedSince(startEpoch, key, tags)) {
181
- await getHandler().set(key, result, { ttl: opts.ttl, tags });
192
+ await getHandler().set(key, result, { ttl: opts.ttl, tags, generation });
182
193
  }
183
194
  return result;
184
195
  };
@@ -128,14 +128,13 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
128
128
  // Need at least a root segment to build a tree
129
129
  if (segments.length === 0) return undefined;
130
130
 
131
- // Exclude the leaf (page) — pages always re-render on navigation.
132
- // Only layouts are cached in the segment tree.
133
- const layouts = segments.length > 1 ? segments.slice(0, -1) : segments;
134
-
131
+ // All entries are layout segments — the server filters out layoutless
132
+ // segments (including the page leaf) in buildSegmentInfo. Cache every
133
+ // entry; pages are never sent.
135
134
  let root: SegmentNode | undefined;
136
135
  let parent: SegmentNode | undefined;
137
136
 
138
- for (const info of layouts) {
137
+ for (const info of segments) {
139
138
  const id = info.segmentId ?? info.path;
140
139
  const node: SegmentNode = {
141
140
  segment: id,