@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.
- package/dist/_chunks/{actions-cjklt63G.js → actions-CSDD6x7U.js} +2 -2
- package/dist/_chunks/{actions-cjklt63G.js.map → actions-CSDD6x7U.js.map} +1 -1
- package/dist/_chunks/{cache-api-CzYUlgXA.js → cache-api-eb1gydM7.js} +41 -13
- package/dist/_chunks/cache-api-eb1gydM7.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js → cli-schema-sync-mGfRbjh2.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-NfLbLnDw.js.map → cli-schema-sync-mGfRbjh2.js.map} +1 -1
- package/dist/_chunks/{plugin-context-DeAxFRMq.js → plugin-context-BnaiU_cF.js} +37 -2
- package/dist/_chunks/plugin-context-BnaiU_cF.js.map +1 -0
- package/dist/_chunks/{walkers-9mz9T7mb.js → walkers-BL3MCMgO.js} +2 -2
- package/dist/_chunks/{walkers-9mz9T7mb.js.map → walkers-BL3MCMgO.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.d.ts +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/nitro.d.ts +11 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +77 -64
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.d.ts +3 -0
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/redis-handler.d.ts +1 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/tag-aware-handler.d.ts +1 -0
- package/dist/cache/tag-aware-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/internal.js +1 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/slot-context.d.ts +29 -0
- package/dist/client/slot-context.d.ts.map +1 -0
- package/dist/client/slot-outlet.d.ts +16 -0
- package/dist/client/slot-outlet.d.ts.map +1 -0
- package/dist/client/slot-provider.d.ts +20 -0
- package/dist/client/slot-provider.d.ts.map +1 -0
- package/dist/config-types.d.ts +2 -1
- package/dist/config-types.d.ts.map +1 -1
- package/dist/dev-tools/logs.d.ts.map +1 -1
- package/dist/index.js +293 -124
- package/dist/index.js.map +1 -1
- package/dist/plugin-context.d.ts +27 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
- package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
- package/dist/plugins/prebuilt.d.ts.map +1 -1
- package/dist/plugins/prerender-sugar.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/html-injector-core.d.ts +30 -9
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +346 -51
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/cache-key.d.ts +4 -0
- package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +23 -0
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
- package/dist/server/prebuilt/slots.d.ts +74 -0
- package/dist/server/prebuilt/slots.d.ts.map +1 -0
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts +12 -2
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +26 -3
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/docs/api/34-api-config.mdx +165 -3
- package/docs/learn/00-introduction.mdx +78 -44
- package/docs/learn/13-configuration.mdx +27 -8
- package/package.json +3 -2
- package/src/adapters/cloudflare-kv-cache.ts +5 -1
- package/src/adapters/nitro.ts +82 -64
- package/src/cache/index.ts +25 -2
- package/src/cache/redis-handler.ts +27 -7
- package/src/cache/tag-aware-handler.ts +5 -1
- package/src/cache/timber-cache.ts +21 -10
- package/src/client/segment-cache.ts +4 -5
- package/src/client/slot-context.ts +48 -0
- package/src/client/slot-outlet.tsx +22 -0
- package/src/client/slot-provider.tsx +25 -0
- package/src/config-types.ts +2 -1
- package/src/dev-tools/logs.ts +7 -0
- package/src/plugin-context.ts +54 -0
- package/src/plugins/cache.ts +1 -2
- package/src/plugins/client-chunks.ts +42 -1
- package/src/plugins/dev-server.ts +12 -69
- package/src/plugins/prebuilt-options-analysis.ts +175 -0
- package/src/plugins/prebuilt.ts +82 -127
- package/src/plugins/prerender-sugar.ts +1 -2
- package/src/server/html-injector-core.ts +85 -27
- package/src/server/html-injectors.ts +5 -1
- package/src/server/node-stream-transforms.ts +6 -1
- package/src/server/pipeline-phases.ts +4 -1
- package/src/server/prebuilt/cache-key.ts +74 -0
- package/src/server/prebuilt/key-discipline.ts +53 -0
- package/src/server/prebuilt/slots.ts +167 -0
- package/src/server/prebuilt-builder.ts +57 -23
- package/src/server/prebuilt-runtime.ts +144 -60
- package/src/server/route-element-builder.ts +82 -73
- package/src/server/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/helpers.ts +12 -10
- package/src/server/rsc-entry/index.ts +16 -70
- package/src/server/state-tree-diff.ts +49 -4
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
### Typed Routes
|
|
19
69
|
|
|
20
|
-
|
|
70
|
+
All routing in timber is fully typed and integrated. So you can write a `<Link>` as:
|
|
21
71
|
|
|
22
|
-
|
|
72
|
+
```tsx
|
|
73
|
+
<Link href="/blog/[slug]" segmentParams={{ slug: 'a-post' }} searchParams={{ foo: 'bar' }}>Page</Link>
|
|
74
|
+
```
|
|
23
75
|
|
|
24
|
-
|
|
76
|
+
Or just normal `<a>` tags work too.
|
|
25
77
|
|
|
26
|
-
|
|
78
|
+
### Middleware and Access Control
|
|
27
79
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
+
Here are our rules for forms:
|
|
41
93
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
### Developer Clarity
|
|
49
105
|
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
|
127
|
-
| `output`
|
|
128
|
-
| `
|
|
129
|
-
| `
|
|
130
|
-
| `clientJavascript`
|
|
131
|
-
| `
|
|
132
|
-
| `
|
|
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.
|
|
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(
|
|
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,
|
package/src/adapters/nitro.ts
CHANGED
|
@@ -195,11 +195,14 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
195
195
|
publicDirName: 'public',
|
|
196
196
|
});
|
|
197
197
|
|
|
198
|
-
// Write
|
|
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 ||
|
|
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
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
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
|
/**
|
package/src/cache/index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
await Promise.all(
|
|
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
|
|
195
|
-
await this.client.
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
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
|
|
137
|
+
for (const info of segments) {
|
|
139
138
|
const id = info.segmentId ?? info.path;
|
|
140
139
|
const node: SegmentNode = {
|
|
141
140
|
segment: id,
|