@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.
@@ -1 +1 @@
1
- {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/plugins/dev-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiC,MAAM,MAAM,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAiChD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAmD1D"}
1
+ {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/plugins/dev-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiC,MAAM,MAAM,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAkChD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAmD1D"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * MIME types that benefit from compression.
3
+ * text/* is handled via prefix matching; these are the specific
4
+ * application/* and image/* types that are compressible.
5
+ */
6
+ export declare const COMPRESSIBLE_TYPES: Set<string>;
7
+ /**
8
+ * Parse Accept-Encoding and return the best supported encoding.
9
+ * Prefers brotli (br) over gzip. Returns null if no supported encoding.
10
+ *
11
+ * We always prefer brotli regardless of quality values because:
12
+ * 1. Brotli achieves better compression ratios than gzip
13
+ * 2. All modern browsers that send br in Accept-Encoding support it well
14
+ * 3. Respecting q-values for br vs gzip adds complexity with no real benefit
15
+ */
16
+ export declare function negotiateEncoding(acceptEncoding: string): 'br' | 'gzip' | null;
17
+ /**
18
+ * Determine if a response should be compressed.
19
+ *
20
+ * Returns false for:
21
+ * - Responses without a body (204, 304, null body)
22
+ * - Already-encoded responses (Content-Encoding set)
23
+ * - Non-compressible content types (images, binary)
24
+ * - SSE streams (text/event-stream — must not be buffered)
25
+ */
26
+ export declare function shouldCompress(response: Response): boolean;
27
+ /**
28
+ * Compress a Web Response if the client supports it and the content is compressible.
29
+ *
30
+ * Returns the original response unchanged if compression is not applicable.
31
+ * Returns a new Response with the compressed body, Content-Encoding, and Vary headers.
32
+ *
33
+ * The body is piped through a compression stream — no buffering of the full response.
34
+ * This preserves streaming behavior for HTML shell + deferred Suspense chunks.
35
+ */
36
+ export declare function compressResponse(request: Request, response: Response): Response;
37
+ //# sourceMappingURL=compress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAaA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,IAAI,CAU9E;AAID;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAmB1D;AAID;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,GAAG,QAAQ,CAmC/E"}
@@ -62,5 +62,5 @@ export declare class RouteSignalWithContext extends Error {
62
62
  * Does NOT serialize to RSC Flight — the caller decides whether to render
63
63
  * to a stream or use the element directly (e.g., for action revalidation).
64
64
  */
65
- export declare function buildRouteElement(req: Request, match: RouteMatch, interception?: InterceptionContext): Promise<RouteElementResult>;
65
+ export declare function buildRouteElement(req: Request, match: RouteMatch, interception?: InterceptionContext, clientStateTree?: Set<string> | null): Promise<RouteElementResult>;
66
66
  //# sourceMappingURL=route-element-builder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAIzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AAID;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,GACjC,OAAO,CAAC,kBAAkB,CAAC,CAyS7B"}
1
+ {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAKzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AAID;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAgU7B"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAsEA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAwWD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;8BAjPpD,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AAmPhD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AAuEA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AA+WD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;8BAxPpD,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA0PhD,wBAAiE"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * State Tree Diffing — Server-side parsing and diffing of X-Timber-State-Tree.
3
+ *
4
+ * The client sends X-Timber-State-Tree on navigation requests, listing
5
+ * the sync segments it has cached. The server diffs this against the
6
+ * target route's segments to skip re-rendering unchanged sync layouts.
7
+ *
8
+ * This is a performance optimization only — NOT a security boundary.
9
+ * All access.ts files run regardless of the state tree content.
10
+ * A fabricated state tree can only cause extra rendering work or stale
11
+ * layouts — never auth bypass.
12
+ *
13
+ * See design/19-client-navigation.md §"X-Timber-State-Tree Header"
14
+ * See design/13-security.md §"State tree manipulation"
15
+ */
16
+ /**
17
+ * Parse the X-Timber-State-Tree header from a request.
18
+ *
19
+ * Returns a Set of segment paths the client has cached, or null if
20
+ * the header is missing, malformed, or empty. Parsing happens before
21
+ * renderToReadableStream — not inside the React render pass.
22
+ *
23
+ * @returns Set of sync segment paths, or null if no valid state tree
24
+ */
25
+ export declare function parseClientStateTree(req: Request): Set<string> | null;
26
+ /**
27
+ * Determine whether a segment's layout rendering can be skipped.
28
+ *
29
+ * A segment is skipped when ALL of the following are true:
30
+ * 1. The client has the segment in its state tree (clientSegments contains urlPath)
31
+ * 2. The layout is sync (not an async function — async layouts always re-render)
32
+ * 3. The segment is NOT the leaf (pages are never cached across navigations)
33
+ *
34
+ * Access.ts still runs for skipped segments — this is enforced by the caller
35
+ * (buildRouteElement) which runs all access checks before building the tree.
36
+ *
37
+ * @param urlPath - The segment's URL path (e.g., "/", "/dashboard")
38
+ * @param layoutComponent - The loaded layout component function
39
+ * @param isLeaf - Whether this is the leaf segment (page segment)
40
+ * @param clientSegments - Set of paths from X-Timber-State-Tree, or null
41
+ */
42
+ export declare function shouldSkipSegment(urlPath: string, layoutComponent: ((...args: unknown[]) => unknown) | undefined, isLeaf: boolean, clientSegments: Set<string> | null): boolean;
43
+ //# sourceMappingURL=state-tree-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC9D,MAAM,EAAE,OAAO,EACf,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACjC,OAAO,CAeT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -87,8 +87,7 @@
87
87
  "dependencies": {
88
88
  "@opentelemetry/api": "^1.9.0",
89
89
  "@opentelemetry/context-async-hooks": "^2.6.0",
90
- "@opentelemetry/sdk-trace-base": "^2.6.0",
91
- "nitro": "^3.0.0"
90
+ "@opentelemetry/sdk-trace-base": "^2.6.0"
92
91
  },
93
92
  "peerDependencies": {
94
93
  "@content-collections/core": "^0.14.0",
@@ -0,0 +1,108 @@
1
+ // Generated compression module template for self-hosted deployments.
2
+ //
3
+ // This file generates a standalone ESM module (_compress.mjs) that is
4
+ // written to the build output during adapter buildOutput(). It's imported
5
+ // by the Nitro entry and preview server at runtime.
6
+ //
7
+ // Uses CompressionStream (Web API) for gzip and node:zlib for brotli.
8
+ // Cloudflare Workers don't need this — the edge auto-compresses.
9
+ //
10
+ // See design/25-production-deployments.md.
11
+
12
+ /**
13
+ * Generate a standalone ESM module that exports compressResponse().
14
+ *
15
+ * Written to `_compress.mjs` during buildOutput. Imported by the Nitro entry
16
+ * and preview server.
17
+ *
18
+ * @internal Exported for testing.
19
+ */
20
+ export function generateCompressModule(): string {
21
+ return `// Generated by @timber-js/app — response compression for self-hosted deployments.
22
+ // Do not edit — this file is regenerated on each build.
23
+ // Uses CompressionStream (Web API) for gzip, node:zlib for brotli.
24
+
25
+ import { createBrotliCompress, constants as zlibConstants } from 'node:zlib';
26
+ import { Readable } from 'node:stream';
27
+
28
+ const COMPRESSIBLE_TYPES = new Set([
29
+ 'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
30
+ 'text/x-component', 'application/json', 'application/javascript',
31
+ 'application/xml', 'application/xhtml+xml', 'application/rss+xml',
32
+ 'application/atom+xml', 'image/svg+xml',
33
+ ]);
34
+
35
+ const NO_COMPRESS_STATUSES = new Set([204, 304]);
36
+
37
+ function negotiateEncoding(acceptEncoding) {
38
+ if (!acceptEncoding) return null;
39
+ const tokens = acceptEncoding.split(',').map(s => s.split(';')[0].trim().toLowerCase());
40
+ if (tokens.includes('br')) return 'br';
41
+ if (tokens.includes('gzip')) return 'gzip';
42
+ return null;
43
+ }
44
+
45
+ function shouldCompress(response) {
46
+ if (!response.body) return false;
47
+ if (NO_COMPRESS_STATUSES.has(response.status)) return false;
48
+ if (response.headers.has('Content-Encoding')) return false;
49
+ const contentType = response.headers.get('Content-Type');
50
+ if (!contentType) return false;
51
+ const mimeType = contentType.split(';')[0].trim().toLowerCase();
52
+ if (mimeType === 'text/event-stream') return false;
53
+ return COMPRESSIBLE_TYPES.has(mimeType);
54
+ }
55
+
56
+ function compressWithGzip(body) {
57
+ return body.pipeThrough(new CompressionStream('gzip'));
58
+ }
59
+
60
+ function compressWithBrotli(body) {
61
+ const brotli = createBrotliCompress({
62
+ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
63
+ });
64
+ const reader = body.getReader();
65
+ const pump = async () => {
66
+ try {
67
+ while (true) {
68
+ const { done, value } = await reader.read();
69
+ if (done) { brotli.end(); return; }
70
+ if (!brotli.write(value)) {
71
+ await new Promise(resolve => brotli.once('drain', resolve));
72
+ }
73
+ }
74
+ } catch (err) {
75
+ brotli.destroy(err instanceof Error ? err : new Error(String(err)));
76
+ }
77
+ };
78
+ pump();
79
+ return Readable.toWeb(brotli);
80
+ }
81
+
82
+ export function compressResponse(request, response) {
83
+ if (!shouldCompress(response)) return response;
84
+ const acceptEncoding = request.headers.get('Accept-Encoding') || '';
85
+ const encoding = negotiateEncoding(acceptEncoding);
86
+ if (!encoding) return response;
87
+ const compressedBody = encoding === 'br'
88
+ ? compressWithBrotli(response.body)
89
+ : compressWithGzip(response.body);
90
+ const headers = new Headers(response.headers);
91
+ headers.set('Content-Encoding', encoding);
92
+ headers.delete('Content-Length');
93
+ const existingVary = headers.get('Vary');
94
+ if (existingVary) {
95
+ if (!existingVary.toLowerCase().includes('accept-encoding')) {
96
+ headers.set('Vary', existingVary + ', Accept-Encoding');
97
+ }
98
+ } else {
99
+ headers.set('Vary', 'Accept-Encoding');
100
+ }
101
+ return new Response(compressedBody, {
102
+ status: response.status,
103
+ statusText: response.statusText,
104
+ headers,
105
+ });
106
+ }
107
+ `;
108
+ }
@@ -9,6 +9,7 @@ import { writeFile, mkdir, cp } from 'node:fs/promises';
9
9
  import { execFile } from 'node:child_process';
10
10
  import { join, relative } from 'node:path';
11
11
  import type { TimberPlatformAdapter, TimberConfig } from './types';
12
+ import { generateCompressModule } from './compress-module.js';
12
13
  // Inlined from server/asset-headers.ts — adapters are loaded by Node at
13
14
  // Vite startup time, before Vite's module resolver is available, so cross-
14
15
  // directory .ts imports don't resolve.
@@ -198,20 +199,19 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
198
199
  await writeFile(join(outDir, '_timber-manifest-init.js'), config.manifestInit);
199
200
  }
200
201
 
201
- // Copy rsc/ssr build output into the nitro dir so imports stay local
202
- // during the Nitro bundling step (avoids broken relative paths in output).
203
- await cp(join(buildDir, 'rsc'), join(outDir, 'rsc'), { recursive: true });
204
- await cp(join(buildDir, 'ssr'), join(outDir, 'ssr'), { recursive: true }).catch(() => {});
202
+ // Write the compression helper module for runtime use.
203
+ // See design/25-production-deployments.md self-hosted deployments
204
+ // need application-level compression (Cloudflare handles it at the edge).
205
+ await writeFile(join(outDir, '_compress.mjs'), generateCompressModule());
205
206
 
206
- // Generate the Nitro entry point (imports from ./rsc/ within nitro dir)
207
+ // Generate the Nitro entry point
207
208
  const hasManifestInit = !!config.manifestInit;
208
209
  const entry = generateNitroEntry(buildDir, outDir, preset, hasManifestInit);
209
210
  await writeFile(join(outDir, 'entry.ts'), entry);
210
211
 
211
- // Run the Nitro build to produce a production-ready server bundle.
212
- // The output goes to dist/nitro/.output/server/index.mjs (for node-server preset).
213
- // Config is passed programmatically — no nitro.config.ts file needed.
214
- await runNitroBuild(outDir, preset, options.nitroConfig);
212
+ // Generate the Nitro config with static asset cache rules
213
+ const nitroConfig = generateNitroConfig(preset, options.nitroConfig);
214
+ await writeFile(join(outDir, 'nitro.config.ts'), nitroConfig);
215
215
  },
216
216
 
217
217
  // Only presets that produce a locally-runnable server get preview().
@@ -252,8 +252,12 @@ export function generateNitroEntry(
252
252
  hasManifestInit = false
253
253
  ): string {
254
254
  // The RSC entry is the main request handler — it exports the fetch handler as default.
255
- // rsc/ is copied into the nitro dir so the import is local.
256
- const serverEntryRelative = './rsc/index.js';
255
+ // The Vite RSC plugin outputs it to rsc/index.js.
256
+ let serverEntryRelative = relative(outDir, join(buildDir, 'rsc', 'index.js'));
257
+ // Ensure the import path starts with ./ for ESM compatibility
258
+ if (!serverEntryRelative.startsWith('.')) {
259
+ serverEntryRelative = './' + serverEntryRelative;
260
+ }
257
261
  const runtimeName = PRESET_CONFIGS[preset].runtimeName;
258
262
  const earlyHints = PRESET_CONFIGS[preset].supportsEarlyHints;
259
263
 
@@ -278,18 +282,19 @@ export function generateNitroEntry(
278
282
  return `// Generated by @timber-js/app/adapters/nitro
279
283
  // Do not edit — this file is regenerated on each build.
280
284
 
281
- ${manifestImport}import { defineEventHandler } from 'nitro/h3'
285
+ ${manifestImport}import { defineEventHandler, toWebRequest, sendWebResponse } from 'h3'
282
286
  import handler, { runWithEarlyHintsSender } from '${serverEntryRelative}'
287
+ import { compressResponse } from './_compress.mjs'
283
288
 
284
289
  // Set TIMBER_RUNTIME for instrumentation.ts conditional SDK initialization.
285
290
  // See design/25-production-deployments.md §"TIMBER_RUNTIME".
286
291
  process.env.TIMBER_RUNTIME = '${runtimeName}'
287
292
 
288
293
  export default defineEventHandler(async (event) => {
289
- // h3 v2: event.req is the Web Request
290
- const webRequest = event.req
294
+ const webRequest = toWebRequest(event)
291
295
  ${handlerCall}
292
- return webResponse
296
+ const finalResponse = compressResponse(webRequest, webResponse)
297
+ return sendWebResponse(event, finalResponse)
293
298
  })
294
299
  `;
295
300
  }
@@ -303,7 +308,6 @@ export function generateNitroConfig(
303
308
 
304
309
  const config: Record<string, unknown> = {
305
310
  preset: presetConfig.nitroPreset,
306
- entry: './entry.ts',
307
311
  output: { dir: presetConfig.outputDir },
308
312
  // Static asset cache headers — hashed assets are immutable, others get 1h.
309
313
  // See design/25-production-deployments.md §"CDN / Edge Cache"
@@ -319,7 +323,7 @@ export function generateNitroConfig(
319
323
  return `// Generated by @timber-js/app/adapters/nitro
320
324
  // Do not edit — this file is regenerated on each build.
321
325
 
322
- import { defineNitroConfig } from 'nitro/config'
326
+ import { defineNitroConfig } from 'nitropack/config'
323
327
 
324
328
  export default defineNitroConfig(${configJson})
325
329
  `;
@@ -368,6 +372,9 @@ if (existsSync(manifestPath)) {
368
372
  // Import the RSC handler (default export is the fetch-like handler).
369
373
  const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
370
374
 
375
+ // Import compression helper for self-hosted response compression.
376
+ const { compressResponse } = await import('./_compress.mjs');
377
+
371
378
  const MIME_TYPES = {
372
379
  '.html': 'text/html',
373
380
  '.js': 'application/javascript',
@@ -395,10 +402,9 @@ const MIME_TYPES = {
395
402
 
396
403
  const publicDir = join(__dirname, '${publicDir}');
397
404
  const port = parseInt(process.env.PORT || '3000', 10);
398
- const host = process.env.HOST || process.env.HOSTNAME || 'localhost';
399
405
 
400
406
  const server = createServer(async (req, res) => {
401
- const url = new URL(req.url || '/', \`http://\${host}:\${port}\`);
407
+ const url = new URL(req.url || '/', \`http://localhost:\${port}\`);
402
408
 
403
409
  // Try serving static files from the public directory first.
404
410
  const filePath = join(publicDir, url.pathname);
@@ -461,10 +467,13 @@ const server = createServer(async (req, res) => {
461
467
  ? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
462
468
  : undefined;
463
469
 
464
- const webResponse = earlyHintsSender && runWithEarlyHintsSender
470
+ const rawResponse = earlyHintsSender && runWithEarlyHintsSender
465
471
  ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
466
472
  : await handler(webRequest);
467
473
 
474
+ // Compress the response for self-hosted deployments.
475
+ const webResponse = compressResponse(webRequest, rawResponse);
476
+
468
477
  // Write the response back to the Node response.
469
478
  res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
470
479
 
@@ -490,11 +499,11 @@ const server = createServer(async (req, res) => {
490
499
  }
491
500
  });
492
501
 
493
- server.listen(port, host, () => {
502
+ server.listen(port, () => {
494
503
  console.log();
495
504
  console.log(' ⚡ timber preview server running at:');
496
505
  console.log();
497
- console.log(\` ➜ http://\${host}:\${port}\`);
506
+ console.log(\` ➜ http://localhost:\${port}\`);
498
507
  console.log();
499
508
  });
500
509
  `;
@@ -525,47 +534,6 @@ export function generateNitroPreviewCommand(
525
534
  };
526
535
  }
527
536
 
528
- /**
529
- * Run the Nitro production build using the programmatic API.
530
- * Uses dynamic import so nitro is only loaded at build time.
531
- * Externalizes the timber RSC/SSR output — those files are pre-built
532
- * by timber and have internal references that nitro's bundler can't follow.
533
- */
534
- async function runNitroBuild(
535
- nitroDir: string,
536
- preset: NitroPreset,
537
- userConfig?: Record<string, unknown>
538
- ): Promise<void> {
539
- const presetConfig = PRESET_CONFIGS[preset];
540
- const { createNitro, build: nitroBuild, prepare, copyPublicAssets } = await import('nitro');
541
-
542
- const nitro = await createNitro({
543
- rootDir: nitroDir,
544
- preset: presetConfig.nitroPreset,
545
- // Use renderer.entry so Nitro wraps our handler with its server runtime
546
- // (HTTP server, static file serving, graceful shutdown, etc.).
547
- // Using `entry` directly would bypass the Nitro server runtime.
548
- renderer: { entry: join(nitroDir, 'entry.ts') },
549
- output: { dir: join(nitroDir, presetConfig.outputDir) },
550
- routeRules: {
551
- '/assets/**': { headers: { 'Cache-Control': IMMUTABLE_CACHE } },
552
- },
553
- // Don't bundle the timber RSC/SSR build output — it has its own
554
- // internal file references that nitro's bundler can't follow.
555
- // Mark them as external so rollup leaves the imports as-is.
556
- rollupConfig: {
557
- external: [/\.\.\/rsc\//, /\.\/_timber-manifest-init/],
558
- },
559
- ...presetConfig.extraConfig,
560
- ...userConfig,
561
- });
562
-
563
- await prepare(nitro);
564
- await copyPublicAssets(nitro);
565
- await nitroBuild(nitro);
566
- await nitro.close();
567
- }
568
-
569
537
  /** Spawn a Nitro preview process and pipe stdio. */
570
538
  function spawnNitroPreview(command: string, args: string[], cwd: string): Promise<void> {
571
539
  return new Promise<void>((resolve, reject) => {
@@ -18,6 +18,7 @@ import { join } from 'node:path';
18
18
  import type { PluginContext } from '#/index.js';
19
19
  import { setViteServer } from '#/server/dev-warnings.js';
20
20
  import { sendErrorToOverlay, classifyErrorPhase, parseFirstAppFrame } from './dev-error-overlay.js';
21
+ import { compressResponse } from '#/server/compress.js';
21
22
 
22
23
  // ─── Constants ────────────────────────────────────────────────────────────
23
24
 
@@ -193,8 +194,13 @@ function createTimberMiddleware(server: ViteDevServer, projectRoot: string) {
193
194
  // Run the full pipeline
194
195
  const webResponse = await handler(webRequest);
195
196
 
197
+ // Compress the response if the client supports it.
198
+ // In dev mode, compression is always enabled for parity with production.
199
+ // See design/25-production-deployments.md.
200
+ const finalResponse = compressResponse(webRequest, webResponse);
201
+
196
202
  // Convert Web Response → Node ServerResponse
197
- await sendWebResponse(res, webResponse);
203
+ await sendWebResponse(res, finalResponse);
198
204
  } catch (error) {
199
205
  // Pipeline error — classify the phase, send to overlay, respond 500.
200
206
  // The dev server remains running for recovery on file fix + HMR.
@@ -0,0 +1,200 @@
1
+ // Response compression for self-hosted deployments (dev server, Nitro preview).
2
+ //
3
+ // Uses CompressionStream (Web Platform API) for gzip and node:zlib for
4
+ // brotli (CompressionStream doesn't support brotli). Cloudflare Workers
5
+ // auto-compress at the edge — this module is only used on Node.js/Bun.
6
+ //
7
+ // See design/25-production-deployments.md.
8
+
9
+ import { createBrotliCompress, constants as zlibConstants } from 'node:zlib';
10
+ import { Readable } from 'node:stream';
11
+
12
+ // ─── Constants ────────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * MIME types that benefit from compression.
16
+ * text/* is handled via prefix matching; these are the specific
17
+ * application/* and image/* types that are compressible.
18
+ */
19
+ export const COMPRESSIBLE_TYPES = new Set([
20
+ 'text/html',
21
+ 'text/css',
22
+ 'text/plain',
23
+ 'text/xml',
24
+ 'text/javascript',
25
+ 'text/x-component',
26
+ 'application/json',
27
+ 'application/javascript',
28
+ 'application/xml',
29
+ 'application/xhtml+xml',
30
+ 'application/rss+xml',
31
+ 'application/atom+xml',
32
+ 'image/svg+xml',
33
+ ]);
34
+
35
+ /**
36
+ * Status codes that should never be compressed (no body or special semantics).
37
+ */
38
+ const NO_COMPRESS_STATUSES = new Set([204, 304]);
39
+
40
+ // ─── Encoding Negotiation ─────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Parse Accept-Encoding and return the best supported encoding.
44
+ * Prefers brotli (br) over gzip. Returns null if no supported encoding.
45
+ *
46
+ * We always prefer brotli regardless of quality values because:
47
+ * 1. Brotli achieves better compression ratios than gzip
48
+ * 2. All modern browsers that send br in Accept-Encoding support it well
49
+ * 3. Respecting q-values for br vs gzip adds complexity with no real benefit
50
+ */
51
+ export function negotiateEncoding(acceptEncoding: string): 'br' | 'gzip' | null {
52
+ if (!acceptEncoding) return null;
53
+
54
+ // Parse tokens from the Accept-Encoding header (ignore quality values).
55
+ // e.g. "gzip;q=1.0, br;q=0.8, deflate" → ['gzip', 'br', 'deflate']
56
+ const tokens = acceptEncoding.split(',').map((s) => s.split(';')[0].trim().toLowerCase());
57
+
58
+ if (tokens.includes('br')) return 'br';
59
+ if (tokens.includes('gzip')) return 'gzip';
60
+ return null;
61
+ }
62
+
63
+ // ─── Compressibility Check ────────────────────────────────────────────────
64
+
65
+ /**
66
+ * Determine if a response should be compressed.
67
+ *
68
+ * Returns false for:
69
+ * - Responses without a body (204, 304, null body)
70
+ * - Already-encoded responses (Content-Encoding set)
71
+ * - Non-compressible content types (images, binary)
72
+ * - SSE streams (text/event-stream — must not be buffered)
73
+ */
74
+ export function shouldCompress(response: Response): boolean {
75
+ // No body to compress
76
+ if (!response.body) return false;
77
+ if (NO_COMPRESS_STATUSES.has(response.status)) return false;
78
+
79
+ // Already compressed
80
+ if (response.headers.has('Content-Encoding')) return false;
81
+
82
+ // Check content type
83
+ const contentType = response.headers.get('Content-Type');
84
+ if (!contentType) return false;
85
+
86
+ // Extract the MIME type (strip charset and other parameters)
87
+ const mimeType = contentType.split(';')[0].trim().toLowerCase();
88
+
89
+ // SSE must not be compressed — it relies on chunk-by-chunk delivery
90
+ if (mimeType === 'text/event-stream') return false;
91
+
92
+ return COMPRESSIBLE_TYPES.has(mimeType);
93
+ }
94
+
95
+ // ─── Compression ──────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Compress a Web Response if the client supports it and the content is compressible.
99
+ *
100
+ * Returns the original response unchanged if compression is not applicable.
101
+ * Returns a new Response with the compressed body, Content-Encoding, and Vary headers.
102
+ *
103
+ * The body is piped through a compression stream — no buffering of the full response.
104
+ * This preserves streaming behavior for HTML shell + deferred Suspense chunks.
105
+ */
106
+ export function compressResponse(request: Request, response: Response): Response {
107
+ // Check if response is compressible
108
+ if (!shouldCompress(response)) return response;
109
+
110
+ // Negotiate encoding with the client
111
+ const acceptEncoding = request.headers.get('Accept-Encoding') ?? '';
112
+ const encoding = negotiateEncoding(acceptEncoding);
113
+ if (!encoding) return response;
114
+
115
+ // Compress the body stream
116
+ const compressedBody = encoding === 'br'
117
+ ? compressWithBrotli(response.body!)
118
+ : compressWithGzip(response.body!);
119
+
120
+ // Build new headers: copy originals, add compression headers, remove Content-Length
121
+ // (compressed size is unknown until streaming completes).
122
+ const headers = new Headers(response.headers);
123
+ headers.set('Content-Encoding', encoding);
124
+ headers.delete('Content-Length');
125
+
126
+ // Append to Vary header (preserve existing Vary values)
127
+ const existingVary = headers.get('Vary');
128
+ if (existingVary) {
129
+ if (!existingVary.toLowerCase().includes('accept-encoding')) {
130
+ headers.set('Vary', `${existingVary}, Accept-Encoding`);
131
+ }
132
+ } else {
133
+ headers.set('Vary', 'Accept-Encoding');
134
+ }
135
+
136
+ return new Response(compressedBody, {
137
+ status: response.status,
138
+ statusText: response.statusText,
139
+ headers,
140
+ });
141
+ }
142
+
143
+ // ─── Gzip (CompressionStream API) ────────────────────────────────────────
144
+
145
+ /**
146
+ * Compress a ReadableStream with gzip using the Web Platform CompressionStream API.
147
+ * Available in Node 18+, Bun, and Deno — no npm dependency needed.
148
+ */
149
+ function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
150
+ const compressionStream = new CompressionStream('gzip');
151
+ // Cast needed: CompressionStream's WritableStream<BufferSource> type is wider
152
+ // than ReadableStream's Uint8Array, but Uint8Array is a valid BufferSource.
153
+ return body.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
154
+ }
155
+
156
+ // ─── Brotli (node:zlib) ──────────────────────────────────────────────────
157
+
158
+ /**
159
+ * Compress a ReadableStream with brotli using node:zlib.
160
+ *
161
+ * CompressionStream doesn't support brotli — it only handles gzip and deflate.
162
+ * We use node:zlib's createBrotliCompress() and bridge between Web streams
163
+ * and Node streams.
164
+ */
165
+ function compressWithBrotli(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
166
+ const brotli = createBrotliCompress({
167
+ params: {
168
+ // Quality 4 balances compression ratio and CPU time for streaming.
169
+ // Default (11) is too slow for real-time responses.
170
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 4,
171
+ },
172
+ });
173
+
174
+ // Pipe the Web ReadableStream into the Node brotli transform.
175
+ const reader = body.getReader();
176
+
177
+ // Pump chunks from the Web ReadableStream into the Node transform.
178
+ const pump = async (): Promise<void> => {
179
+ try {
180
+ while (true) {
181
+ const { done, value } = await reader.read();
182
+ if (done) {
183
+ brotli.end();
184
+ return;
185
+ }
186
+ // Write to brotli, wait for drain if buffer is full
187
+ if (!brotli.write(value)) {
188
+ await new Promise<void>((resolve) => brotli.once('drain', resolve));
189
+ }
190
+ }
191
+ } catch (err) {
192
+ brotli.destroy(err instanceof Error ? err : new Error(String(err)));
193
+ }
194
+ };
195
+ // Start pumping (fire and forget — errors propagate via brotli stream)
196
+ pump();
197
+
198
+ // Convert the Node readable (brotli output) to a Web ReadableStream.
199
+ return Readable.toWeb(brotli) as ReadableStream<Uint8Array>;
200
+ }