@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.
Files changed (102) 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 +40 -0
  15. package/dist/adapters/nitro.d.ts.map +1 -1
  16. package/dist/adapters/nitro.js +133 -73
  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/slot-context.d.ts +29 -0
  28. package/dist/client/slot-context.d.ts.map +1 -0
  29. package/dist/client/slot-outlet.d.ts +16 -0
  30. package/dist/client/slot-outlet.d.ts.map +1 -0
  31. package/dist/client/slot-provider.d.ts +20 -0
  32. package/dist/client/slot-provider.d.ts.map +1 -0
  33. package/dist/config-types.d.ts +2 -1
  34. package/dist/config-types.d.ts.map +1 -1
  35. package/dist/dev-tools/logs.d.ts.map +1 -1
  36. package/dist/index.js +293 -124
  37. package/dist/index.js.map +1 -1
  38. package/dist/plugin-context.d.ts +27 -0
  39. package/dist/plugin-context.d.ts.map +1 -1
  40. package/dist/plugins/cache.d.ts.map +1 -1
  41. package/dist/plugins/client-chunks.d.ts.map +1 -1
  42. package/dist/plugins/dev-server.d.ts.map +1 -1
  43. package/dist/plugins/prebuilt-options-analysis.d.ts +40 -0
  44. package/dist/plugins/prebuilt-options-analysis.d.ts.map +1 -0
  45. package/dist/plugins/prebuilt.d.ts.map +1 -1
  46. package/dist/plugins/prerender-sugar.d.ts.map +1 -1
  47. package/dist/routing/index.js +2 -2
  48. package/dist/server/html-injector-core.d.ts +30 -9
  49. package/dist/server/html-injector-core.d.ts.map +1 -1
  50. package/dist/server/html-injectors.d.ts.map +1 -1
  51. package/dist/server/index.js +1 -1
  52. package/dist/server/internal.js +346 -51
  53. package/dist/server/internal.js.map +1 -1
  54. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  55. package/dist/server/pipeline-phases.d.ts.map +1 -1
  56. package/dist/server/prebuilt/cache-key.d.ts +4 -0
  57. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  58. package/dist/server/prebuilt/key-discipline.d.ts +23 -0
  59. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -0
  60. package/dist/server/prebuilt/slots.d.ts +74 -0
  61. package/dist/server/prebuilt/slots.d.ts.map +1 -0
  62. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  63. package/dist/server/prebuilt-runtime.d.ts +12 -2
  64. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  65. package/dist/server/rsc-entry/deny-fallback.d.ts +29 -0
  66. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -0
  67. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  68. package/docs/api/34-api-config.mdx +165 -3
  69. package/docs/learn/00-introduction.mdx +78 -44
  70. package/docs/learn/13-configuration.mdx +27 -8
  71. package/package.json +3 -2
  72. package/src/adapters/cloudflare-kv-cache.ts +5 -1
  73. package/src/adapters/nitro.ts +188 -68
  74. package/src/cache/index.ts +25 -2
  75. package/src/cache/redis-handler.ts +27 -7
  76. package/src/cache/tag-aware-handler.ts +5 -1
  77. package/src/cache/timber-cache.ts +21 -10
  78. package/src/client/slot-context.ts +48 -0
  79. package/src/client/slot-outlet.tsx +22 -0
  80. package/src/client/slot-provider.tsx +25 -0
  81. package/src/config-types.ts +2 -1
  82. package/src/dev-tools/logs.ts +7 -0
  83. package/src/plugin-context.ts +54 -0
  84. package/src/plugins/cache.ts +1 -2
  85. package/src/plugins/client-chunks.ts +42 -1
  86. package/src/plugins/dev-server.ts +12 -69
  87. package/src/plugins/prebuilt-options-analysis.ts +175 -0
  88. package/src/plugins/prebuilt.ts +82 -127
  89. package/src/plugins/prerender-sugar.ts +1 -2
  90. package/src/server/html-injector-core.ts +85 -27
  91. package/src/server/html-injectors.ts +5 -1
  92. package/src/server/node-stream-transforms.ts +6 -1
  93. package/src/server/pipeline-phases.ts +4 -1
  94. package/src/server/prebuilt/cache-key.ts +74 -0
  95. package/src/server/prebuilt/key-discipline.ts +53 -0
  96. package/src/server/prebuilt/slots.ts +167 -0
  97. package/src/server/prebuilt-builder.ts +57 -23
  98. package/src/server/prebuilt-runtime.ts +144 -60
  99. package/src/server/rsc-entry/deny-fallback.ts +92 -0
  100. package/src/server/rsc-entry/index.ts +16 -70
  101. package/dist/_chunks/cache-api-CzYUlgXA.js.map +0 -1
  102. 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 | 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.163",
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(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,
@@ -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 the compression helper module for runtime use.
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 || process.env.HOSTNAME || 'localhost';
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 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
- }
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<void> {
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
+ }
@@ -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
  };