@timber-js/app 0.1.44 → 0.1.45

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.
@@ -6,14 +6,19 @@
6
6
  export declare const COMPRESSIBLE_TYPES: Set<string>;
7
7
  /**
8
8
  * Parse Accept-Encoding and return the best supported encoding.
9
- * Prefers brotli (br) over gzip. Returns null if no supported encoding.
9
+ * Returns 'gzip' if the client accepts it, null otherwise.
10
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
11
+ * Brotli (br) is intentionally not handled at the application level.
12
+ * At the streaming-friendly quality levels (0–4), brotli's compression
13
+ * ratio advantage over gzip is marginal, and node:zlib's brotli transform
14
+ * buffers output internally turning smooth streaming responses into
15
+ * large infrequent bursts. Brotli's real wins come from offline/static
16
+ * compression at higher quality levels (5–11), which CDNs and reverse
17
+ * proxies (Cloudflare, nginx, Caddy) apply on cached responses.
18
+ *
19
+ * See design/25-production-deployments.md.
15
20
  */
16
- export declare function negotiateEncoding(acceptEncoding: string): 'br' | 'gzip' | null;
21
+ export declare function negotiateEncoding(acceptEncoding: string): 'gzip' | null;
17
22
  /**
18
23
  * Determine if a response should be compressed.
19
24
  *
@@ -1 +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"}
1
+ {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/server/compress.ts"],"names":[],"mappings":"AAYA;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,aAc7B,CAAC;AASH;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CASvE;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,CAiC/E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
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",
@@ -4,7 +4,9 @@
4
4
  // written to the build output during adapter buildOutput(). It's imported
5
5
  // by the Nitro entry and preview server at runtime.
6
6
  //
7
- // Uses CompressionStream (Web API) for gzip and node:zlib for brotli.
7
+ // Uses CompressionStream (Web API) for gzip. Brotli is left to CDNs/reverse
8
+ // proxies — at streaming quality levels its ratio advantage is marginal and
9
+ // node:zlib buffers output internally, breaking streaming.
8
10
  // Cloudflare Workers don't need this — the edge auto-compresses.
9
11
  //
10
12
  // See design/25-production-deployments.md.
@@ -20,10 +22,9 @@
20
22
  export function generateCompressModule(): string {
21
23
  return `// Generated by @timber-js/app — response compression for self-hosted deployments.
22
24
  // 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';
25
+ // Uses CompressionStream (Web API) for gzip. Brotli is left to CDNs/reverse
26
+ // proxies — at streaming quality levels its ratio advantage is marginal and
27
+ // node:zlib buffers output internally, breaking streaming.
27
28
 
28
29
  const COMPRESSIBLE_TYPES = new Set([
29
30
  'text/html', 'text/css', 'text/plain', 'text/xml', 'text/javascript',
@@ -37,7 +38,10 @@ const NO_COMPRESS_STATUSES = new Set([204, 304]);
37
38
  function negotiateEncoding(acceptEncoding) {
38
39
  if (!acceptEncoding) return null;
39
40
  const tokens = acceptEncoding.split(',').map(s => s.split(';')[0].trim().toLowerCase());
40
- if (tokens.includes('br')) return 'br';
41
+ // Brotli (br) is intentionally not handled at the application level.
42
+ // At streaming-friendly quality levels (0-4), brotli's ratio advantage over
43
+ // gzip is marginal, and node:zlib's brotli transform buffers output internally.
44
+ // CDNs/reverse proxies apply brotli at higher quality levels on cached responses.
41
45
  if (tokens.includes('gzip')) return 'gzip';
42
46
  return null;
43
47
  }
@@ -57,36 +61,12 @@ function compressWithGzip(body) {
57
61
  return body.pipeThrough(new CompressionStream('gzip'));
58
62
  }
59
63
 
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
64
  export function compressResponse(request, response) {
83
65
  if (!shouldCompress(response)) return response;
84
66
  const acceptEncoding = request.headers.get('Accept-Encoding') || '';
85
67
  const encoding = negotiateEncoding(acceptEncoding);
86
68
  if (!encoding) return response;
87
- const compressedBody = encoding === 'br'
88
- ? compressWithBrotli(response.body)
89
- : compressWithGzip(response.body);
69
+ const compressedBody = compressWithGzip(response.body);
90
70
  const headers = new Headers(response.headers);
91
71
  headers.set('Content-Encoding', encoding);
92
72
  headers.delete('Content-Length');
@@ -1,14 +1,13 @@
1
1
  // Response compression for self-hosted deployments (dev server, Nitro preview).
2
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.
3
+ // Uses CompressionStream (Web Platform API) for gzip. Brotli is intentionally
4
+ // left to CDNs and reverse proxies — at streaming-friendly quality levels its
5
+ // ratio advantage is marginal, and node:zlib's brotli transform buffers output
6
+ // internally, breaking streaming. Cloudflare Workers auto-compress at the edge —
7
+ // this module is only used on Node.js/Bun.
6
8
  //
7
9
  // See design/25-production-deployments.md.
8
10
 
9
- import { createBrotliCompress, constants as zlibConstants } from 'node:zlib';
10
- import { Readable } from 'node:stream';
11
-
12
11
  // ─── Constants ────────────────────────────────────────────────────────────
13
12
 
14
13
  /**
@@ -41,21 +40,25 @@ const NO_COMPRESS_STATUSES = new Set([204, 304]);
41
40
 
42
41
  /**
43
42
  * Parse Accept-Encoding and return the best supported encoding.
44
- * Prefers brotli (br) over gzip. Returns null if no supported encoding.
43
+ * Returns 'gzip' if the client accepts it, null otherwise.
44
+ *
45
+ * Brotli (br) is intentionally not handled at the application level.
46
+ * At the streaming-friendly quality levels (0–4), brotli's compression
47
+ * ratio advantage over gzip is marginal, and node:zlib's brotli transform
48
+ * buffers output internally — turning smooth streaming responses into
49
+ * large infrequent bursts. Brotli's real wins come from offline/static
50
+ * compression at higher quality levels (5–11), which CDNs and reverse
51
+ * proxies (Cloudflare, nginx, Caddy) apply on cached responses.
45
52
  *
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
53
+ * See design/25-production-deployments.md.
50
54
  */
51
- export function negotiateEncoding(acceptEncoding: string): 'br' | 'gzip' | null {
55
+ export function negotiateEncoding(acceptEncoding: string): 'gzip' | null {
52
56
  if (!acceptEncoding) return null;
53
57
 
54
58
  // Parse tokens from the Accept-Encoding header (ignore quality values).
55
59
  // e.g. "gzip;q=1.0, br;q=0.8, deflate" → ['gzip', 'br', 'deflate']
56
60
  const tokens = acceptEncoding.split(',').map((s) => s.split(';')[0].trim().toLowerCase());
57
61
 
58
- if (tokens.includes('br')) return 'br';
59
62
  if (tokens.includes('gzip')) return 'gzip';
60
63
  return null;
61
64
  }
@@ -112,10 +115,8 @@ export function compressResponse(request: Request, response: Response): Response
112
115
  const encoding = negotiateEncoding(acceptEncoding);
113
116
  if (!encoding) return response;
114
117
 
115
- // Compress the body stream
116
- const compressedBody = encoding === 'br'
117
- ? compressWithBrotli(response.body!)
118
- : compressWithGzip(response.body!);
118
+ // Compress the body stream with gzip via the Web Platform CompressionStream API.
119
+ const compressedBody = compressWithGzip(response.body!);
119
120
 
120
121
  // Build new headers: copy originals, add compression headers, remove Content-Length
121
122
  // (compressed size is unknown until streaming completes).
@@ -153,48 +154,3 @@ function compressWithGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint
153
154
  return body.pipeThrough(compressionStream as unknown as TransformStream<Uint8Array, Uint8Array>);
154
155
  }
155
156
 
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
- }