@timber-js/app 0.2.0-alpha.163 → 0.2.0-alpha.165
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 +40 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +133 -73
- 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/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/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/index.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 +188 -68
- 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/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/rsc-entry/deny-fallback.ts +92 -0
- package/src/server/rsc-entry/index.ts +16 -70
- package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
- package/dist/_chunks/plugin-context-DeAxFRMq.js.map +0 -1
|
@@ -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.165",
|
|
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
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// compression, graceful shutdown, static file serving, and platform quirks.
|
|
6
6
|
// See design/11-platform.md and design/25-production-deployments.md.
|
|
7
7
|
|
|
8
|
-
import { writeFile, readFile } from 'node:fs/promises';
|
|
8
|
+
import { writeFile, readFile, cp, glob } from 'node:fs/promises';
|
|
9
9
|
import { execFile } from 'node:child_process';
|
|
10
|
-
import { join, relative } from 'node:path';
|
|
10
|
+
import { join, relative, dirname, basename } from 'node:path';
|
|
11
11
|
import type { TimberPlatformAdapter, TimberConfig } from './types';
|
|
12
12
|
import { generateCompressModule } from './compress-module.js';
|
|
13
13
|
import { IMMUTABLE_CACHE } from './shared.js';
|
|
@@ -38,6 +38,12 @@ interface PresetConfig {
|
|
|
38
38
|
nitroPreset: string;
|
|
39
39
|
/** Output directory name within the build dir. */
|
|
40
40
|
outputDir: string;
|
|
41
|
+
/**
|
|
42
|
+
* Path to the server bundle directory relative to outputDir.
|
|
43
|
+
* Nitro places the bundled server entry + _chunks/ here.
|
|
44
|
+
* Most presets use `server`; Vercel uses `functions/__server.func`.
|
|
45
|
+
*/
|
|
46
|
+
serverBundleDir: string;
|
|
41
47
|
/** Whether the runtime supports waitUntil. */
|
|
42
48
|
supportsWaitUntil: boolean;
|
|
43
49
|
/** Whether the runtime supports application-level 103 Early Hints. */
|
|
@@ -52,6 +58,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
52
58
|
'vercel': {
|
|
53
59
|
nitroPreset: 'vercel',
|
|
54
60
|
outputDir: '.vercel/output',
|
|
61
|
+
serverBundleDir: 'functions/__server.func',
|
|
55
62
|
supportsWaitUntil: true,
|
|
56
63
|
supportsEarlyHints: false,
|
|
57
64
|
runtimeName: 'vercel',
|
|
@@ -60,6 +67,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
60
67
|
'vercel-edge': {
|
|
61
68
|
nitroPreset: 'vercel-edge',
|
|
62
69
|
outputDir: '.vercel/output',
|
|
70
|
+
serverBundleDir: 'functions/__server.func',
|
|
63
71
|
supportsWaitUntil: true,
|
|
64
72
|
supportsEarlyHints: false,
|
|
65
73
|
runtimeName: 'vercel-edge',
|
|
@@ -67,6 +75,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
67
75
|
'netlify': {
|
|
68
76
|
nitroPreset: 'netlify',
|
|
69
77
|
outputDir: '.netlify/functions-internal',
|
|
78
|
+
serverBundleDir: 'server',
|
|
70
79
|
supportsWaitUntil: false,
|
|
71
80
|
supportsEarlyHints: false,
|
|
72
81
|
runtimeName: 'netlify',
|
|
@@ -74,6 +83,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
74
83
|
'netlify-edge': {
|
|
75
84
|
nitroPreset: 'netlify-edge',
|
|
76
85
|
outputDir: '.netlify/edge-functions',
|
|
86
|
+
serverBundleDir: 'server',
|
|
77
87
|
supportsWaitUntil: true,
|
|
78
88
|
supportsEarlyHints: false,
|
|
79
89
|
runtimeName: 'netlify-edge',
|
|
@@ -81,6 +91,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
81
91
|
'aws-lambda': {
|
|
82
92
|
nitroPreset: 'aws-lambda',
|
|
83
93
|
outputDir: '.output',
|
|
94
|
+
serverBundleDir: 'server',
|
|
84
95
|
supportsWaitUntil: false,
|
|
85
96
|
supportsEarlyHints: false,
|
|
86
97
|
runtimeName: 'aws-lambda',
|
|
@@ -88,6 +99,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
88
99
|
'deno-deploy': {
|
|
89
100
|
nitroPreset: 'deno-deploy',
|
|
90
101
|
outputDir: '.output',
|
|
102
|
+
serverBundleDir: 'server',
|
|
91
103
|
supportsWaitUntil: true,
|
|
92
104
|
supportsEarlyHints: false,
|
|
93
105
|
runtimeName: 'deno-deploy',
|
|
@@ -95,6 +107,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
95
107
|
'azure-functions': {
|
|
96
108
|
nitroPreset: 'azure-functions',
|
|
97
109
|
outputDir: '.output',
|
|
110
|
+
serverBundleDir: 'server',
|
|
98
111
|
supportsWaitUntil: false,
|
|
99
112
|
supportsEarlyHints: false,
|
|
100
113
|
runtimeName: 'azure-functions',
|
|
@@ -102,6 +115,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
102
115
|
'node-server': {
|
|
103
116
|
nitroPreset: 'node-server',
|
|
104
117
|
outputDir: '.output',
|
|
118
|
+
serverBundleDir: 'server',
|
|
105
119
|
supportsWaitUntil: true,
|
|
106
120
|
// Disabled by default: most node-server deployments sit behind a
|
|
107
121
|
// reverse proxy (nginx, caddy, traefik) that doesn't support 103
|
|
@@ -116,6 +130,7 @@ const PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {
|
|
|
116
130
|
'bun': {
|
|
117
131
|
nitroPreset: 'bun',
|
|
118
132
|
outputDir: '.output',
|
|
133
|
+
serverBundleDir: 'server',
|
|
119
134
|
supportsWaitUntil: true,
|
|
120
135
|
// Disabled for same reason as node-server — reverse proxies choke on 103.
|
|
121
136
|
// Link headers on the 200 response are converted to 103 by CDNs.
|
|
@@ -195,11 +210,14 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
195
210
|
publicDirName: 'public',
|
|
196
211
|
});
|
|
197
212
|
|
|
198
|
-
// Write
|
|
213
|
+
// Write runtime helper modules used by the preview server.
|
|
199
214
|
// See design/25-production-deployments.md — self-hosted deployments
|
|
200
215
|
// need application-level compression (Cloudflare handles it at the edge).
|
|
201
216
|
await writeFile(join(outDir, '_compress.mjs'), await generateCompressModule());
|
|
202
217
|
|
|
218
|
+
// Web→Node response bridge with backpressure. See TIM-1154.
|
|
219
|
+
await writeFile(join(outDir, '_send-response.mjs'), generateSendResponseModule());
|
|
220
|
+
|
|
203
221
|
// Prepend the manifest assignment directly into the RSC entry so
|
|
204
222
|
// globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.
|
|
205
223
|
// This must be top-level code, not an import, because rollup tree-shakes
|
|
@@ -217,7 +235,29 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
|
|
|
217
235
|
// Run the Nitro build to produce a production-ready server bundle.
|
|
218
236
|
// The output goes to dist/nitro/.output/server/index.mjs (for node-server preset).
|
|
219
237
|
// Config is passed programmatically — no nitro.config.ts file needed.
|
|
220
|
-
await runNitroBuild(outDir, preset, options.nitroConfig);
|
|
238
|
+
const { serverDir } = await runNitroBuild(outDir, preset, options.nitroConfig);
|
|
239
|
+
|
|
240
|
+
// Copy prebuilt flight payloads from the canonical build source
|
|
241
|
+
// (buildDir/prebuilt, cleaned by the prebuilt plugin each build) into
|
|
242
|
+
// the Nitro output. The RSC entry resolves `../prebuilt/` relative to
|
|
243
|
+
// its bundled chunk (<serverDir>/_chunks/), so prebuilt/ must live at
|
|
244
|
+
// <serverDir>/prebuilt/. serverDir is the resolved path from Nitro
|
|
245
|
+
// (honors user overrides via nitroConfig.output.serverDir).
|
|
246
|
+
const prebuiltSrc = join(buildDir, 'prebuilt');
|
|
247
|
+
await cp(prebuiltSrc, join(serverDir, 'prebuilt'), {
|
|
248
|
+
recursive: true,
|
|
249
|
+
}).catch((e: NodeJS.ErrnoException) => {
|
|
250
|
+
if (e.code !== 'ENOENT') throw e;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Vercel with functionRules clones __server.func to per-route .func
|
|
254
|
+
// directories during the Nitro build. Copy prebuilt into those clones
|
|
255
|
+
// so requests routed to them also resolve ../prebuilt correctly.
|
|
256
|
+
if (preset === 'vercel' || preset === 'vercel-edge') {
|
|
257
|
+
const functionsDir = dirname(serverDir);
|
|
258
|
+
const baseFuncName = basename(serverDir);
|
|
259
|
+
await copyPrebuiltToVercelFuncClones(functionsDir, prebuiltSrc, baseFuncName);
|
|
260
|
+
}
|
|
221
261
|
},
|
|
222
262
|
|
|
223
263
|
// Only presets that produce a locally-runnable server get preview().
|
|
@@ -378,6 +418,9 @@ const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}'
|
|
|
378
418
|
// Import compression helper for self-hosted response compression.
|
|
379
419
|
const { compressResponse } = await import('./_compress.mjs');
|
|
380
420
|
|
|
421
|
+
// Web→Node response bridge with backpressure (TIM-1154).
|
|
422
|
+
const { sendNodeResponse } = await import('./_send-response.mjs');
|
|
423
|
+
|
|
381
424
|
const MIME_TYPES = {
|
|
382
425
|
'.html': 'text/html',
|
|
383
426
|
'.js': 'application/javascript',
|
|
@@ -414,7 +457,7 @@ const publicDir = join(__dirname, '${publicDir}');
|
|
|
414
457
|
const envPort = process.env.PORT ? parseInt(process.env.PORT, 10) : null;
|
|
415
458
|
const portIsExplicit = envPort != null && Number.isFinite(envPort) && envPort > 0;
|
|
416
459
|
const startPort = portIsExplicit ? envPort : 3000;
|
|
417
|
-
const host = process.env.HOST ||
|
|
460
|
+
const host = process.env.HOST || 'localhost';
|
|
418
461
|
|
|
419
462
|
// Set after listenWithBump() resolves so request handlers can build
|
|
420
463
|
// absolute URLs from the actual bound port (which may differ from
|
|
@@ -501,68 +544,11 @@ const server = createServer(async (req, res) => {
|
|
|
501
544
|
// Compress the response for self-hosted deployments.
|
|
502
545
|
const webResponse = compressResponse(webRequest, rawResponse);
|
|
503
546
|
|
|
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
|
-
}
|
|
547
|
+
// Write the response to Node's ServerResponse. sendNodeResponse handles
|
|
548
|
+
// status, headers (including Set-Cookie splitting via [...headers]),
|
|
549
|
+
// body streaming with backpressure, and client disconnect cleanup.
|
|
550
|
+
// See TIM-1154.
|
|
551
|
+
await sendNodeResponse(res, webResponse);
|
|
566
552
|
} catch (err) {
|
|
567
553
|
console.error('[timber preview] Request error:', err);
|
|
568
554
|
if (!res.headersSent) {
|
|
@@ -649,11 +635,16 @@ export function generateNitroPreviewCommand(
|
|
|
649
635
|
* Externalizes the timber RSC/SSR output — those files are pre-built
|
|
650
636
|
* by timber and have internal references that nitro's bundler can't follow.
|
|
651
637
|
*/
|
|
638
|
+
interface NitroBuildResult {
|
|
639
|
+
/** Resolved output.serverDir — where the server bundle + _chunks/ live. */
|
|
640
|
+
serverDir: string;
|
|
641
|
+
}
|
|
642
|
+
|
|
652
643
|
async function runNitroBuild(
|
|
653
644
|
nitroDir: string,
|
|
654
645
|
preset: NitroPreset,
|
|
655
646
|
userConfig?: Record<string, unknown>
|
|
656
|
-
): Promise<
|
|
647
|
+
): Promise<NitroBuildResult> {
|
|
657
648
|
const presetConfig = PRESET_CONFIGS[preset];
|
|
658
649
|
const {
|
|
659
650
|
createNitro,
|
|
@@ -684,10 +675,18 @@ async function runNitroBuild(
|
|
|
684
675
|
...userConfig,
|
|
685
676
|
});
|
|
686
677
|
|
|
678
|
+
// Read the resolved serverDir AFTER config merging — userConfig may
|
|
679
|
+
// override output.dir or output.serverDir. Nitro appends a trailing
|
|
680
|
+
// slash during resolution; strip it for join() compatibility.
|
|
681
|
+
const raw = nitro.options.output.serverDir;
|
|
682
|
+
const serverDir = raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
|
683
|
+
|
|
687
684
|
await prepare(nitro);
|
|
688
685
|
await copyPublicAssets(nitro);
|
|
689
686
|
await nitroBuild(nitro);
|
|
690
687
|
await nitro.close();
|
|
688
|
+
|
|
689
|
+
return { serverDir };
|
|
691
690
|
}
|
|
692
691
|
|
|
693
692
|
/** Spawn a Nitro preview process and pipe stdio. */
|
|
@@ -702,6 +701,75 @@ function spawnNitroPreview(command: string, args: string[], cwd: string): Promis
|
|
|
702
701
|
});
|
|
703
702
|
}
|
|
704
703
|
|
|
704
|
+
// ─── Send Response Module ───────────────────────────────────────────────────
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Generate a standalone ESM module that exports sendNodeResponse.
|
|
708
|
+
*
|
|
709
|
+
* Mirrors srvx/node's sendNodeResponse: status + headers via writeHead,
|
|
710
|
+
* body streaming with backpressure (waits for drain on write() === false),
|
|
711
|
+
* and client disconnect cleanup. Written to `_send-response.mjs` during
|
|
712
|
+
* buildOutput for use by the preview server script. See TIM-1154.
|
|
713
|
+
*
|
|
714
|
+
* @internal Exported for testing.
|
|
715
|
+
*/
|
|
716
|
+
export function generateSendResponseModule(): string {
|
|
717
|
+
return `// Generated by @timber-js/app — Web→Node response bridge.
|
|
718
|
+
// Do not edit — this file is regenerated on each build.
|
|
719
|
+
// Mirrors srvx/node's sendNodeResponse with backpressure support.
|
|
720
|
+
|
|
721
|
+
export function sendNodeResponse(nodeRes, webRes) {
|
|
722
|
+
if (!webRes) {
|
|
723
|
+
nodeRes.statusCode = 500;
|
|
724
|
+
return new Promise((resolve) => nodeRes.end(resolve));
|
|
725
|
+
}
|
|
726
|
+
const rawHeaders = [...webRes.headers];
|
|
727
|
+
const writeHeaders = rawHeaders.flat();
|
|
728
|
+
if (!nodeRes.headersSent) {
|
|
729
|
+
if (nodeRes.req?.httpVersion === '2.0') {
|
|
730
|
+
nodeRes.writeHead(webRes.status, writeHeaders);
|
|
731
|
+
} else {
|
|
732
|
+
nodeRes.writeHead(webRes.status, webRes.statusText, writeHeaders);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (!webRes.body) {
|
|
736
|
+
return new Promise((resolve) => nodeRes.end(resolve));
|
|
737
|
+
}
|
|
738
|
+
// Stream with backpressure: pause reading when the kernel buffer is
|
|
739
|
+
// full (write() returns false) and resume on 'drain'.
|
|
740
|
+
if (nodeRes.destroyed) {
|
|
741
|
+
webRes.body.cancel();
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const reader = webRes.body.getReader();
|
|
745
|
+
function streamCancel(error) {
|
|
746
|
+
reader.cancel(error).catch(() => {});
|
|
747
|
+
if (error) nodeRes.destroy(error);
|
|
748
|
+
}
|
|
749
|
+
function streamHandle({ done, value }) {
|
|
750
|
+
try {
|
|
751
|
+
if (done) {
|
|
752
|
+
nodeRes.end();
|
|
753
|
+
} else if (nodeRes.write(value)) {
|
|
754
|
+
reader.read().then(streamHandle, streamCancel);
|
|
755
|
+
} else {
|
|
756
|
+
nodeRes.once('drain', () => reader.read().then(streamHandle, streamCancel));
|
|
757
|
+
}
|
|
758
|
+
} catch (error) {
|
|
759
|
+
streamCancel(error instanceof Error ? error : undefined);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
nodeRes.on('close', streamCancel);
|
|
763
|
+
nodeRes.on('error', streamCancel);
|
|
764
|
+
reader.read().then(streamHandle, streamCancel);
|
|
765
|
+
return reader.closed.catch(streamCancel).finally(() => {
|
|
766
|
+
nodeRes.off('close', streamCancel);
|
|
767
|
+
nodeRes.off('error', streamCancel);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
`;
|
|
771
|
+
}
|
|
772
|
+
|
|
705
773
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
706
774
|
|
|
707
775
|
/**
|
|
@@ -711,3 +779,55 @@ function spawnNitroPreview(command: string, args: string[], cwd: string): Promis
|
|
|
711
779
|
export function getPresetConfig(preset: NitroPreset): PresetConfig {
|
|
712
780
|
return PRESET_CONFIGS[preset];
|
|
713
781
|
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Resolve the destination path for prebuilt flight payloads in the Nitro
|
|
785
|
+
* output directory. The RSC entry resolves `../prebuilt/` relative to its
|
|
786
|
+
* bundled chunk location (`<serverBundleDir>/_chunks/`), so prebuilt/ must
|
|
787
|
+
* live at `<serverBundleDir>/prebuilt/`.
|
|
788
|
+
*
|
|
789
|
+
* The serverBundleDir varies by preset — Nitro's Vercel preset uses
|
|
790
|
+
* `functions/__server.func` while most others use `server`.
|
|
791
|
+
*
|
|
792
|
+
* @internal Exported for testing.
|
|
793
|
+
*/
|
|
794
|
+
export function resolvePrebuiltOutputPath(outDir: string, preset: NitroPreset): string {
|
|
795
|
+
const presetConfig = PRESET_CONFIGS[preset];
|
|
796
|
+
return join(outDir, presetConfig.outputDir, presetConfig.serverBundleDir, 'prebuilt');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Copy prebuilt payloads into cloned Vercel function directories.
|
|
801
|
+
*
|
|
802
|
+
* When users configure `vercel.functionRules`, Nitro's compiled hook clones
|
|
803
|
+
* the base __server.func to per-route .func directories. These clones are
|
|
804
|
+
* created during `runNitroBuild()` — before our post-build prebuilt copy.
|
|
805
|
+
* Each clone resolves `../prebuilt` independently, so they each need a copy.
|
|
806
|
+
*
|
|
807
|
+
* @internal Exported for testing.
|
|
808
|
+
*/
|
|
809
|
+
export async function copyPrebuiltToVercelFuncClones(
|
|
810
|
+
functionsDir: string,
|
|
811
|
+
prebuiltSrc: string,
|
|
812
|
+
baseFuncName: string
|
|
813
|
+
): Promise<void> {
|
|
814
|
+
const funcDirs: string[] = [];
|
|
815
|
+
try {
|
|
816
|
+
for await (const match of glob('**/*.func', { cwd: functionsDir })) {
|
|
817
|
+
if (!match.endsWith(baseFuncName)) {
|
|
818
|
+
funcDirs.push(join(functionsDir, match));
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
} catch {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
await Promise.all(
|
|
825
|
+
funcDirs.map((dir) =>
|
|
826
|
+
cp(prebuiltSrc, join(dir, 'prebuilt'), { recursive: true }).catch(
|
|
827
|
+
(e: NodeJS.ErrnoException) => {
|
|
828
|
+
if (e.code !== 'ENOENT') throw e;
|
|
829
|
+
}
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
);
|
|
833
|
+
}
|
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
|
};
|