@timber-js/app 0.1.44 → 0.1.46
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/dist/server/index.js +20 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/logger.d.ts +4 -0
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/pipeline.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
- package/src/server/logger.ts +4 -0
- package/src/server/pipeline.ts +29 -5
package/dist/server/logger.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export declare function logRequestCompleted(data: {
|
|
|
30
30
|
path: string;
|
|
31
31
|
status: number;
|
|
32
32
|
durationMs: number;
|
|
33
|
+
/** Number of concurrent in-flight requests (including this one) at completion time. */
|
|
34
|
+
concurrency?: number;
|
|
33
35
|
}): void;
|
|
34
36
|
/** Log request received. Level: debug. */
|
|
35
37
|
export declare function logRequestReceived(data: {
|
|
@@ -42,6 +44,8 @@ export declare function logSlowRequest(data: {
|
|
|
42
44
|
path: string;
|
|
43
45
|
durationMs: number;
|
|
44
46
|
threshold: number;
|
|
47
|
+
/** Number of concurrent in-flight requests at the time the slow request completed. */
|
|
48
|
+
concurrency?: number;
|
|
45
49
|
}): void;
|
|
46
50
|
/** Log middleware short-circuit. Level: debug. */
|
|
47
51
|
export declare function logMiddlewareShortCircuit(data: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/server/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,6FAA6F;AAC7F,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxD,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxD,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1D;AAMD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAEpD;AAED;;;GAGG;AACH,wBAAgB,SAAS,IAAI,YAAY,GAAG,IAAI,CAE/C;AAsBD,4CAA4C;AAC5C,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/server/logger.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,6FAA6F;AAC7F,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxD,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxD,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACzD,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC1D;AAMD;;;GAGG;AACH,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAEpD;AAED;;;GAGG;AACH,wBAAgB,SAAS,IAAI,YAAY,GAAG,IAAI,CAE/C;AAsBD,4CAA4C;AAC5C,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,IAAI,CAEP;AAED,0CAA0C;AAC1C,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAE/E;AAED,+CAA+C;AAC/C,wBAAgB,cAAc,CAAC,IAAI,EAAE;IACnC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GAAG,IAAI,CAEP;AAED,kDAAkD;AAClD,wBAAgB,yBAAyB,CAAC,IAAI,EAAE;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,IAAI,CAEP;AAED,6DAA6D;AAC7D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAM/F;AAED,sDAAsD;AACtD,wBAAgB,cAAc,CAAC,IAAI,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAQ3F;AAED,iDAAiD;AACjD,wBAAgB,aAAa,CAAC,IAAI,EAAE;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAM5D;AAED,sEAAsE;AACtE,wBAAgB,uBAAuB,IAAI,IAAI,CAE9C;AAED,sDAAsD;AACtD,wBAAgB,oBAAoB,CAAC,IAAI,EAAE;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAEnE;AAED,6DAA6D;AAC7D,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAEpF;AAED,oCAAoC;AACpC,wBAAgB,YAAY,CAAC,IAAI,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAE7D"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAY,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAoC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAItD,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,uDAAuD;IACvD,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B;AAED,6DAA6D;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAAC;AAEnE,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,CACjC,QAAQ,EAAE,MAAM,KACb,OAAO,oBAAoB,EAAE,kBAAkB,GAAG,IAAI,CAAC;AAE5D,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,YAAY,CAAC,EAAE,mBAAmB,KAC/B,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,+DAA+D;AAC/D,MAAM,MAAM,iBAAiB,GAAG,CAC9B,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAI1B,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACtD,qEAAqE;IACrE,UAAU,EAAE,YAAY,CAAC;IACzB,iGAAiG;IACjG,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C,kEAAkE;IAClE,MAAM,EAAE,aAAa,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,eAAe,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzF,kFAAkF;IAClF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,gFAAgF;IAChF,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,2BAA2B,EAAE,mBAAmB,EAAE,CAAC;IACjF;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,CACpB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAY,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAoC1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAItD,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,mDAAmD;IACnD,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;IAC1C,uDAAuD;IACvD,UAAU,CAAC,EAAE,YAAY,CAAC;CAC3B;AAED,6DAA6D;AAC7D,MAAM,MAAM,YAAY,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAAC;AAEnE,sEAAsE;AACtE,MAAM,MAAM,oBAAoB,GAAG,CACjC,QAAQ,EAAE,MAAM,KACb,OAAO,oBAAoB,EAAE,kBAAkB,GAAG,IAAI,CAAC;AAE5D,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,YAAY,CAAC,EAAE,mBAAmB,KAC/B,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAElC,+DAA+D;AAC/D,MAAM,MAAM,iBAAiB,GAAG,CAC9B,KAAK,EAAE,UAAU,EACjB,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;AAI1B,MAAM,WAAW,cAAc;IAC7B,iFAAiF;IACjF,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,gFAAgF;IAChF,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC;QAAE,OAAO,EAAE,WAAW,CAAA;KAAE,CAAC,CAAC;IACtD,qEAAqE;IACrE,UAAU,EAAE,YAAY,CAAC;IACzB,iGAAiG;IACjG,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C,kEAAkE;IAClE,MAAM,EAAE,aAAa,CAAC;IACtB,kEAAkE;IAClE,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,eAAe,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACzF,kFAAkF;IAClF,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,gFAAgF;IAChF,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,yGAAyG;IACzG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,OAAO,2BAA2B,EAAE,mBAAmB,EAAE,CAAC;IACjF;;;;;OAKG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxD;;;;;;;;;;OAUG;IACH,mBAAmB,CAAC,EAAE,CACpB,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,eAAe,EAAE,OAAO,KACrB,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACnC;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA+V1F"}
|
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
|
-
}
|
package/src/server/logger.ts
CHANGED
|
@@ -67,6 +67,8 @@ export function logRequestCompleted(data: {
|
|
|
67
67
|
path: string;
|
|
68
68
|
status: number;
|
|
69
69
|
durationMs: number;
|
|
70
|
+
/** Number of concurrent in-flight requests (including this one) at completion time. */
|
|
71
|
+
concurrency?: number;
|
|
70
72
|
}): void {
|
|
71
73
|
_logger?.info('request completed', withTraceContext(data));
|
|
72
74
|
}
|
|
@@ -82,6 +84,8 @@ export function logSlowRequest(data: {
|
|
|
82
84
|
path: string;
|
|
83
85
|
durationMs: number;
|
|
84
86
|
threshold: number;
|
|
87
|
+
/** Number of concurrent in-flight requests at the time the slow request completed. */
|
|
88
|
+
concurrency?: number;
|
|
85
89
|
}): void {
|
|
86
90
|
_logger?.warn('slow request exceeded threshold', withTraceContext(data));
|
|
87
91
|
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -173,11 +173,16 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
173
173
|
onPipelineError,
|
|
174
174
|
} = config;
|
|
175
175
|
|
|
176
|
+
// Concurrent request counter — tracks how many requests are in-flight.
|
|
177
|
+
// Logged with each request for diagnosing resource contention.
|
|
178
|
+
let activeRequests = 0;
|
|
179
|
+
|
|
176
180
|
return async (req: Request): Promise<Response> => {
|
|
177
181
|
const url = new URL(req.url);
|
|
178
182
|
const method = req.method;
|
|
179
183
|
const path = url.pathname;
|
|
180
184
|
const startTime = performance.now();
|
|
185
|
+
activeRequests++;
|
|
181
186
|
|
|
182
187
|
// Establish per-request trace ID scope (design/17-logging.md §"trace_id is Always Set").
|
|
183
188
|
// This runs before runWithRequestContext so traceId() is available from the
|
|
@@ -215,9 +220,9 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
215
220
|
// DevSpanProcessor reads this for tree/summary output.
|
|
216
221
|
await setSpanAttribute('http.response.status_code', result.status);
|
|
217
222
|
|
|
218
|
-
// Append Server-Timing header
|
|
219
|
-
//
|
|
220
|
-
//
|
|
223
|
+
// Append Server-Timing header.
|
|
224
|
+
// In dev mode: detailed per-phase breakdown (proxy, middleware, render).
|
|
225
|
+
// In production: single total duration — safe to expose, no phase names.
|
|
221
226
|
// Response.redirect() creates immutable headers, so we must
|
|
222
227
|
// ensure mutability before writing Server-Timing.
|
|
223
228
|
if (enableServerTiming) {
|
|
@@ -226,6 +231,13 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
226
231
|
result = ensureMutableResponse(result);
|
|
227
232
|
result.headers.set('Server-Timing', serverTiming);
|
|
228
233
|
}
|
|
234
|
+
} else {
|
|
235
|
+
// Production: emit total request duration only.
|
|
236
|
+
// No phase breakdown — prevents information disclosure
|
|
237
|
+
// while giving browser DevTools useful timing data.
|
|
238
|
+
const totalMs = Math.round(performance.now() - startTime);
|
|
239
|
+
result = ensureMutableResponse(result);
|
|
240
|
+
result.headers.set('Server-Timing', `total;dur=${totalMs}`);
|
|
229
241
|
}
|
|
230
242
|
|
|
231
243
|
return result;
|
|
@@ -235,10 +247,12 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
235
247
|
// Post-span: structured production logging
|
|
236
248
|
const durationMs = Math.round(performance.now() - startTime);
|
|
237
249
|
const status = response.status;
|
|
238
|
-
|
|
250
|
+
const concurrency = activeRequests;
|
|
251
|
+
activeRequests--;
|
|
252
|
+
logRequestCompleted({ method, path, status, durationMs, concurrency });
|
|
239
253
|
|
|
240
254
|
if (slowRequestMs > 0 && durationMs > slowRequestMs) {
|
|
241
|
-
logSlowRequest({ method, path, durationMs, threshold: slowRequestMs });
|
|
255
|
+
logSlowRequest({ method, path, durationMs, threshold: slowRequestMs, concurrency });
|
|
242
256
|
}
|
|
243
257
|
|
|
244
258
|
return response;
|
|
@@ -474,6 +488,16 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
474
488
|
markResponseFlushed();
|
|
475
489
|
return response;
|
|
476
490
|
} catch (error) {
|
|
491
|
+
// DenySignal leaked from render (e.g. notFound() in metadata()).
|
|
492
|
+
// Return the deny status code instead of 500.
|
|
493
|
+
if (error instanceof DenySignal) {
|
|
494
|
+
return new Response(null, { status: error.status });
|
|
495
|
+
}
|
|
496
|
+
// RedirectSignal leaked from render — honour the redirect.
|
|
497
|
+
if (error instanceof RedirectSignal) {
|
|
498
|
+
responseHeaders.set('Location', error.location);
|
|
499
|
+
return new Response(null, { status: error.status, headers: responseHeaders });
|
|
500
|
+
}
|
|
477
501
|
logRenderError({ method, path, error });
|
|
478
502
|
await fireOnRequestError(error, req, 'render');
|
|
479
503
|
if (onPipelineError && error instanceof Error) onPipelineError(error, 'render');
|