@timber-js/app 0.1.38 → 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.
@@ -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"}
@@ -52,7 +52,7 @@ export interface NitroAdapterOptions {
52
52
  */
53
53
  export declare function nitro(options?: NitroAdapterOptions): TimberPlatformAdapter;
54
54
  /** @internal Exported for testing. */
55
- export declare function generateNitroEntry(buildDir: string, outDir: string, preset: NitroPreset): string;
55
+ export declare function generateNitroEntry(buildDir: string, outDir: string, preset: NitroPreset, hasManifestInit?: boolean): string;
56
56
  /** @internal Exported for testing. */
57
57
  export declare function generateNitroConfig(preset: NitroPreset, userConfig?: Record<string, unknown>): string;
58
58
  /**
@@ -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;AAqBnE;;;;;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,CAqF9E;AAID,sCAAsC;AACtC,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,WAAW,GAClB,MAAM,CAwCR;AAED,sCAAsC;AACtC,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,WAAW,EACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAyBR;AAOD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,MAAM,CAgKnF;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;AAyDD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,YAAY,CAEjE"}
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"}
@@ -1,6 +1,105 @@
1
1
  import { join, relative } from "node:path";
2
- import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
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,16 +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 cp(join(buildDir, "rsc"), join(outDir, "rsc"), { recursive: true });
119
- await cp(join(buildDir, "ssr"), join(outDir, "ssr"), { recursive: true }).catch(() => {});
120
- if (config.manifestInit) {
121
- const rscEntry = join(outDir, "rsc", "index.js");
122
- const rscContent = await readFile(rscEntry, "utf-8");
123
- await writeFile(rscEntry, `${config.manifestInit}\n${rscContent}`);
124
- }
125
- const entry = generateNitroEntry(buildDir, outDir, preset);
217
+ await writeFile(join(outDir, "_compress.mjs"), generateCompressModule());
218
+ const entry = generateNitroEntry(buildDir, outDir, preset, !!config.manifestInit);
126
219
  await writeFile(join(outDir, "entry.ts"), entry);
127
- await runNitroBuild(outDir, preset, options.nitroConfig);
220
+ const nitroConfig = generateNitroConfig(preset, options.nitroConfig);
221
+ await writeFile(join(outDir, "nitro.config.ts"), nitroConfig);
128
222
  },
129
223
  preview: LOCALLY_PREVIEWABLE.has(preset) ? async (_config, buildDir) => {
130
224
  const previewScript = generatePreviewScript(buildDir, preset);
@@ -141,21 +235,25 @@ function nitro(options = {}) {
141
235
  };
142
236
  }
143
237
  /** @internal Exported for testing. */
144
- function generateNitroEntry(buildDir, outDir, preset) {
238
+ function generateNitroEntry(buildDir, outDir, preset, hasManifestInit = false) {
239
+ let serverEntryRelative = relative(outDir, join(buildDir, "rsc", "index.js"));
240
+ if (!serverEntryRelative.startsWith(".")) serverEntryRelative = "./" + serverEntryRelative;
241
+ const runtimeName = PRESET_CONFIGS[preset].runtimeName;
242
+ const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;
145
243
  return `// Generated by @timber-js/app/adapters/nitro
146
244
  // Do not edit — this file is regenerated on each build.
147
245
 
148
- import { defineEventHandler } from 'nitro/h3'
149
- import handler, { runWithEarlyHintsSender } from './rsc/index.js'
246
+ ${hasManifestInit ? "import './_timber-manifest-init.js'\n" : ""}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'
247
+ import handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'
248
+ import { compressResponse } from './_compress.mjs'
150
249
 
151
250
  // Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
152
251
  // See design/25-production-deployments.md §"TIMBER_RUNTIME".
153
- process.env.TIMBER_RUNTIME = '${PRESET_CONFIGS[preset].runtimeName}'
252
+ process.env.TIMBER_RUNTIME = '${runtimeName}'
154
253
 
155
254
  export default defineEventHandler(async (event) => {
156
- // h3 v2: event.req is the Web Request
157
- const webRequest = event.req
158
- ${PRESET_CONFIGS[preset].supportsEarlyHints ? ` const nodeRes = event.node?.res
255
+ const webRequest = toWebRequest(event)
256
+ ${earlyHints ? ` const nodeRes = event.node?.res
159
257
  const earlyHintsSender = (typeof nodeRes?.writeEarlyHints === 'function')
160
258
  ? (links) => { try { nodeRes.writeEarlyHints({ link: links }) } catch {} }
161
259
  : undefined
@@ -163,7 +261,8 @@ ${PRESET_CONFIGS[preset].supportsEarlyHints ? ` const nodeRes = event.node?.res
163
261
  const webResponse = earlyHintsSender
164
262
  ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
165
263
  : await handler(webRequest)` : ` const webResponse = await handler(webRequest)`}
166
- return webResponse
264
+ const finalResponse = compressResponse(webRequest, webResponse)
265
+ return sendWebResponse(event, finalResponse)
167
266
  })
168
267
  `;
169
268
  }
@@ -172,7 +271,6 @@ function generateNitroConfig(preset, userConfig) {
172
271
  const presetConfig = PRESET_CONFIGS[preset];
173
272
  const config = {
174
273
  preset: presetConfig.nitroPreset,
175
- entry: "./entry.ts",
176
274
  output: { dir: presetConfig.outputDir },
177
275
  routeRules: { "/assets/**": { headers: { "Cache-Control": IMMUTABLE_CACHE } } },
178
276
  ...presetConfig.extraConfig,
@@ -181,7 +279,7 @@ function generateNitroConfig(preset, userConfig) {
181
279
  return `// Generated by @timber-js/app/adapters/nitro
182
280
  // Do not edit — this file is regenerated on each build.
183
281
 
184
- import { defineNitroConfig } from 'nitro/config'
282
+ import { defineNitroConfig } from 'nitropack/config'
185
283
 
186
284
  export default defineNitroConfig(${JSON.stringify(config, null, 2)})
187
285
  `;
@@ -224,6 +322,9 @@ if (existsSync(manifestPath)) {
224
322
  // Import the RSC handler (default export is the fetch-like handler).
225
323
  const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
226
324
 
325
+ // Import compression helper for self-hosted response compression.
326
+ const { compressResponse } = await import('./_compress.mjs');
327
+
227
328
  const MIME_TYPES = {
228
329
  '.html': 'text/html',
229
330
  '.js': 'application/javascript',
@@ -251,10 +352,9 @@ const MIME_TYPES = {
251
352
 
252
353
  const publicDir = join(__dirname, '${publicDir}');
253
354
  const port = parseInt(process.env.PORT || '3000', 10);
254
- const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
255
355
 
256
356
  const server = createServer(async (req, res) => {
257
- const url = new URL(req.url || '/', \`http://\${host}:\${port}\`);
357
+ const url = new URL(req.url || '/', \`http://localhost:\${port}\`);
258
358
 
259
359
  // Try serving static files from the public directory first.
260
360
  const filePath = join(publicDir, url.pathname);
@@ -317,10 +417,13 @@ const server = createServer(async (req, res) => {
317
417
  ? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
318
418
  : undefined;
319
419
 
320
- const webResponse = earlyHintsSender && runWithEarlyHintsSender
420
+ const rawResponse = earlyHintsSender && runWithEarlyHintsSender
321
421
  ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
322
422
  : await handler(webRequest);
323
423
 
424
+ // Compress the response for self-hosted deployments.
425
+ const webResponse = compressResponse(webRequest, rawResponse);
426
+
324
427
  // Write the response back to the Node response.
325
428
  res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
326
429
 
@@ -346,11 +449,11 @@ const server = createServer(async (req, res) => {
346
449
  }
347
450
  });
348
451
 
349
- server.listen(port, host, () => {
452
+ server.listen(port, () => {
350
453
  console.log();
351
454
  console.log(' ⚡ timber preview server running at:');
352
455
  console.log();
353
- console.log(\` ➜ http://\${host}:\${port}\`);
456
+ console.log(\` ➜ http://localhost:\${port}\`);
354
457
  console.log();
355
458
  });
356
459
  `;
@@ -366,30 +469,6 @@ function generateNitroPreviewCommand(buildDir, preset) {
366
469
  cwd: nitroDir
367
470
  };
368
471
  }
369
- /**
370
- * Run the Nitro production build using the programmatic API.
371
- * Uses dynamic import so nitro is only loaded at build time.
372
- * Externalizes the timber RSC/SSR output — those files are pre-built
373
- * by timber and have internal references that nitro's bundler can't follow.
374
- */
375
- async function runNitroBuild(nitroDir, preset, userConfig) {
376
- const presetConfig = PRESET_CONFIGS[preset];
377
- const { createNitro, build: nitroBuild, prepare, copyPublicAssets } = await import("nitro");
378
- const nitro = await createNitro({
379
- rootDir: nitroDir,
380
- preset: presetConfig.nitroPreset,
381
- renderer: { entry: join(nitroDir, "entry.ts") },
382
- output: { dir: join(nitroDir, presetConfig.outputDir) },
383
- routeRules: { "/assets/**": { headers: { "Cache-Control": IMMUTABLE_CACHE } } },
384
- rollupConfig: { external: [/\.\.\/rsc\//] },
385
- ...presetConfig.extraConfig,
386
- ...userConfig
387
- });
388
- await prepare(nitro);
389
- await copyPublicAssets(nitro);
390
- await nitroBuild(nitro);
391
- await nitro.close();
392
- }
393
472
  /** Spawn a Nitro preview process and pipe stdio. */
394
473
  function spawnNitroPreview(command, args, cwd) {
395
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, readFile, 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 // Prepend the manifest assignment directly into the RSC entry so\n // globalThis.__TIMBER_BUILD_MANIFEST__ is set before any module reads it.\n // This must be top-level code, not an import, because rollup tree-shakes\n // side-effect-only globalThis assignments from imported modules.\n if (config.manifestInit) {\n const rscEntry = join(outDir, 'rsc', 'index.js');\n const rscContent = await readFile(rscEntry, 'utf-8');\n await writeFile(rscEntry, `${config.manifestInit}\\n${rscContent}`);\n }\n\n // Generate the Nitro entry point (imports from ./rsc/ within nitro dir)\n const entry = generateNitroEntry(buildDir, outDir, preset);\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): 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 // The manifest init is prepended to rsc/index.js before the nitro build,\n // so globalThis.__TIMBER_BUILD_MANIFEST__ is set before any code reads it.\n const serverEntryRelative = './rsc/index.js';\n const runtimeName = PRESET_CONFIGS[preset].runtimeName;\n const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;\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\nimport { 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\\//],\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":"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"}
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`);