@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.
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.js +8 -30
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/index.js +13 -38
- package/dist/index.js.map +1 -1
- package/dist/server/compress.d.ts +11 -6
- package/dist/server/compress.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/compress-module.ts +11 -31
- package/src/server/compress.ts +18 -62
|
@@ -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
|
-
*
|
|
9
|
+
* Returns 'gzip' if the client accepts it, null otherwise.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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): '
|
|
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":"
|
|
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
|
@@ -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
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 =
|
|
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');
|
package/src/server/compress.ts
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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): '
|
|
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 =
|
|
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
|
-
}
|