@timber-js/app 0.1.37 → 0.1.39
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/adapters/compress-module.d.ts +10 -0
- package/dist/adapters/compress-module.d.ts.map +1 -0
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +120 -39
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/index.js +126 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/server/compress.d.ts +37 -0
- package/dist/server/compress.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +43 -0
- package/dist/server/state-tree-diff.d.ts.map +1 -0
- package/package.json +2 -3
- package/src/adapters/compress-module.ts +108 -0
- package/src/adapters/nitro.ts +31 -63
- package/src/plugins/dev-server.ts +7 -1
- package/src/server/compress.ts +200 -0
- package/src/server/route-element-builder.ts +27 -2
- package/src/server/rsc-entry/index.ts +9 -1
- package/src/server/state-tree-diff.ts +77 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a standalone ESM module that exports compressResponse().
|
|
3
|
+
*
|
|
4
|
+
* Written to `_compress.mjs` during buildOutput. Imported by the Nitro entry
|
|
5
|
+
* and preview server.
|
|
6
|
+
*
|
|
7
|
+
* @internal Exported for testing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function generateCompressModule(): string;
|
|
10
|
+
//# sourceMappingURL=compress-module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compress-module.d.ts","sourceRoot":"","sources":["../../src/adapters/compress-module.ts"],"names":[],"mappings":"AAWA;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CAwF/C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nitro.d.ts","sourceRoot":"","sources":["../../src/adapters/nitro.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,qBAAqB,EAAgB,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"nitro.d.ts","sourceRoot":"","sources":["../../src/adapters/nitro.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,qBAAqB,EAAgB,MAAM,SAAS,CAAC;AAsBnE;;;;;GAKG;AACH,MAAM,MAAM,WAAW,GACnB,QAAQ,GACR,aAAa,GACb,SAAS,GACT,cAAc,GACd,YAAY,GACZ,aAAa,GACb,iBAAiB,GACjB,aAAa,GACb,KAAK,CAAC;AAEV,2CAA2C;AAC3C,UAAU,YAAY;IACpB,mDAAmD;IACnD,WAAW,EAAE,MAAM,CAAC;IACpB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,iBAAiB,EAAE,OAAO,CAAC;IAC3B,sEAAsE;IACtE,kBAAkB,EAAE,OAAO,CAAC;IAC5B,iFAAiF;IACjF,WAAW,EAAE,MAAM,CAAC;IACpB,sDAAsD;IACtD,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAuED,qCAAqC;AACrC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IAErB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACvC;AAID;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,KAAK,CAAC,OAAO,GAAE,mBAAwB,GAAG,qBAAqB,CA2E9E;AAID,sCAAsC;AACtC,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,EACnB,eAAe,UAAQ,GACtB,MAAM,CA+CR;AAED,sCAAsC;AACtC,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAwBR;AAOD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAqKnF;AAED,wEAAwE;AACxE,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;CACb;AAED,sCAAsC;AACtC,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,WAAW,GAClB,mBAAmB,GAAG,IAAI,CAY5B;AAgBD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,CAEjE"}
|
package/dist/adapters/nitro.js
CHANGED
|
@@ -1,6 +1,105 @@
|
|
|
1
1
|
import { join, relative } from "node:path";
|
|
2
2
|
import { cp, mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
|
+
//#region src/adapters/compress-module.ts
|
|
5
|
+
/**
|
|
6
|
+
* Generate a standalone ESM module that exports compressResponse().
|
|
7
|
+
*
|
|
8
|
+
* Written to `_compress.mjs` during buildOutput. Imported by the Nitro entry
|
|
9
|
+
* and preview server.
|
|
10
|
+
*
|
|
11
|
+
* @internal Exported for testing.
|
|
12
|
+
*/
|
|
13
|
+
function generateCompressModule() {
|
|
14
|
+
return `// Generated by @timber-js/app — response compression for self-hosted deployments.
|
|
15
|
+
// Do not edit — this file is regenerated on each build.
|
|
16
|
+
// Uses CompressionStream (Web API) for gzip, node:zlib for brotli.
|
|
17
|
+
|
|
18
|
+
import { createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
19
|
+
import { Readable } from 'node:stream';
|
|
20
|
+
|
|
21
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
22
|
+
'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
|
|
23
|
+
'text/x-component', 'application/json', 'application/javascript',
|
|
24
|
+
'application/xml', 'application/xhtml+xml', 'application/rss+xml',
|
|
25
|
+
'application/atom+xml', 'image/svg+xml',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
29
|
+
|
|
30
|
+
function negotiateEncoding(acceptEncoding) {
|
|
31
|
+
if (!acceptEncoding) return null;
|
|
32
|
+
const tokens = acceptEncoding.split(',').map(s => s.split(';')[0].trim().toLowerCase());
|
|
33
|
+
if (tokens.includes('br')) return 'br';
|
|
34
|
+
if (tokens.includes('gzip')) return 'gzip';
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shouldCompress(response) {
|
|
39
|
+
if (!response.body) return false;
|
|
40
|
+
if (NO_COMPRESS_STATUSES.has(response.status)) return false;
|
|
41
|
+
if (response.headers.has('Content-Encoding')) return false;
|
|
42
|
+
const contentType = response.headers.get('Content-Type');
|
|
43
|
+
if (!contentType) return false;
|
|
44
|
+
const mimeType = contentType.split(';')[0].trim().toLowerCase();
|
|
45
|
+
if (mimeType === 'text/event-stream') return false;
|
|
46
|
+
return COMPRESSIBLE_TYPES.has(mimeType);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compressWithGzip(body) {
|
|
50
|
+
return body.pipeThrough(new CompressionStream('gzip'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function compressWithBrotli(body) {
|
|
54
|
+
const brotli = createBrotliCompress({
|
|
55
|
+
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
|
|
56
|
+
});
|
|
57
|
+
const reader = body.getReader();
|
|
58
|
+
const pump = async () => {
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) { brotli.end(); return; }
|
|
63
|
+
if (!brotli.write(value)) {
|
|
64
|
+
await new Promise(resolve => brotli.once('drain', resolve));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
brotli.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
pump();
|
|
72
|
+
return Readable.toWeb(brotli);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function compressResponse(request, response) {
|
|
76
|
+
if (!shouldCompress(response)) return response;
|
|
77
|
+
const acceptEncoding = request.headers.get('Accept-Encoding') || '';
|
|
78
|
+
const encoding = negotiateEncoding(acceptEncoding);
|
|
79
|
+
if (!encoding) return response;
|
|
80
|
+
const compressedBody = encoding === 'br'
|
|
81
|
+
? compressWithBrotli(response.body)
|
|
82
|
+
: compressWithGzip(response.body);
|
|
83
|
+
const headers = new Headers(response.headers);
|
|
84
|
+
headers.set('Content-Encoding', encoding);
|
|
85
|
+
headers.delete('Content-Length');
|
|
86
|
+
const existingVary = headers.get('Vary');
|
|
87
|
+
if (existingVary) {
|
|
88
|
+
if (!existingVary.toLowerCase().includes('accept-encoding')) {
|
|
89
|
+
headers.set('Vary', existingVary + ', Accept-Encoding');
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
headers.set('Vary', 'Accept-Encoding');
|
|
93
|
+
}
|
|
94
|
+
return new Response(compressedBody, {
|
|
95
|
+
status: response.status,
|
|
96
|
+
statusText: response.statusText,
|
|
97
|
+
headers,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
//#endregion
|
|
4
103
|
//#region src/adapters/nitro.ts
|
|
5
104
|
var IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
6
105
|
var STATIC_CACHE = "public, max-age=3600, must-revalidate";
|
|
@@ -115,11 +214,11 @@ function nitro(options = {}) {
|
|
|
115
214
|
}).catch(() => {});
|
|
116
215
|
await writeFile(join(publicDir, "_headers"), generateHeadersFile());
|
|
117
216
|
if (config.manifestInit) await writeFile(join(outDir, "_timber-manifest-init.js"), config.manifestInit);
|
|
118
|
-
await
|
|
119
|
-
await cp(join(buildDir, "ssr"), join(outDir, "ssr"), { recursive: true }).catch(() => {});
|
|
217
|
+
await writeFile(join(outDir, "_compress.mjs"), generateCompressModule());
|
|
120
218
|
const entry = generateNitroEntry(buildDir, outDir, preset, !!config.manifestInit);
|
|
121
219
|
await writeFile(join(outDir, "entry.ts"), entry);
|
|
122
|
-
|
|
220
|
+
const nitroConfig = generateNitroConfig(preset, options.nitroConfig);
|
|
221
|
+
await writeFile(join(outDir, "nitro.config.ts"), nitroConfig);
|
|
123
222
|
},
|
|
124
223
|
preview: LOCALLY_PREVIEWABLE.has(preset) ? async (_config, buildDir) => {
|
|
125
224
|
const previewScript = generatePreviewScript(buildDir, preset);
|
|
@@ -137,22 +236,23 @@ function nitro(options = {}) {
|
|
|
137
236
|
}
|
|
138
237
|
/** @internal Exported for testing. */
|
|
139
238
|
function generateNitroEntry(buildDir, outDir, preset, hasManifestInit = false) {
|
|
140
|
-
|
|
239
|
+
let serverEntryRelative = relative(outDir, join(buildDir, "rsc", "index.js"));
|
|
240
|
+
if (!serverEntryRelative.startsWith(".")) serverEntryRelative = "./" + serverEntryRelative;
|
|
141
241
|
const runtimeName = PRESET_CONFIGS[preset].runtimeName;
|
|
142
242
|
const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;
|
|
143
243
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
144
244
|
// Do not edit — this file is regenerated on each build.
|
|
145
245
|
|
|
146
|
-
${hasManifestInit ? "import './_timber-manifest-init.js'\n" : ""}import { defineEventHandler } from '
|
|
246
|
+
${hasManifestInit ? "import './_timber-manifest-init.js'\n" : ""}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'
|
|
147
247
|
import handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'
|
|
248
|
+
import { compressResponse } from './_compress.mjs'
|
|
148
249
|
|
|
149
250
|
// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
|
|
150
251
|
// See design/25-production-deployments.md §"TIMBER_RUNTIME".
|
|
151
252
|
process.env.TIMBER_RUNTIME = '${runtimeName}'
|
|
152
253
|
|
|
153
254
|
export default defineEventHandler(async (event) => {
|
|
154
|
-
|
|
155
|
-
const webRequest = event.req
|
|
255
|
+
const webRequest = toWebRequest(event)
|
|
156
256
|
${earlyHints ? ` const nodeRes = event.node?.res
|
|
157
257
|
const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')
|
|
158
258
|
? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }
|
|
@@ -161,7 +261,8 @@ ${earlyHints ? ` const nodeRes = event.node?.res
|
|
|
161
261
|
const webResponse = earlyHintsSender
|
|
162
262
|
? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
|
|
163
263
|
: await handler(webRequest)` : ` const webResponse = await handler(webRequest)`}
|
|
164
|
-
|
|
264
|
+
const finalResponse = compressResponse(webRequest, webResponse)
|
|
265
|
+
return sendWebResponse(event, finalResponse)
|
|
165
266
|
})
|
|
166
267
|
`;
|
|
167
268
|
}
|
|
@@ -170,7 +271,6 @@ function generateNitroConfig(preset, userConfig) {
|
|
|
170
271
|
const presetConfig = PRESET_CONFIGS[preset];
|
|
171
272
|
const config = {
|
|
172
273
|
preset: presetConfig.nitroPreset,
|
|
173
|
-
entry: "./entry.ts",
|
|
174
274
|
output: { dir: presetConfig.outputDir },
|
|
175
275
|
routeRules: { "/assets/**": { headers: { "Cache-Control": IMMUTABLE_CACHE } } },
|
|
176
276
|
...presetConfig.extraConfig,
|
|
@@ -179,7 +279,7 @@ function generateNitroConfig(preset, userConfig) {
|
|
|
179
279
|
return `// Generated by @timber-js/app/adapters/nitro
|
|
180
280
|
// Do not edit — this file is regenerated on each build.
|
|
181
281
|
|
|
182
|
-
import { defineNitroConfig } from '
|
|
282
|
+
import { defineNitroConfig } from 'nitropack/config'
|
|
183
283
|
|
|
184
284
|
export default defineNitroConfig(${JSON.stringify(config, null, 2)})
|
|
185
285
|
`;
|
|
@@ -222,6 +322,9 @@ if (existsSync(manifestPath)) {
|
|
|
222
322
|
// Import the RSC handler (default export is the fetch-like handler).
|
|
223
323
|
const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
|
|
224
324
|
|
|
325
|
+
// Import compression helper for self-hosted response compression.
|
|
326
|
+
const { compressResponse } = await import('./_compress.mjs');
|
|
327
|
+
|
|
225
328
|
const MIME_TYPES = {
|
|
226
329
|
'.html': 'text/html',
|
|
227
330
|
'.js': 'application/javascript',
|
|
@@ -249,10 +352,9 @@ const MIME_TYPES = {
|
|
|
249
352
|
|
|
250
353
|
const publicDir = join(__dirname, '${publicDir}');
|
|
251
354
|
const port = parseInt(process.env.PORT || '3000', 10);
|
|
252
|
-
const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
|
|
253
355
|
|
|
254
356
|
const server = createServer(async (req, res) => {
|
|
255
|
-
const url = new URL(req.url || '/', \`http
|
|
357
|
+
const url = new URL(req.url || '/', \`http://localhost:\${port}\`);
|
|
256
358
|
|
|
257
359
|
// Try serving static files from the public directory first.
|
|
258
360
|
const filePath = join(publicDir, url.pathname);
|
|
@@ -315,10 +417,13 @@ const server = createServer(async (req, res) => {
|
|
|
315
417
|
? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
|
|
316
418
|
: undefined;
|
|
317
419
|
|
|
318
|
-
const
|
|
420
|
+
const rawResponse = earlyHintsSender && runWithEarlyHintsSender
|
|
319
421
|
? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
|
|
320
422
|
: await handler(webRequest);
|
|
321
423
|
|
|
424
|
+
// Compress the response for self-hosted deployments.
|
|
425
|
+
const webResponse = compressResponse(webRequest, rawResponse);
|
|
426
|
+
|
|
322
427
|
// Write the response back to the Node response.
|
|
323
428
|
res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
|
|
324
429
|
|
|
@@ -344,11 +449,11 @@ const server = createServer(async (req, res) => {
|
|
|
344
449
|
}
|
|
345
450
|
});
|
|
346
451
|
|
|
347
|
-
server.listen(port,
|
|
452
|
+
server.listen(port, () => {
|
|
348
453
|
console.log();
|
|
349
454
|
console.log(' ⚡ timber preview server running at:');
|
|
350
455
|
console.log();
|
|
351
|
-
console.log(\` ➜ http
|
|
456
|
+
console.log(\` ➜ http://localhost:\${port}\`);
|
|
352
457
|
console.log();
|
|
353
458
|
});
|
|
354
459
|
`;
|
|
@@ -364,30 +469,6 @@ function generateNitroPreviewCommand(buildDir, preset) {
|
|
|
364
469
|
cwd: nitroDir
|
|
365
470
|
};
|
|
366
471
|
}
|
|
367
|
-
/**
|
|
368
|
-
* Run the Nitro production build using the programmatic API.
|
|
369
|
-
* Uses dynamic import so nitro is only loaded at build time.
|
|
370
|
-
* Externalizes the timber RSC/SSR output — those files are pre-built
|
|
371
|
-
* by timber and have internal references that nitro's bundler can't follow.
|
|
372
|
-
*/
|
|
373
|
-
async function runNitroBuild(nitroDir, preset, userConfig) {
|
|
374
|
-
const presetConfig = PRESET_CONFIGS[preset];
|
|
375
|
-
const { createNitro, build: nitroBuild, prepare, copyPublicAssets } = await import("nitro");
|
|
376
|
-
const nitro = await createNitro({
|
|
377
|
-
rootDir: nitroDir,
|
|
378
|
-
preset: presetConfig.nitroPreset,
|
|
379
|
-
renderer: { entry: join(nitroDir, "entry.ts") },
|
|
380
|
-
output: { dir: join(nitroDir, presetConfig.outputDir) },
|
|
381
|
-
routeRules: { "/assets/**": { headers: { "Cache-Control": IMMUTABLE_CACHE } } },
|
|
382
|
-
rollupConfig: { external: [/\.\.\/rsc\//, /\.\/_timber-manifest-init/] },
|
|
383
|
-
...presetConfig.extraConfig,
|
|
384
|
-
...userConfig
|
|
385
|
-
});
|
|
386
|
-
await prepare(nitro);
|
|
387
|
-
await copyPublicAssets(nitro);
|
|
388
|
-
await nitroBuild(nitro);
|
|
389
|
-
await nitro.close();
|
|
390
|
-
}
|
|
391
472
|
/** Spawn a Nitro preview process and pipe stdio. */
|
|
392
473
|
function spawnNitroPreview(command, args, cwd) {
|
|
393
474
|
return new Promise((resolve, reject) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nitro.js","names":[],"sources":["../../src/adapters/nitro.ts"],"sourcesContent":["// Nitro adapter — multi-platform deployment\n//\n// Covers everything except Cloudflare Workers: Node.js, Bun, Vercel,\n// Netlify, AWS Lambda, Deno Deploy, Azure Functions. Nitro handles\n// compression, graceful shutdown, static file serving, and platform quirks.\n// See design/11-platform.md and design/25-production-deployments.md.\n\nimport { writeFile, mkdir, cp } from 'node:fs/promises';\nimport { execFile } from 'node:child_process';\nimport { join, relative } from 'node:path';\nimport type { TimberPlatformAdapter, TimberConfig } from './types';\n// Inlined from server/asset-headers.ts — adapters are loaded by Node at\n// Vite startup time, before Vite's module resolver is available, so cross-\n// directory .ts imports don't resolve.\nconst IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\nconst STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\nfunction generateHeadersFile(): string {\n return `# Auto-generated by @timber-js/app — static asset cache headers.\n# See design/25-production-deployments.md §\"CDN / Edge Cache\"\n\n/assets/*\n Cache-Control: ${IMMUTABLE_CACHE}\n\n/*\n Cache-Control: ${STATIC_CACHE}\n`;\n}\n\n// ─── Presets ─────────────────────────────────────────────────────────────────\n\n/**\n * Supported Nitro deployment presets.\n *\n * Each preset maps to a Nitro deployment target. The adapter generates\n * the appropriate configuration and entry point for the selected platform.\n */\nexport type NitroPreset =\n | 'vercel'\n | 'vercel-edge'\n | 'netlify'\n | 'netlify-edge'\n | 'aws-lambda'\n | 'deno-deploy'\n | 'azure-functions'\n | 'node-server'\n | 'bun';\n\n/** Preset-specific Nitro configuration. */\ninterface PresetConfig {\n /** Nitro preset name passed to the Nitro build. */\n nitroPreset: string;\n /** Output directory name within the build dir. */\n outputDir: string;\n /** Whether the runtime supports waitUntil. */\n supportsWaitUntil: boolean;\n /** Whether the runtime supports application-level 103 Early Hints. */\n supportsEarlyHints: boolean;\n /** Value for TIMBER_RUNTIME env var. See design/25-production-deployments.md. */\n runtimeName: string;\n /** Additional nitro.config fields for this preset. */\n extraConfig?: Record<string, unknown>;\n}\n\nconst PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {\n 'vercel': {\n nitroPreset: 'vercel',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel',\n extraConfig: { vercel: { functions: { maxDuration: 30 } } },\n },\n 'vercel-edge': {\n nitroPreset: 'vercel-edge',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel-edge',\n },\n 'netlify': {\n nitroPreset: 'netlify',\n outputDir: '.netlify/functions-internal',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'netlify',\n },\n 'netlify-edge': {\n nitroPreset: 'netlify-edge',\n outputDir: '.netlify/edge-functions',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'netlify-edge',\n },\n 'aws-lambda': {\n nitroPreset: 'aws-lambda',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'aws-lambda',\n },\n 'deno-deploy': {\n nitroPreset: 'deno-deploy',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'deno-deploy',\n },\n 'azure-functions': {\n nitroPreset: 'azure-functions',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'azure-functions',\n },\n 'node-server': {\n nitroPreset: 'node-server',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'node-server',\n },\n 'bun': {\n nitroPreset: 'bun',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'bun',\n },\n};\n\n// ─── Options ─────────────────────────────────────────────────────────────────\n\n/** Options for the Nitro adapter. */\nexport interface NitroAdapterOptions {\n /**\n * Deployment preset. Determines the target platform.\n * @default 'node-server'\n */\n preset?: NitroPreset;\n\n /**\n * Additional Nitro configuration to merge into the generated config.\n * Overrides default values for the selected preset.\n */\n nitroConfig?: Record<string, unknown>;\n}\n\n// ─── Adapter ─────────────────────────────────────────────────────────────────\n\n/**\n * Create a Nitro-based adapter for multi-platform deployment.\n *\n * Nitro abstracts deployment targets — the same timber.js app can deploy\n * to Vercel, Netlify, AWS, Deno Deploy, or Azure by changing the preset.\n *\n * @example\n * ```ts\n * import { nitro } from '@timber-js/app/adapters/nitro'\n *\n * export default {\n * output: 'server',\n * adapter: nitro({ preset: 'vercel' }),\n * }\n * ```\n */\nexport function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter {\n const preset = options.preset ?? 'node-server';\n const presetConfig = PRESET_CONFIGS[preset];\n const pendingPromises: Promise<unknown>[] = [];\n\n return {\n name: `nitro-${preset}`,\n\n async buildOutput(config: TimberConfig, buildDir: string) {\n const outDir = join(buildDir, 'nitro');\n await mkdir(outDir, { recursive: true });\n\n // Copy client assets to public directory.\n // When client JavaScript is disabled, skip .js files — only CSS,\n // fonts, images, and other static assets are needed.\n const clientDir = join(buildDir, 'client');\n const publicDir = join(outDir, 'public');\n await mkdir(publicDir, { recursive: true });\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: config.clientJavascriptDisabled ? (src: string) => !src.endsWith('.js') : undefined,\n }).catch(() => {\n // Client dir may not exist when client JavaScript is disabled\n });\n\n // Write _headers file for platforms that support it (Netlify, etc.).\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n await writeFile(join(publicDir, '_headers'), generateHeadersFile());\n\n // Write the build manifest init module (if manifest data was produced).\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n // Copy rsc/ssr build output into the nitro dir so imports stay local\n // during the Nitro bundling step (avoids broken relative paths in output).\n await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });\n await cp(join(buildDir, 'ssr'), join(outDir, 'ssr'), { recursive: true }).catch(() => {});\n\n // Generate the Nitro entry point (imports from ./rsc/ within nitro dir)\n const hasManifestInit = !!config.manifestInit;\n const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);\n await writeFile(join(outDir, 'entry.ts'), entry);\n\n // Run the Nitro build to produce a production-ready server bundle.\n // The output goes to dist/nitro/.output/server/index.mjs (for node-server preset).\n // Config is passed programmatically — no nitro.config.ts file needed.\n await runNitroBuild(outDir, preset, options.nitroConfig);\n },\n\n // Only presets that produce a locally-runnable server get preview().\n // Serverless presets (vercel, netlify, aws-lambda, etc.) have no\n // local runtime — Vite's built-in preview is the fallback.\n preview: LOCALLY_PREVIEWABLE.has(preset)\n ? async (_config: TimberConfig, buildDir: string) => {\n // Generate a standalone preview server that uses Node's built-in\n // HTTP server. The Nitro entry.ts can't be run directly because\n // it imports h3 (a Nitro dependency not available at runtime).\n const previewScript = generatePreviewScript(buildDir, preset);\n const scriptPath = join(buildDir, 'nitro', '_preview-server.mjs');\n await writeFile(scriptPath, previewScript);\n\n const command = preset === 'bun' ? 'bun' : 'node';\n await spawnNitroPreview(command, [scriptPath], join(buildDir, 'nitro'));\n }\n : undefined,\n\n waitUntil: presetConfig.supportsWaitUntil\n ? (promise: Promise<unknown>) => {\n const tracked = promise.catch((err) => {\n console.error('[timber] waitUntil promise rejected:', err);\n });\n pendingPromises.push(tracked);\n }\n : undefined,\n };\n}\n\n// ─── Entry Generation ────────────────────────────────────────────────────────\n\n/** @internal Exported for testing. */\nexport function generateNitroEntry(\n buildDir: string,\n outDir: string,\n preset: NitroPreset,\n hasManifestInit = false\n): string {\n // The RSC entry is the main request handler — it exports the fetch handler as default.\n // rsc/ is copied into the nitro dir so the import is local.\n const serverEntryRelative = './rsc/index.js';\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;\n\n // Build manifest init must be imported before the handler so that\n // globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.\n const manifestImport = hasManifestInit ? \"import './_timber-manifest-init.js'\\n\" : '';\n\n // On node-server and bun, wrap the handler with ALS so the pipeline\n // can send 103 Early Hints via res.writeEarlyHints(). Other presets\n // either don't support 103 or handle it at the CDN level.\n const handlerCall = earlyHints\n ? ` const nodeRes = event.node?.res\n const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')\n ? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }\n : undefined\n\n const webResponse = earlyHintsSender\n ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))\n : await handler(webRequest)`\n : ` const webResponse = await handler(webRequest)`;\n\n return `// Generated by @timber-js/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\n${manifestImport}import { defineEventHandler } from 'nitro/h3'\nimport handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'\n\n// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.\n// See design/25-production-deployments.md §\"TIMBER_RUNTIME\".\nprocess.env.TIMBER_RUNTIME = '${runtimeName}'\n\nexport default defineEventHandler(async (event) => {\n // h3 v2: event.req is the Web Request\n const webRequest = event.req\n${handlerCall}\n return webResponse\n})\n`;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroConfig(\n preset: NitroPreset,\n userConfig?: Record<string, unknown>\n): string {\n const presetConfig = PRESET_CONFIGS[preset];\n\n const config: Record<string, unknown> = {\n preset: presetConfig.nitroPreset,\n entry: './entry.ts',\n output: { dir: presetConfig.outputDir },\n // Static asset cache headers — hashed assets are immutable, others get 1h.\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n routeRules: {\n '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },\n },\n ...presetConfig.extraConfig,\n ...userConfig,\n };\n\n const configJson = JSON.stringify(config, null, 2);\n\n return `// Generated by @timber-js/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\nimport { defineNitroConfig } from 'nitro/config'\n\nexport default defineNitroConfig(${configJson})\n`;\n}\n\n// ─── Preview ─────────────────────────────────────────────────────────────────\n\n/** Presets that produce a locally-runnable server entry. */\nconst LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);\n\n/**\n * Generate a standalone preview server script that uses Node's built-in\n * HTTP server. This bypasses Nitro entirely — the Nitro entry.ts imports\n * h3 which isn't available outside a Nitro build. For local preview we\n * just need to serve static files and route requests to the RSC handler.\n *\n * @internal Exported for testing.\n */\nexport function generatePreviewScript(buildDir: string, preset: NitroPreset): string {\n const rscEntryRelative = relative(join(buildDir, 'nitro'), join(buildDir, 'rsc', 'index.js'));\n const rscEntry = rscEntryRelative.startsWith('.') ? rscEntryRelative : './' + rscEntryRelative;\n const publicDir = './public';\n const manifestInitPath = './_timber-manifest-init.js';\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n\n return `// Generated by @timber-js/app — standalone preview server.\n// Uses Node's built-in HTTP server to serve static assets and route\n// dynamic requests through the RSC handler. No Nitro/h3 dependency.\n\nimport { createServer } from 'node:http';\nimport { readFile, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { existsSync } from 'node:fs';\n\n// Set runtime before importing the handler.\nprocess.env.TIMBER_RUNTIME = '${runtimeName}';\n\n// Load the build manifest if it exists.\nconst __dirname = fileURLToPath(new URL('.', import.meta.url));\nconst manifestPath = join(__dirname, '${manifestInitPath}');\nif (existsSync(manifestPath)) {\n await import('${manifestInitPath}');\n}\n\n// Import the RSC handler (default export is the fetch-like handler).\nconst { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');\n\nconst MIME_TYPES = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.mjs': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.ttf': 'font/ttf',\n '.otf': 'font/otf',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.webm': 'video/webm',\n '.mp4': 'video/mp4',\n '.txt': 'text/plain',\n '.xml': 'application/xml',\n '.wasm': 'application/wasm',\n};\n\nconst publicDir = join(__dirname, '${publicDir}');\nconst port = parseInt(process.env.PORT || '3000', 10);\nconst host = process.env.HOST || process.env.HOSTNAME || 'localhost';\n\nconst server = createServer(async (req, res) => {\n const url = new URL(req.url || '/', \\`http://\\${host}:\\${port}\\`);\n\n // Try serving static files from the public directory first.\n const filePath = join(publicDir, url.pathname);\n // Prevent path traversal.\n if (filePath.startsWith(publicDir)) {\n try {\n const fileStat = await stat(filePath);\n if (fileStat.isFile()) {\n const ext = extname(filePath);\n const contentType = MIME_TYPES[ext] || 'application/octet-stream';\n const body = await readFile(filePath);\n // Hashed assets get immutable cache, others get short cache.\n const cacheControl = url.pathname.startsWith('/assets/')\n ? 'public, max-age=31536000, immutable'\n : 'public, max-age=3600, must-revalidate';\n res.writeHead(200, {\n 'Content-Type': contentType,\n 'Content-Length': body.length,\n 'Cache-Control': cacheControl,\n });\n res.end(body);\n return;\n }\n } catch {\n // File not found — fall through to the RSC handler.\n }\n }\n\n // Convert Node request to Web Request.\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value) {\n if (Array.isArray(value)) {\n for (const v of value) headers.append(key, v);\n } else {\n headers.set(key, value);\n }\n }\n }\n\n let body = undefined;\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n body = await new Promise((resolve) => {\n const chunks = [];\n req.on('data', (chunk) => chunks.push(chunk));\n req.on('end', () => resolve(Buffer.concat(chunks)));\n });\n }\n\n const webRequest = new Request(url.href, {\n method: req.method,\n headers,\n body,\n duplex: body ? 'half' : undefined,\n });\n\n try {\n // Support 103 Early Hints when available.\n const earlyHintsSender = (typeof res.writeEarlyHints === 'function')\n ? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }\n : undefined;\n\n const webResponse = earlyHintsSender && runWithEarlyHintsSender\n ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))\n : await handler(webRequest);\n\n // Write the response back to the Node response.\n res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));\n\n if (webResponse.body) {\n const reader = webResponse.body.getReader();\n const pump = async () => {\n while (true) {\n const { done, value } = await reader.read();\n if (done) { res.end(); return; }\n res.write(value);\n }\n };\n await pump();\n } else {\n res.end();\n }\n } catch (err) {\n console.error('[timber preview] Request error:', err);\n if (!res.headersSent) {\n res.writeHead(500, { 'Content-Type': 'text/plain' });\n }\n res.end('Internal Server Error');\n }\n});\n\nserver.listen(port, host, () => {\n console.log();\n console.log(' ⚡ timber preview server running at:');\n console.log();\n console.log(\\` ➜ http://\\${host}:\\${port}\\`);\n console.log();\n});\n`;\n}\n\n/** Command descriptor for Nitro preview — testable without spawning. */\nexport interface NitroPreviewCommand {\n command: string;\n args: string[];\n cwd: string;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroPreviewCommand(\n buildDir: string,\n preset: NitroPreset\n): NitroPreviewCommand | null {\n if (!LOCALLY_PREVIEWABLE.has(preset)) return null;\n\n const nitroDir = join(buildDir, 'nitro');\n const entryPath = join(nitroDir, 'entry.ts');\n\n const command = preset === 'bun' ? 'bun' : 'node';\n return {\n command,\n args: [entryPath],\n cwd: nitroDir,\n };\n}\n\n/**\n * Run the Nitro production build using the programmatic API.\n * Uses dynamic import so nitro is only loaded at build time.\n * Externalizes the timber RSC/SSR output — those files are pre-built\n * by timber and have internal references that nitro's bundler can't follow.\n */\nasync function runNitroBuild(\n nitroDir: string,\n preset: NitroPreset,\n userConfig?: Record<string, unknown>\n): Promise<void> {\n const presetConfig = PRESET_CONFIGS[preset];\n const { createNitro, build: nitroBuild, prepare, copyPublicAssets } = await import('nitro');\n\n const nitro = await createNitro({\n rootDir: nitroDir,\n preset: presetConfig.nitroPreset,\n // Use renderer.entry so Nitro wraps our handler with its server runtime\n // (HTTP server, static file serving, graceful shutdown, etc.).\n // Using `entry` directly would bypass the Nitro server runtime.\n renderer: { entry: join(nitroDir, 'entry.ts') },\n output: { dir: join(nitroDir, presetConfig.outputDir) },\n routeRules: {\n '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },\n },\n // Don't bundle the timber RSC/SSR build output — it has its own\n // internal file references that nitro's bundler can't follow.\n // Mark them as external so rollup leaves the imports as-is.\n rollupConfig: {\n external: [/\\.\\.\\/rsc\\//, /\\.\\/_timber-manifest-init/],\n },\n ...presetConfig.extraConfig,\n ...userConfig,\n });\n\n await prepare(nitro);\n await copyPublicAssets(nitro);\n await nitroBuild(nitro);\n await nitro.close();\n}\n\n/** Spawn a Nitro preview process and pipe stdio. */\nfunction spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const child = execFile(command, args, { cwd }, (err) => {\n if (err) reject(err);\n else resolve();\n });\n child.stdout?.pipe(process.stdout);\n child.stderr?.pipe(process.stderr);\n });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Get the preset configuration for a given preset name.\n * @internal Exported for testing.\n */\nexport function getPresetConfig(preset: NitroPreset): PresetConfig {\n return PRESET_CONFIGS[preset];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"nitro.js","names":[],"sources":["../../src/adapters/compress-module.ts","../../src/adapters/nitro.ts"],"sourcesContent":["// Generated compression module template for self-hosted deployments.\n//\n// This file generates a standalone ESM module (_compress.mjs) that is\n// written to the build output during adapter buildOutput(). It's imported\n// by the Nitro entry and preview server at runtime.\n//\n// Uses CompressionStream (Web API) for gzip and node:zlib for brotli.\n// Cloudflare Workers don't need this — the edge auto-compresses.\n//\n// See design/25-production-deployments.md.\n\n/**\n * Generate a standalone ESM module that exports compressResponse().\n *\n * Written to `_compress.mjs` during buildOutput. Imported by the Nitro entry\n * and preview server.\n *\n * @internal Exported for testing.\n */\nexport function generateCompressModule(): string {\n return `// Generated by @timber-js/app — response compression for self-hosted deployments.\n// Do not edit — this file is regenerated on each build.\n// Uses CompressionStream (Web API) for gzip, node:zlib for brotli.\n\nimport { createBrotliCompress, constants as zlibConstants } from 'node:zlib';\nimport { Readable } from 'node:stream';\n\nconst COMPRESSIBLE_TYPES = new Set([\n 'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',\n 'text/x-component', 'application/json', 'application/javascript',\n 'application/xml', 'application/xhtml+xml', 'application/rss+xml',\n 'application/atom+xml', 'image/svg+xml',\n]);\n\nconst NO_COMPRESS_STATUSES = new Set([204, 304]);\n\nfunction negotiateEncoding(acceptEncoding) {\n if (!acceptEncoding) return null;\n const tokens = acceptEncoding.split(',').map(s => s.split(';')[0].trim().toLowerCase());\n if (tokens.includes('br')) return 'br';\n if (tokens.includes('gzip')) return 'gzip';\n return null;\n}\n\nfunction shouldCompress(response) {\n if (!response.body) return false;\n if (NO_COMPRESS_STATUSES.has(response.status)) return false;\n if (response.headers.has('Content-Encoding')) return false;\n const contentType = response.headers.get('Content-Type');\n if (!contentType) return false;\n const mimeType = contentType.split(';')[0].trim().toLowerCase();\n if (mimeType === 'text/event-stream') return false;\n return COMPRESSIBLE_TYPES.has(mimeType);\n}\n\nfunction compressWithGzip(body) {\n return body.pipeThrough(new CompressionStream('gzip'));\n}\n\nfunction compressWithBrotli(body) {\n const brotli = createBrotliCompress({\n params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },\n });\n const reader = body.getReader();\n const pump = async () => {\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) { brotli.end(); return; }\n if (!brotli.write(value)) {\n await new Promise(resolve => brotli.once('drain', resolve));\n }\n }\n } catch (err) {\n brotli.destroy(err instanceof Error ? err : new Error(String(err)));\n }\n };\n pump();\n return Readable.toWeb(brotli);\n}\n\nexport function compressResponse(request, response) {\n if (!shouldCompress(response)) return response;\n const acceptEncoding = request.headers.get('Accept-Encoding') || '';\n const encoding = negotiateEncoding(acceptEncoding);\n if (!encoding) return response;\n const compressedBody = encoding === 'br'\n ? compressWithBrotli(response.body)\n : compressWithGzip(response.body);\n const headers = new Headers(response.headers);\n headers.set('Content-Encoding', encoding);\n headers.delete('Content-Length');\n const existingVary = headers.get('Vary');\n if (existingVary) {\n if (!existingVary.toLowerCase().includes('accept-encoding')) {\n headers.set('Vary', existingVary + ', Accept-Encoding');\n }\n } else {\n headers.set('Vary', 'Accept-Encoding');\n }\n return new Response(compressedBody, {\n status: response.status,\n statusText: response.statusText,\n headers,\n });\n}\n`;\n}\n","// Nitro adapter — multi-platform deployment\n//\n// Covers everything except Cloudflare Workers: Node.js, Bun, Vercel,\n// Netlify, AWS Lambda, Deno Deploy, Azure Functions. Nitro handles\n// compression, graceful shutdown, static file serving, and platform quirks.\n// See design/11-platform.md and design/25-production-deployments.md.\n\nimport { writeFile, mkdir, cp } from 'node:fs/promises';\nimport { execFile } from 'node:child_process';\nimport { join, relative } from 'node:path';\nimport type { TimberPlatformAdapter, TimberConfig } from './types';\nimport { generateCompressModule } from './compress-module.js';\n// Inlined from server/asset-headers.ts — adapters are loaded by Node at\n// Vite startup time, before Vite's module resolver is available, so cross-\n// directory .ts imports don't resolve.\nconst IMMUTABLE_CACHE = 'public, max-age=31536000, immutable';\nconst STATIC_CACHE = 'public, max-age=3600, must-revalidate';\n\nfunction generateHeadersFile(): string {\n return `# Auto-generated by @timber-js/app — static asset cache headers.\n# See design/25-production-deployments.md §\"CDN / Edge Cache\"\n\n/assets/*\n Cache-Control: ${IMMUTABLE_CACHE}\n\n/*\n Cache-Control: ${STATIC_CACHE}\n`;\n}\n\n// ─── Presets ─────────────────────────────────────────────────────────────────\n\n/**\n * Supported Nitro deployment presets.\n *\n * Each preset maps to a Nitro deployment target. The adapter generates\n * the appropriate configuration and entry point for the selected platform.\n */\nexport type NitroPreset =\n | 'vercel'\n | 'vercel-edge'\n | 'netlify'\n | 'netlify-edge'\n | 'aws-lambda'\n | 'deno-deploy'\n | 'azure-functions'\n | 'node-server'\n | 'bun';\n\n/** Preset-specific Nitro configuration. */\ninterface PresetConfig {\n /** Nitro preset name passed to the Nitro build. */\n nitroPreset: string;\n /** Output directory name within the build dir. */\n outputDir: string;\n /** Whether the runtime supports waitUntil. */\n supportsWaitUntil: boolean;\n /** Whether the runtime supports application-level 103 Early Hints. */\n supportsEarlyHints: boolean;\n /** Value for TIMBER_RUNTIME env var. See design/25-production-deployments.md. */\n runtimeName: string;\n /** Additional nitro.config fields for this preset. */\n extraConfig?: Record<string, unknown>;\n}\n\nconst PRESET_CONFIGS: Record<NitroPreset, PresetConfig> = {\n 'vercel': {\n nitroPreset: 'vercel',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel',\n extraConfig: { vercel: { functions: { maxDuration: 30 } } },\n },\n 'vercel-edge': {\n nitroPreset: 'vercel-edge',\n outputDir: '.vercel/output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'vercel-edge',\n },\n 'netlify': {\n nitroPreset: 'netlify',\n outputDir: '.netlify/functions-internal',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'netlify',\n },\n 'netlify-edge': {\n nitroPreset: 'netlify-edge',\n outputDir: '.netlify/edge-functions',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'netlify-edge',\n },\n 'aws-lambda': {\n nitroPreset: 'aws-lambda',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'aws-lambda',\n },\n 'deno-deploy': {\n nitroPreset: 'deno-deploy',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: false,\n runtimeName: 'deno-deploy',\n },\n 'azure-functions': {\n nitroPreset: 'azure-functions',\n outputDir: '.output',\n supportsWaitUntil: false,\n supportsEarlyHints: false,\n runtimeName: 'azure-functions',\n },\n 'node-server': {\n nitroPreset: 'node-server',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'node-server',\n },\n 'bun': {\n nitroPreset: 'bun',\n outputDir: '.output',\n supportsWaitUntil: true,\n supportsEarlyHints: true,\n runtimeName: 'bun',\n },\n};\n\n// ─── Options ─────────────────────────────────────────────────────────────────\n\n/** Options for the Nitro adapter. */\nexport interface NitroAdapterOptions {\n /**\n * Deployment preset. Determines the target platform.\n * @default 'node-server'\n */\n preset?: NitroPreset;\n\n /**\n * Additional Nitro configuration to merge into the generated config.\n * Overrides default values for the selected preset.\n */\n nitroConfig?: Record<string, unknown>;\n}\n\n// ─── Adapter ─────────────────────────────────────────────────────────────────\n\n/**\n * Create a Nitro-based adapter for multi-platform deployment.\n *\n * Nitro abstracts deployment targets — the same timber.js app can deploy\n * to Vercel, Netlify, AWS, Deno Deploy, or Azure by changing the preset.\n *\n * @example\n * ```ts\n * import { nitro } from '@timber-js/app/adapters/nitro'\n *\n * export default {\n * output: 'server',\n * adapter: nitro({ preset: 'vercel' }),\n * }\n * ```\n */\nexport function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter {\n const preset = options.preset ?? 'node-server';\n const presetConfig = PRESET_CONFIGS[preset];\n const pendingPromises: Promise<unknown>[] = [];\n\n return {\n name: `nitro-${preset}`,\n\n async buildOutput(config: TimberConfig, buildDir: string) {\n const outDir = join(buildDir, 'nitro');\n await mkdir(outDir, { recursive: true });\n\n // Copy client assets to public directory.\n // When client JavaScript is disabled, skip .js files — only CSS,\n // fonts, images, and other static assets are needed.\n const clientDir = join(buildDir, 'client');\n const publicDir = join(outDir, 'public');\n await mkdir(publicDir, { recursive: true });\n await cp(clientDir, publicDir, {\n recursive: true,\n filter: config.clientJavascriptDisabled ? (src: string) => !src.endsWith('.js') : undefined,\n }).catch(() => {\n // Client dir may not exist when client JavaScript is disabled\n });\n\n // Write _headers file for platforms that support it (Netlify, etc.).\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n await writeFile(join(publicDir, '_headers'), generateHeadersFile());\n\n // Write the build manifest init module (if manifest data was produced).\n if (config.manifestInit) {\n await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);\n }\n\n // Write the compression helper module for runtime use.\n // See design/25-production-deployments.md — self-hosted deployments\n // need application-level compression (Cloudflare handles it at the edge).\n await writeFile(join(outDir, '_compress.mjs'), generateCompressModule());\n\n // Generate the Nitro entry point\n const hasManifestInit = !!config.manifestInit;\n const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);\n await writeFile(join(outDir, 'entry.ts'), entry);\n\n // Generate the Nitro config with static asset cache rules\n const nitroConfig = generateNitroConfig(preset, options.nitroConfig);\n await writeFile(join(outDir, 'nitro.config.ts'), nitroConfig);\n },\n\n // Only presets that produce a locally-runnable server get preview().\n // Serverless presets (vercel, netlify, aws-lambda, etc.) have no\n // local runtime — Vite's built-in preview is the fallback.\n preview: LOCALLY_PREVIEWABLE.has(preset)\n ? async (_config: TimberConfig, buildDir: string) => {\n // Generate a standalone preview server that uses Node's built-in\n // HTTP server. The Nitro entry.ts can't be run directly because\n // it imports h3 (a Nitro dependency not available at runtime).\n const previewScript = generatePreviewScript(buildDir, preset);\n const scriptPath = join(buildDir, 'nitro', '_preview-server.mjs');\n await writeFile(scriptPath, previewScript);\n\n const command = preset === 'bun' ? 'bun' : 'node';\n await spawnNitroPreview(command, [scriptPath], join(buildDir, 'nitro'));\n }\n : undefined,\n\n waitUntil: presetConfig.supportsWaitUntil\n ? (promise: Promise<unknown>) => {\n const tracked = promise.catch((err) => {\n console.error('[timber] waitUntil promise rejected:', err);\n });\n pendingPromises.push(tracked);\n }\n : undefined,\n };\n}\n\n// ─── Entry Generation ────────────────────────────────────────────────────────\n\n/** @internal Exported for testing. */\nexport function generateNitroEntry(\n buildDir: string,\n outDir: string,\n preset: NitroPreset,\n hasManifestInit = false\n): string {\n // The RSC entry is the main request handler — it exports the fetch handler as default.\n // The Vite RSC plugin outputs it to rsc/index.js.\n let serverEntryRelative = relative(outDir, join(buildDir, 'rsc', 'index.js'));\n // Ensure the import path starts with ./ for ESM compatibility\n if (!serverEntryRelative.startsWith('.')) {\n serverEntryRelative = './' + serverEntryRelative;\n }\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;\n\n // Build manifest init must be imported before the handler so that\n // globalThis.__TIMBER_BUILD_MANIFEST__ is set when the virtual module evaluates.\n const manifestImport = hasManifestInit ? \"import './_timber-manifest-init.js'\\n\" : '';\n\n // On node-server and bun, wrap the handler with ALS so the pipeline\n // can send 103 Early Hints via res.writeEarlyHints(). Other presets\n // either don't support 103 or handle it at the CDN level.\n const handlerCall = earlyHints\n ? ` const nodeRes = event.node?.res\n const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')\n ? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }\n : undefined\n\n const webResponse = earlyHintsSender\n ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))\n : await handler(webRequest)`\n : ` const webResponse = await handler(webRequest)`;\n\n return `// Generated by @timber-js/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\n${manifestImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'\nimport handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'\nimport { compressResponse } from './_compress.mjs'\n\n// Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.\n// See design/25-production-deployments.md §\"TIMBER_RUNTIME\".\nprocess.env.TIMBER_RUNTIME = '${runtimeName}'\n\nexport default defineEventHandler(async (event) => {\n const webRequest = toWebRequest(event)\n${handlerCall}\n const finalResponse = compressResponse(webRequest, webResponse)\n return sendWebResponse(event, finalResponse)\n})\n`;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroConfig(\n preset: NitroPreset,\n userConfig?: Record<string, unknown>\n): string {\n const presetConfig = PRESET_CONFIGS[preset];\n\n const config: Record<string, unknown> = {\n preset: presetConfig.nitroPreset,\n output: { dir: presetConfig.outputDir },\n // Static asset cache headers — hashed assets are immutable, others get 1h.\n // See design/25-production-deployments.md §\"CDN / Edge Cache\"\n routeRules: {\n '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },\n },\n ...presetConfig.extraConfig,\n ...userConfig,\n };\n\n const configJson = JSON.stringify(config, null, 2);\n\n return `// Generated by @timber-js/app/adapters/nitro\n// Do not edit — this file is regenerated on each build.\n\nimport { defineNitroConfig } from 'nitropack/config'\n\nexport default defineNitroConfig(${configJson})\n`;\n}\n\n// ─── Preview ─────────────────────────────────────────────────────────────────\n\n/** Presets that produce a locally-runnable server entry. */\nconst LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);\n\n/**\n * Generate a standalone preview server script that uses Node's built-in\n * HTTP server. This bypasses Nitro entirely — the Nitro entry.ts imports\n * h3 which isn't available outside a Nitro build. For local preview we\n * just need to serve static files and route requests to the RSC handler.\n *\n * @internal Exported for testing.\n */\nexport function generatePreviewScript(buildDir: string, preset: NitroPreset): string {\n const rscEntryRelative = relative(join(buildDir, 'nitro'), join(buildDir, 'rsc', 'index.js'));\n const rscEntry = rscEntryRelative.startsWith('.') ? rscEntryRelative : './' + rscEntryRelative;\n const publicDir = './public';\n const manifestInitPath = './_timber-manifest-init.js';\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n\n return `// Generated by @timber-js/app — standalone preview server.\n// Uses Node's built-in HTTP server to serve static assets and route\n// dynamic requests through the RSC handler. No Nitro/h3 dependency.\n\nimport { createServer } from 'node:http';\nimport { readFile, stat } from 'node:fs/promises';\nimport { join, extname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { existsSync } from 'node:fs';\n\n// Set runtime before importing the handler.\nprocess.env.TIMBER_RUNTIME = '${runtimeName}';\n\n// Load the build manifest if it exists.\nconst __dirname = fileURLToPath(new URL('.', import.meta.url));\nconst manifestPath = join(__dirname, '${manifestInitPath}');\nif (existsSync(manifestPath)) {\n await import('${manifestInitPath}');\n}\n\n// Import the RSC handler (default export is the fetch-like handler).\nconst { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');\n\n// Import compression helper for self-hosted response compression.\nconst { compressResponse } = await import('./_compress.mjs');\n\nconst MIME_TYPES = {\n '.html': 'text/html',\n '.js': 'application/javascript',\n '.mjs': 'application/javascript',\n '.css': 'text/css',\n '.json': 'application/json',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.ico': 'image/x-icon',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.ttf': 'font/ttf',\n '.otf': 'font/otf',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.webm': 'video/webm',\n '.mp4': 'video/mp4',\n '.txt': 'text/plain',\n '.xml': 'application/xml',\n '.wasm': 'application/wasm',\n};\n\nconst publicDir = join(__dirname, '${publicDir}');\nconst port = parseInt(process.env.PORT || '3000', 10);\n\nconst server = createServer(async (req, res) => {\n const url = new URL(req.url || '/', \\`http://localhost:\\${port}\\`);\n\n // Try serving static files from the public directory first.\n const filePath = join(publicDir, url.pathname);\n // Prevent path traversal.\n if (filePath.startsWith(publicDir)) {\n try {\n const fileStat = await stat(filePath);\n if (fileStat.isFile()) {\n const ext = extname(filePath);\n const contentType = MIME_TYPES[ext] || 'application/octet-stream';\n const body = await readFile(filePath);\n // Hashed assets get immutable cache, others get short cache.\n const cacheControl = url.pathname.startsWith('/assets/')\n ? 'public, max-age=31536000, immutable'\n : 'public, max-age=3600, must-revalidate';\n res.writeHead(200, {\n 'Content-Type': contentType,\n 'Content-Length': body.length,\n 'Cache-Control': cacheControl,\n });\n res.end(body);\n return;\n }\n } catch {\n // File not found — fall through to the RSC handler.\n }\n }\n\n // Convert Node request to Web Request.\n const headers = new Headers();\n for (const [key, value] of Object.entries(req.headers)) {\n if (value) {\n if (Array.isArray(value)) {\n for (const v of value) headers.append(key, v);\n } else {\n headers.set(key, value);\n }\n }\n }\n\n let body = undefined;\n if (req.method !== 'GET' && req.method !== 'HEAD') {\n body = await new Promise((resolve) => {\n const chunks = [];\n req.on('data', (chunk) => chunks.push(chunk));\n req.on('end', () => resolve(Buffer.concat(chunks)));\n });\n }\n\n const webRequest = new Request(url.href, {\n method: req.method,\n headers,\n body,\n duplex: body ? 'half' : undefined,\n });\n\n try {\n // Support 103 Early Hints when available.\n const earlyHintsSender = (typeof res.writeEarlyHints === 'function')\n ? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }\n : undefined;\n\n const rawResponse = earlyHintsSender && runWithEarlyHintsSender\n ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))\n : await handler(webRequest);\n\n // Compress the response for self-hosted deployments.\n const webResponse = compressResponse(webRequest, rawResponse);\n\n // Write the response back to the Node response.\n res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));\n\n if (webResponse.body) {\n const reader = webResponse.body.getReader();\n const pump = async () => {\n while (true) {\n const { done, value } = await reader.read();\n if (done) { res.end(); return; }\n res.write(value);\n }\n };\n await pump();\n } else {\n res.end();\n }\n } catch (err) {\n console.error('[timber preview] Request error:', err);\n if (!res.headersSent) {\n res.writeHead(500, { 'Content-Type': 'text/plain' });\n }\n res.end('Internal Server Error');\n }\n});\n\nserver.listen(port, () => {\n console.log();\n console.log(' ⚡ timber preview server running at:');\n console.log();\n console.log(\\` ➜ http://localhost:\\${port}\\`);\n console.log();\n});\n`;\n}\n\n/** Command descriptor for Nitro preview — testable without spawning. */\nexport interface NitroPreviewCommand {\n command: string;\n args: string[];\n cwd: string;\n}\n\n/** @internal Exported for testing. */\nexport function generateNitroPreviewCommand(\n buildDir: string,\n preset: NitroPreset\n): NitroPreviewCommand | null {\n if (!LOCALLY_PREVIEWABLE.has(preset)) return null;\n\n const nitroDir = join(buildDir, 'nitro');\n const entryPath = join(nitroDir, 'entry.ts');\n\n const command = preset === 'bun' ? 'bun' : 'node';\n return {\n command,\n args: [entryPath],\n cwd: nitroDir,\n };\n}\n\n/** Spawn a Nitro preview process and pipe stdio. */\nfunction spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const child = execFile(command, args, { cwd }, (err) => {\n if (err) reject(err);\n else resolve();\n });\n child.stdout?.pipe(process.stdout);\n child.stderr?.pipe(process.stderr);\n });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Get the preset configuration for a given preset name.\n * @internal Exported for testing.\n */\nexport function getPresetConfig(preset: NitroPreset): PresetConfig {\n return PRESET_CONFIGS[preset];\n}\n"],"mappings":";;;;;;;;;;;;AAmBA,SAAgB,yBAAiC;AAC/C,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACLT,IAAM,kBAAkB;AACxB,IAAM,eAAe;AAErB,SAAS,sBAA8B;AACrC,QAAO;;;;mBAIU,gBAAgB;;;mBAGhB,aAAa;;;AAuChC,IAAM,iBAAoD;CACxD,UAAU;EACR,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACb,aAAa,EAAE,QAAQ,EAAE,WAAW,EAAE,aAAa,IAAI,EAAE,EAAE;EAC5D;CACD,eAAe;EACb,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,WAAW;EACT,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,gBAAgB;EACd,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,cAAc;EACZ,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,eAAe;EACb,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,mBAAmB;EACjB,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,eAAe;EACb,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACD,OAAO;EACL,aAAa;EACb,WAAW;EACX,mBAAmB;EACnB,oBAAoB;EACpB,aAAa;EACd;CACF;;;;;;;;;;;;;;;;;AAqCD,SAAgB,MAAM,UAA+B,EAAE,EAAyB;CAC9E,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,eAAe,eAAe;CACpC,MAAM,kBAAsC,EAAE;AAE9C,QAAO;EACL,MAAM,SAAS;EAEf,MAAM,YAAY,QAAsB,UAAkB;GACxD,MAAM,SAAS,KAAK,UAAU,QAAQ;AACtC,SAAM,MAAM,QAAQ,EAAE,WAAW,MAAM,CAAC;GAKxC,MAAM,YAAY,KAAK,UAAU,SAAS;GAC1C,MAAM,YAAY,KAAK,QAAQ,SAAS;AACxC,SAAM,MAAM,WAAW,EAAE,WAAW,MAAM,CAAC;AAC3C,SAAM,GAAG,WAAW,WAAW;IAC7B,WAAW;IACX,QAAQ,OAAO,4BAA4B,QAAgB,CAAC,IAAI,SAAS,MAAM,GAAG,KAAA;IACnF,CAAC,CAAC,YAAY,GAEb;AAIF,SAAM,UAAU,KAAK,WAAW,WAAW,EAAE,qBAAqB,CAAC;AAGnE,OAAI,OAAO,aACT,OAAM,UAAU,KAAK,QAAQ,2BAA2B,EAAE,OAAO,aAAa;AAMhF,SAAM,UAAU,KAAK,QAAQ,gBAAgB,EAAE,wBAAwB,CAAC;GAIxE,MAAM,QAAQ,mBAAmB,UAAU,QAAQ,QAD3B,CAAC,CAAC,OAAO,aAC0C;AAC3E,SAAM,UAAU,KAAK,QAAQ,WAAW,EAAE,MAAM;GAGhD,MAAM,cAAc,oBAAoB,QAAQ,QAAQ,YAAY;AACpE,SAAM,UAAU,KAAK,QAAQ,kBAAkB,EAAE,YAAY;;EAM/D,SAAS,oBAAoB,IAAI,OAAO,GACpC,OAAO,SAAuB,aAAqB;GAIjD,MAAM,gBAAgB,sBAAsB,UAAU,OAAO;GAC7D,MAAM,aAAa,KAAK,UAAU,SAAS,sBAAsB;AACjE,SAAM,UAAU,YAAY,cAAc;AAG1C,SAAM,kBADU,WAAW,QAAQ,QAAQ,QACV,CAAC,WAAW,EAAE,KAAK,UAAU,QAAQ,CAAC;MAEzE,KAAA;EAEJ,WAAW,aAAa,qBACnB,YAA8B;GAC7B,MAAM,UAAU,QAAQ,OAAO,QAAQ;AACrC,YAAQ,MAAM,wCAAwC,IAAI;KAC1D;AACF,mBAAgB,KAAK,QAAQ;MAE/B,KAAA;EACL;;;AAMH,SAAgB,mBACd,UACA,QACA,QACA,kBAAkB,OACV;CAGR,IAAI,sBAAsB,SAAS,QAAQ,KAAK,UAAU,OAAO,WAAW,CAAC;AAE7E,KAAI,CAAC,oBAAoB,WAAW,IAAI,CACtC,uBAAsB,OAAO;CAE/B,MAAM,cAAc,eAAe,QAAQ;CAC3C,MAAM,aAAa,eAAe,QAAQ;AAoB1C,QAAO;;;EAhBgB,kBAAkB,0CAA0C,GAmBpE;oDACmC,oBAAoB;;;;;gCAKxC,YAAY;;;;EApBtB,aAChB;;;;;;;mCAQA,kDAeQ;;;;;;;AAQd,SAAgB,oBACd,QACA,YACQ;CACR,MAAM,eAAe,eAAe;CAEpC,MAAM,SAAkC;EACtC,QAAQ,aAAa;EACrB,QAAQ,EAAE,KAAK,aAAa,WAAW;EAGvC,YAAY,EACV,cAAc,EAAE,SAAS,EAAE,iBAAiB,iBAAiB,EAAE,EAChE;EACD,GAAG,aAAa;EAChB,GAAG;EACJ;AAID,QAAO;;;;;mCAFY,KAAK,UAAU,QAAQ,MAAM,EAAE,CAON;;;;AAO9C,IAAM,sBAAsB,IAAI,IAAiB,CAAC,eAAe,MAAM,CAAC;;;;;;;;;AAUxE,SAAgB,sBAAsB,UAAkB,QAA6B;CACnF,MAAM,mBAAmB,SAAS,KAAK,UAAU,QAAQ,EAAE,KAAK,UAAU,OAAO,WAAW,CAAC;CAC7F,MAAM,WAAW,iBAAiB,WAAW,IAAI,GAAG,mBAAmB,OAAO;CAC9E,MAAM,YAAY;CAClB,MAAM,mBAAmB;AAGzB,QAAO;;;;;;;;;;;gCAFa,eAAe,QAAQ,YAaD;;;;wCAIJ,iBAAiB;;kBAEvC,iBAAiB;;;;sEAImC,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qCA8B1C,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqH/C,SAAgB,4BACd,UACA,QAC4B;AAC5B,KAAI,CAAC,oBAAoB,IAAI,OAAO,CAAE,QAAO;CAE7C,MAAM,WAAW,KAAK,UAAU,QAAQ;CACxC,MAAM,YAAY,KAAK,UAAU,WAAW;AAG5C,QAAO;EACL,SAFc,WAAW,QAAQ,QAAQ;EAGzC,MAAM,CAAC,UAAU;EACjB,KAAK;EACN;;;AAIH,SAAS,kBAAkB,SAAiB,MAAgB,KAA4B;AACtF,QAAO,IAAI,SAAe,SAAS,WAAW;EAC5C,MAAM,QAAQ,SAAS,SAAS,MAAM,EAAE,KAAK,GAAG,QAAQ;AACtD,OAAI,IAAK,QAAO,IAAI;OACf,UAAS;IACd;AACF,QAAM,QAAQ,KAAK,QAAQ,OAAO;AAClC,QAAM,QAAQ,KAAK,QAAQ,OAAO;GAClC;;;;;;AASJ,SAAgB,gBAAgB,QAAmC;AACjE,QAAO,eAAe"}
|
package/dist/index.js
CHANGED
|
@@ -5,9 +5,10 @@ import { dirname, extname, join, resolve } from "node:path";
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import react from "@vitejs/plugin-react";
|
|
8
|
+
import { constants, createBrotliCompress, gzipSync } from "node:zlib";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
8
10
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
9
11
|
import { createHash } from "node:crypto";
|
|
10
|
-
import { gzipSync } from "node:zlib";
|
|
11
12
|
import { performance as performance$1 } from "node:perf_hooks";
|
|
12
13
|
//#region \0rolldown/runtime.js
|
|
13
14
|
var __create = Object.create;
|
|
@@ -11519,6 +11520,129 @@ function sendErrorToOverlay(server, error, phase, projectRoot) {
|
|
|
11519
11520
|
} catch {}
|
|
11520
11521
|
}
|
|
11521
11522
|
//#endregion
|
|
11523
|
+
//#region src/server/compress.ts
|
|
11524
|
+
/**
|
|
11525
|
+
* MIME types that benefit from compression.
|
|
11526
|
+
* text/* is handled via prefix matching; these are the specific
|
|
11527
|
+
* application/* and image/* types that are compressible.
|
|
11528
|
+
*/
|
|
11529
|
+
var COMPRESSIBLE_TYPES = new Set([
|
|
11530
|
+
"text/html",
|
|
11531
|
+
"text/css",
|
|
11532
|
+
"text/plain",
|
|
11533
|
+
"text/xml",
|
|
11534
|
+
"text/javascript",
|
|
11535
|
+
"text/x-component",
|
|
11536
|
+
"application/json",
|
|
11537
|
+
"application/javascript",
|
|
11538
|
+
"application/xml",
|
|
11539
|
+
"application/xhtml+xml",
|
|
11540
|
+
"application/rss+xml",
|
|
11541
|
+
"application/atom+xml",
|
|
11542
|
+
"image/svg+xml"
|
|
11543
|
+
]);
|
|
11544
|
+
/**
|
|
11545
|
+
* Status codes that should never be compressed (no body or special semantics).
|
|
11546
|
+
*/
|
|
11547
|
+
var NO_COMPRESS_STATUSES = new Set([204, 304]);
|
|
11548
|
+
/**
|
|
11549
|
+
* Parse Accept-Encoding and return the best supported encoding.
|
|
11550
|
+
* Prefers brotli (br) over gzip. Returns null if no supported encoding.
|
|
11551
|
+
*
|
|
11552
|
+
* We always prefer brotli regardless of quality values because:
|
|
11553
|
+
* 1. Brotli achieves better compression ratios than gzip
|
|
11554
|
+
* 2. All modern browsers that send br in Accept-Encoding support it well
|
|
11555
|
+
* 3. Respecting q-values for br vs gzip adds complexity with no real benefit
|
|
11556
|
+
*/
|
|
11557
|
+
function negotiateEncoding(acceptEncoding) {
|
|
11558
|
+
if (!acceptEncoding) return null;
|
|
11559
|
+
const tokens = acceptEncoding.split(",").map((s) => s.split(";")[0].trim().toLowerCase());
|
|
11560
|
+
if (tokens.includes("br")) return "br";
|
|
11561
|
+
if (tokens.includes("gzip")) return "gzip";
|
|
11562
|
+
return null;
|
|
11563
|
+
}
|
|
11564
|
+
/**
|
|
11565
|
+
* Determine if a response should be compressed.
|
|
11566
|
+
*
|
|
11567
|
+
* Returns false for:
|
|
11568
|
+
* - Responses without a body (204, 304, null body)
|
|
11569
|
+
* - Already-encoded responses (Content-Encoding set)
|
|
11570
|
+
* - Non-compressible content types (images, binary)
|
|
11571
|
+
* - SSE streams (text/event-stream — must not be buffered)
|
|
11572
|
+
*/
|
|
11573
|
+
function shouldCompress(response) {
|
|
11574
|
+
if (!response.body) return false;
|
|
11575
|
+
if (NO_COMPRESS_STATUSES.has(response.status)) return false;
|
|
11576
|
+
if (response.headers.has("Content-Encoding")) return false;
|
|
11577
|
+
const contentType = response.headers.get("Content-Type");
|
|
11578
|
+
if (!contentType) return false;
|
|
11579
|
+
const mimeType = contentType.split(";")[0].trim().toLowerCase();
|
|
11580
|
+
if (mimeType === "text/event-stream") return false;
|
|
11581
|
+
return COMPRESSIBLE_TYPES.has(mimeType);
|
|
11582
|
+
}
|
|
11583
|
+
/**
|
|
11584
|
+
* Compress a Web Response if the client supports it and the content is compressible.
|
|
11585
|
+
*
|
|
11586
|
+
* Returns the original response unchanged if compression is not applicable.
|
|
11587
|
+
* Returns a new Response with the compressed body, Content-Encoding, and Vary headers.
|
|
11588
|
+
*
|
|
11589
|
+
* The body is piped through a compression stream — no buffering of the full response.
|
|
11590
|
+
* This preserves streaming behavior for HTML shell + deferred Suspense chunks.
|
|
11591
|
+
*/
|
|
11592
|
+
function compressResponse(request, response) {
|
|
11593
|
+
if (!shouldCompress(response)) return response;
|
|
11594
|
+
const encoding = negotiateEncoding(request.headers.get("Accept-Encoding") ?? "");
|
|
11595
|
+
if (!encoding) return response;
|
|
11596
|
+
const compressedBody = encoding === "br" ? compressWithBrotli(response.body) : compressWithGzip(response.body);
|
|
11597
|
+
const headers = new Headers(response.headers);
|
|
11598
|
+
headers.set("Content-Encoding", encoding);
|
|
11599
|
+
headers.delete("Content-Length");
|
|
11600
|
+
const existingVary = headers.get("Vary");
|
|
11601
|
+
if (existingVary) {
|
|
11602
|
+
if (!existingVary.toLowerCase().includes("accept-encoding")) headers.set("Vary", `${existingVary}, Accept-Encoding`);
|
|
11603
|
+
} else headers.set("Vary", "Accept-Encoding");
|
|
11604
|
+
return new Response(compressedBody, {
|
|
11605
|
+
status: response.status,
|
|
11606
|
+
statusText: response.statusText,
|
|
11607
|
+
headers
|
|
11608
|
+
});
|
|
11609
|
+
}
|
|
11610
|
+
/**
|
|
11611
|
+
* Compress a ReadableStream with gzip using the Web Platform CompressionStream API.
|
|
11612
|
+
* Available in Node 18+, Bun, and Deno — no npm dependency needed.
|
|
11613
|
+
*/
|
|
11614
|
+
function compressWithGzip(body) {
|
|
11615
|
+
const compressionStream = new CompressionStream("gzip");
|
|
11616
|
+
return body.pipeThrough(compressionStream);
|
|
11617
|
+
}
|
|
11618
|
+
/**
|
|
11619
|
+
* Compress a ReadableStream with brotli using node:zlib.
|
|
11620
|
+
*
|
|
11621
|
+
* CompressionStream doesn't support brotli — it only handles gzip and deflate.
|
|
11622
|
+
* We use node:zlib's createBrotliCompress() and bridge between Web streams
|
|
11623
|
+
* and Node streams.
|
|
11624
|
+
*/
|
|
11625
|
+
function compressWithBrotli(body) {
|
|
11626
|
+
const brotli = createBrotliCompress({ params: { [constants.BROTLI_PARAM_QUALITY]: 4 } });
|
|
11627
|
+
const reader = body.getReader();
|
|
11628
|
+
const pump = async () => {
|
|
11629
|
+
try {
|
|
11630
|
+
while (true) {
|
|
11631
|
+
const { done, value } = await reader.read();
|
|
11632
|
+
if (done) {
|
|
11633
|
+
brotli.end();
|
|
11634
|
+
return;
|
|
11635
|
+
}
|
|
11636
|
+
if (!brotli.write(value)) await new Promise((resolve) => brotli.once("drain", resolve));
|
|
11637
|
+
}
|
|
11638
|
+
} catch (err) {
|
|
11639
|
+
brotli.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
11640
|
+
}
|
|
11641
|
+
};
|
|
11642
|
+
pump();
|
|
11643
|
+
return Readable.toWeb(brotli);
|
|
11644
|
+
}
|
|
11645
|
+
//#endregion
|
|
11522
11646
|
//#region src/plugins/dev-server.ts
|
|
11523
11647
|
var RSC_ENTRY_ID = "virtual:timber-rsc-entry";
|
|
11524
11648
|
/**
|
|
@@ -11616,7 +11740,7 @@ function createTimberMiddleware(server, projectRoot) {
|
|
|
11616
11740
|
}
|
|
11617
11741
|
try {
|
|
11618
11742
|
const webRequest = toWebRequest(req);
|
|
11619
|
-
await sendWebResponse(res, await handler(webRequest));
|
|
11743
|
+
await sendWebResponse(res, compressResponse(webRequest, await handler(webRequest)));
|
|
11620
11744
|
} catch (error) {
|
|
11621
11745
|
if (error instanceof Error) sendErrorToOverlay(server, error, classifyErrorPhase(error, projectRoot), projectRoot);
|
|
11622
11746
|
else process.stderr.write(`\x1b[31m[timber] Dev server error:\x1b[0m ${String(error)}\n`);
|