@wooksjs/event-http 0.7.27 → 0.7.28

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/index.cjs CHANGED
@@ -747,6 +747,120 @@ function useHttpContext(ctx) {
747
747
  return ctx ?? (0, _wooksjs_event_core.current)();
748
748
  }
749
749
 
750
+ //#endregion
751
+ //#region packages/event-http/src/response/compression.ts
752
+ const COMPRESSIBLE_TYPE = /^(?:text\/(?!event-stream)|image\/svg\+xml|application\/(?:json|javascript|x-javascript|ecmascript|xml|xhtml\+xml|graphql|x-ndjson|ndjson|wasm|x-www-form-urlencoded)\b)|\+(?:json|xml)\b/i;
753
+ /**
754
+ * Default compression filter: `text/*` (except `text/event-stream`), JSON and `+json`, XML and
755
+ * `+xml`, JavaScript, SVG, NDJSON, WASM and form-urlencoded content types.
756
+ */
757
+ function isCompressibleType(contentType) {
758
+ return COMPRESSIBLE_TYPE.test(contentType);
759
+ }
760
+ const DEFAULTS = {
761
+ threshold: 1024,
762
+ encodings: ["br", "gzip"],
763
+ brotliQuality: 4,
764
+ gzipLevel: 6,
765
+ filter: isCompressibleType
766
+ };
767
+ /**
768
+ * @internal Resolves the `compression` option. `base` supplies the values `value` does not set
769
+ * (the app settings for a per-response override). Returns `undefined` when compression is off.
770
+ */
771
+ function resolveCompression(value, base = DEFAULTS) {
772
+ if (!value) return;
773
+ if (value === true) return base;
774
+ return {
775
+ threshold: value.threshold ?? base.threshold,
776
+ encodings: value.encodings ?? base.encodings,
777
+ brotliQuality: value.brotliQuality ?? base.brotliQuality,
778
+ gzipLevel: value.gzipLevel ?? base.gzipLevel,
779
+ filter: value.filter ?? base.filter
780
+ };
781
+ }
782
+ /**
783
+ * Picks the content coding for a response from the request's `Accept-Encoding` header.
784
+ *
785
+ * The coding with the highest client q-value wins; `supported` order breaks ties. `q=0`
786
+ * excludes a coding, `*` matches every coding not listed explicitly. Returns `undefined` when
787
+ * the header is missing or none of `supported` is acceptable — send the body uncompressed then.
788
+ *
789
+ * @example
790
+ * ```ts
791
+ * negotiateEncoding('gzip, deflate, br', ['br', 'gzip']) // 'br'
792
+ * negotiateEncoding('br;q=0.5, gzip', ['br', 'gzip']) // 'gzip'
793
+ * negotiateEncoding('*;q=0, identity', ['br', 'gzip']) // undefined
794
+ * ```
795
+ */
796
+ function negotiateEncoding(acceptEncoding, supported) {
797
+ if (!acceptEncoding) return;
798
+ const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(",") : acceptEncoding;
799
+ const listed = /* @__PURE__ */ new Map();
800
+ let wildcard;
801
+ for (const part of header.split(",")) {
802
+ const semi = part.indexOf(";");
803
+ const name = (semi === -1 ? part : part.slice(0, semi)).trim().toLowerCase();
804
+ if (!name) continue;
805
+ const q = semi === -1 ? 1 : parseQ(part.slice(semi + 1));
806
+ if (name === "*") wildcard = q;
807
+ else listed.set(name, q);
808
+ }
809
+ let best;
810
+ let bestQ = 0;
811
+ for (const enc of supported) {
812
+ const q = listed.get(enc) ?? wildcard ?? 0;
813
+ if (q > bestQ) {
814
+ best = enc;
815
+ bestQ = q;
816
+ }
817
+ }
818
+ return best;
819
+ }
820
+ function parseQ(params) {
821
+ for (const param of params.split(";")) {
822
+ const eq = param.indexOf("=");
823
+ if (eq !== -1 && param.slice(0, eq).trim().toLowerCase() === "q") {
824
+ const q = Number(param.slice(eq + 1).trim());
825
+ return Number.isFinite(q) && q > 0 ? Math.min(q, 1) : 0;
826
+ }
827
+ }
828
+ return 1;
829
+ }
830
+ /** @internal Compresses a rendered body of `size` bytes with the given coding (async, libuv threadpool). */
831
+ function compressResponseBody(body, size, encoding, opts) {
832
+ return new Promise((resolve, reject) => {
833
+ const done = (error, result) => {
834
+ if (error) reject(error);
835
+ else resolve(result);
836
+ };
837
+ if (encoding === "br") (0, node_zlib.brotliCompress)(body, { params: {
838
+ [node_zlib.constants.BROTLI_PARAM_QUALITY]: opts.brotliQuality,
839
+ [node_zlib.constants.BROTLI_PARAM_MODE]: node_zlib.constants.BROTLI_MODE_TEXT,
840
+ [node_zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
841
+ } }, done);
842
+ else (0, node_zlib.gzip)(body, { level: opts.gzipLevel }, done);
843
+ });
844
+ }
845
+ /** @internal Cache key for compressed bytes of a prerendered body (coding + level). */
846
+ function compressionCacheKey(encoding, opts) {
847
+ return encoding === "br" ? `br${opts.brotliQuality}` : `gzip${opts.gzipLevel}`;
848
+ }
849
+ /**
850
+ * @internal Appends `token` to a `Vary` header value unless it (or `*`) is already listed.
851
+ * Returns `undefined` when nothing has to change.
852
+ */
853
+ function mergeVary(existing, token) {
854
+ if (!existing || existing.length === 0) return token;
855
+ const value = Array.isArray(existing) ? existing.join(", ") : existing;
856
+ const lower = token.toLowerCase();
857
+ for (const part of value.split(",")) {
858
+ const name = part.trim().toLowerCase();
859
+ if (name === lower || name === "*") return;
860
+ }
861
+ return value.trim() ? `${value}, ${token}` : token;
862
+ }
863
+
750
864
  //#endregion
751
865
  //#region packages/event-http/src/utils/time.ts
752
866
  function convertTime(time, unit = "ms") {
@@ -916,8 +1030,10 @@ var HttpResponse = class {
916
1030
  * @param _req - The underlying Node.js `IncomingMessage`.
917
1031
  * @param _logger - Logger instance for error reporting.
918
1032
  * @param defaultHeaders - Optional headers to pre-populate on this response (e.g. from `securityHeaders()`).
1033
+ * @param _captureMode - Finalize state on `send()` without writing to `_res` (programmatic fetch).
1034
+ * @param compression - App-level response compression settings (`undefined` = off).
919
1035
  */
920
- constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
1036
+ constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
921
1037
  this._res = _res;
922
1038
  this._req = _req;
923
1039
  this._logger = _logger;
@@ -927,6 +1043,7 @@ var HttpResponse = class {
927
1043
  this._headers = {};
928
1044
  this._hasCookies = false;
929
1045
  this._responded = false;
1046
+ this._compression = compression;
930
1047
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
931
1048
  }
932
1049
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
@@ -1059,6 +1176,24 @@ var HttpResponse = class {
1059
1176
  return this;
1060
1177
  }
1061
1178
  /**
1179
+ * Overrides response compression for this response (chainable).
1180
+ *
1181
+ * - `false` — never compress this response (e.g. a body that mixes secrets with reflected input).
1182
+ * - `true` — compress with the app settings, or the defaults when the app has compression off.
1183
+ * - an options object — compress with these settings over the app settings (or the defaults).
1184
+ *
1185
+ * Only regular bodies (strings, numbers, booleans, objects, `Uint8Array`) are compressed; streams
1186
+ * and fetch `Response` bodies are sent as is.
1187
+ */
1188
+ setCompression(value) {
1189
+ this._compression = value === true ? this._compression ?? resolveCompression(true) : resolveCompression(value, this._compression);
1190
+ return this;
1191
+ }
1192
+ /** Effective compression settings for this response, or `false` when compression is off. */
1193
+ get compression() {
1194
+ return this._compression ?? false;
1195
+ }
1196
+ /**
1062
1197
  * Returns the underlying Node.js `ServerResponse`.
1063
1198
  * @param passthrough - If `true`, the framework still manages the response lifecycle. If `false` (default), the response is marked as "responded" and the framework will not touch it.
1064
1199
  */
@@ -1096,15 +1231,13 @@ var HttpResponse = class {
1096
1231
  });
1097
1232
  }
1098
1233
  const rendered = this.renderBody();
1234
+ const prerendered = this.takePrerendered();
1099
1235
  this.autoStatus(!!rendered);
1100
- if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
1236
+ if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
1101
1237
  status: this._status,
1102
1238
  headers: this._buildWebHeaders()
1103
1239
  });
1104
- if (rendered) {
1105
- const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
1106
- this._headers["content-length"] = contentLength.toString();
1107
- }
1240
+ if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
1108
1241
  const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
1109
1242
  const webResponse = new globalThis.Response(webBody, {
1110
1243
  status: this._status,
@@ -1151,7 +1284,7 @@ var HttpResponse = class {
1151
1284
  if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
1152
1285
  const prerendered = getPrerenderedJson(body);
1153
1286
  if (prerendered) {
1154
- this._prerenderedEtag = prerendered.etag;
1287
+ this._prerendered = prerendered;
1155
1288
  return prerendered.json;
1156
1289
  }
1157
1290
  return JSON.stringify(body);
@@ -1174,6 +1307,7 @@ var HttpResponse = class {
1174
1307
  *
1175
1308
  * Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
1176
1309
  * then writes the body. Supports `Readable` streams, `fetch` `Response` objects, and regular values.
1310
+ * Returns a Promise (which never rejects) for streamed bodies and compressed regular bodies.
1177
1311
  *
1178
1312
  * @throws Error if the response was already sent.
1179
1313
  */
@@ -1193,7 +1327,7 @@ var HttpResponse = class {
1193
1327
  const method = this._req.method;
1194
1328
  if (body instanceof stream.Readable) return this.sendStream(body, method);
1195
1329
  if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
1196
- this.sendRegular(method);
1330
+ return this.sendRegular(method);
1197
1331
  }
1198
1332
  finalizeCookies() {
1199
1333
  if (!this._hasCookies) return;
@@ -1211,12 +1345,9 @@ var HttpResponse = class {
1211
1345
  * matches into a bodiless `304`. Returns `true` when the response became `304`.
1212
1346
  * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
1213
1347
  */
1214
- applyPrerenderedEtag() {
1215
- const etag = this._prerenderedEtag;
1216
- if (!etag) return false;
1217
- this._prerenderedEtag = void 0;
1348
+ applyPrerenderedEtag(etag) {
1218
1349
  const method = this._req.method;
1219
- if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.hasHeaderIgnoreCase("etag")) return false;
1350
+ if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
1220
1351
  this._headers.etag = etag;
1221
1352
  if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
1222
1353
  this._status = EHttpStatusCode.NotModified;
@@ -1226,9 +1357,16 @@ var HttpResponse = class {
1226
1357
  }
1227
1358
  return true;
1228
1359
  }
1229
- hasHeaderIgnoreCase(name) {
1230
- for (const key in this._headers) if (key.toLowerCase() === name) return true;
1231
- return false;
1360
+ /** Returns the stored key of header `name` (lower-case) in whatever casing it was set with. */
1361
+ headerKeyIgnoreCase(name) {
1362
+ if (name in this._headers) return name;
1363
+ for (const key in this._headers) if (key.toLowerCase() === name) return key;
1364
+ }
1365
+ /** Returns and clears the prerender entry picked by the last `renderBody()`. */
1366
+ takePrerendered() {
1367
+ const prerendered = this._prerendered;
1368
+ this._prerendered = void 0;
1369
+ return prerendered;
1232
1370
  }
1233
1371
  autoStatus(hasBody) {
1234
1372
  if (this._status) return;
@@ -1275,17 +1413,101 @@ var HttpResponse = class {
1275
1413
  return this.pipeToResponse(stream.Readable.fromWeb(fetchResponse.body), "Error streaming fetch response body");
1276
1414
  }
1277
1415
  sendRegular(method) {
1278
- const renderedBody = this.renderBody();
1279
- this.autoStatus(!!renderedBody);
1280
- if (this.applyPrerenderedEtag()) {
1416
+ const body = this.renderBody();
1417
+ const prerendered = this.takePrerendered();
1418
+ const size = renderedSize(body, prerendered);
1419
+ this.autoStatus(!!body);
1420
+ const compression = this._compression && this.isCompressible(size, this._compression) ? this._compression : void 0;
1421
+ if (compression) this.appendVary("Accept-Encoding");
1422
+ if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) {
1281
1423
  this._res.writeHead(this._status, this._headers).end();
1282
1424
  return;
1283
1425
  }
1284
- const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1285
- this._headers["content-length"] = contentLength.toString();
1286
- this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
1426
+ if (compression && method !== "HEAD") {
1427
+ const encoding = negotiateEncoding(this._req.headers["accept-encoding"], compression.encodings);
1428
+ if (encoding) return this.sendCompressed(body, size, encoding, compression, prerendered);
1429
+ }
1430
+ this._headers["content-length"] = size.toString();
1431
+ this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : body);
1432
+ }
1433
+ /**
1434
+ * Whether a rendered regular body of `size` bytes may be compressed — independent of the
1435
+ * request's `Accept-Encoding` (and of HEAD, which is answered uncompressed).
1436
+ */
1437
+ isCompressible(size, opts) {
1438
+ const status = this._status;
1439
+ if (!size || size < opts.threshold || status < 200 || status === 204 || status === 206 || status === 304) return false;
1440
+ let contentType;
1441
+ for (const key in this._headers) {
1442
+ const lower = key.toLowerCase();
1443
+ if (lower === "content-type") contentType = this._headers[key];
1444
+ else if (lower === "content-encoding") return false;
1445
+ else if (lower === "cache-control" && /no-transform/i.test(String(this._headers[key]))) return false;
1446
+ }
1447
+ return typeof contentType === "string" && opts.filter(contentType, this);
1448
+ }
1449
+ /** Adds `token` to the `Vary` header (case-insensitive merge, keeps existing entries). */
1450
+ appendVary(token) {
1451
+ const key = this.headerKeyIgnoreCase("vary") ?? "vary";
1452
+ const merged = mergeVary(this._headers[key], token);
1453
+ if (merged !== void 0) this._headers[key] = merged;
1454
+ }
1455
+ /**
1456
+ * Sends the body compressed with `encoding`. Prerendered bodies are compressed once per coding
1457
+ * and level and the bytes reused (sent synchronously once ready). A compressor failure is
1458
+ * logged and the body is sent uncompressed — headers are not written yet, so that is safe.
1459
+ * Never rejects: a sync handler's `send()` is not awaited.
1460
+ */
1461
+ sendCompressed(body, size, encoding, opts, prerendered) {
1462
+ let pending;
1463
+ if (prerendered?.json === body) {
1464
+ const cache = prerendered.compressed ??= {};
1465
+ const cacheKey = compressionCacheKey(encoding, opts);
1466
+ let entry = cache[cacheKey];
1467
+ if (!entry) {
1468
+ const promise = compressResponseBody(body, size, encoding, opts);
1469
+ promise.then((bytes) => cache[cacheKey] = bytes, () => delete cache[cacheKey]);
1470
+ entry = cache[cacheKey] = promise;
1471
+ }
1472
+ pending = entry;
1473
+ } else pending = compressResponseBody(body, size, encoding, opts);
1474
+ if (Buffer.isBuffer(pending)) {
1475
+ this.writeDeferred(pending, pending.byteLength, encoding);
1476
+ return;
1477
+ }
1478
+ return pending.then((bytes) => this.writeDeferred(bytes, bytes.byteLength, encoding), (error) => {
1479
+ this._logger.error("Response compression failed, sending uncompressed body", error);
1480
+ this.writeDeferred(body, size);
1481
+ }).catch((error) => {
1482
+ this._logger.error("Failed to send compressed response", error);
1483
+ });
1484
+ }
1485
+ /**
1486
+ * Writes the body picked by `sendCompressed()` — the `encoding`-compressed bytes, or the identity
1487
+ * body after a compressor failure — unless the client went away meanwhile.
1488
+ */
1489
+ writeDeferred(body, length, encoding) {
1490
+ if (this._res.destroyed) return;
1491
+ if (encoding) {
1492
+ this._headers["content-encoding"] = encoding;
1493
+ for (const key in this._headers) {
1494
+ const lower = key.toLowerCase();
1495
+ if (lower === "content-length" && key !== lower) delete this._headers[key];
1496
+ else if (lower === "etag") {
1497
+ const etag = this._headers[key];
1498
+ if (typeof etag === "string" && etag && !etag.startsWith("W/")) this._headers[key] = `W/${etag}`;
1499
+ }
1500
+ }
1501
+ }
1502
+ this._headers["content-length"] = length.toString();
1503
+ this._res.writeHead(this._status, this._headers).end(body);
1287
1504
  }
1288
1505
  };
1506
+ /** UTF-8 size of a rendered body — computed once per registration for a prerendered body. */
1507
+ function renderedSize(body, prerendered) {
1508
+ if (typeof body !== "string") return body.byteLength;
1509
+ return prerendered?.json === body ? prerendered.size ??= Buffer.byteLength(body) : Buffer.byteLength(body);
1510
+ }
1289
1511
  /** Converts a Record of headers to a Web Standard `Headers` object. */
1290
1512
  function recordToWebHeaders(record) {
1291
1513
  const headers = new Headers();
@@ -1659,7 +1881,7 @@ function escapeHtml(value) {
1659
1881
  //#endregion
1660
1882
  //#region packages/event-http/src/response/wooks-http-response.ts
1661
1883
  let framework = {
1662
- version: "0.7.27",
1884
+ version: "0.7.28",
1663
1885
  poweredBy: "wooksjs",
1664
1886
  link: "https://wooks.moost.org/",
1665
1887
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1755,6 +1977,7 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1755
1977
  this.logger = opts?.logger || this.getLogger(`[wooks-http]`);
1756
1978
  this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
1757
1979
  this.eventContextOptions = this.getEventContextOptions();
1980
+ this.compression = resolveCompression(opts?.compression);
1758
1981
  }
1759
1982
  /** Registers a handler for all HTTP methods on the given path. */
1760
1983
  all(path, handler) {
@@ -1877,8 +2100,9 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1877
2100
  const RequestLimits = this.opts?.requestLimits;
1878
2101
  const notFoundHandler = this.opts?.onNotFound;
1879
2102
  const defaultHeaders = this.opts?.defaultHeaders;
2103
+ const compression = this.compression;
1880
2104
  return (req, res) => {
1881
- const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
2105
+ const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
1882
2106
  const method = req.method || "";
1883
2107
  const url = req.url || "";
1884
2108
  createHttpContext(ctxOptions, {
@@ -2277,6 +2501,8 @@ exports.createHttpApp = createHttpApp;
2277
2501
  exports.createHttpContext = createHttpContext;
2278
2502
  exports.httpKind = httpKind;
2279
2503
  exports.httpStatusCodes = httpStatusCodes;
2504
+ exports.isCompressibleType = isCompressibleType;
2505
+ exports.negotiateEncoding = negotiateEncoding;
2280
2506
  exports.prepareTestHttpContext = prepareTestHttpContext;
2281
2507
  exports.prerenderJson = prerenderJson;
2282
2508
  exports.rawBodySlot = rawBodySlot;
package/dist/index.d.ts CHANGED
@@ -347,6 +347,60 @@ interface TCacheControl {
347
347
  /** Renders a `TCacheControl` object into a `Cache-Control` header string. */
348
348
  declare function renderCacheControl(data: TCacheControl): string;
349
349
 
350
+ /** Content codings supported by response compression, in the order they are listed by default. */
351
+ type THttpCompressionEncoding = 'br' | 'gzip';
352
+ /**
353
+ * Response compression settings — the `compression` option of `createHttpApp()` and the
354
+ * argument of `HttpResponse.setCompression()`.
355
+ */
356
+ interface THttpCompressionOptions {
357
+ /** Minimum body size in bytes to compress. Smaller bodies are sent as is. @default 1024 */
358
+ threshold?: number;
359
+ /**
360
+ * Codings the server may use, in preference order. The client's `Accept-Encoding` q-values
361
+ * decide first; this order breaks ties. @default ['br', 'gzip']
362
+ */
363
+ encodings?: THttpCompressionEncoding[];
364
+ /** Brotli quality (0–11). Higher is smaller and much slower — keep 4–5 for dynamic bodies. @default 4 */
365
+ brotliQuality?: number;
366
+ /** Gzip level (1–9). @default 6 */
367
+ gzipLevel?: number;
368
+ /**
369
+ * Decides whether a response with this content type may be compressed.
370
+ * Replaces the default check — call `isCompressibleType(contentType)` inside to extend it.
371
+ * @default isCompressibleType
372
+ */
373
+ filter?: (contentType: string, response: HttpResponse) => boolean;
374
+ }
375
+ /** Fully resolved compression settings (what `HttpResponse.compression` returns). */
376
+ interface TResolvedHttpCompression {
377
+ threshold: number;
378
+ encodings: THttpCompressionEncoding[];
379
+ brotliQuality: number;
380
+ gzipLevel: number;
381
+ filter: (contentType: string, response: HttpResponse) => boolean;
382
+ }
383
+ /**
384
+ * Default compression filter: `text/*` (except `text/event-stream`), JSON and `+json`, XML and
385
+ * `+xml`, JavaScript, SVG, NDJSON, WASM and form-urlencoded content types.
386
+ */
387
+ declare function isCompressibleType(contentType: string): boolean;
388
+ /**
389
+ * Picks the content coding for a response from the request's `Accept-Encoding` header.
390
+ *
391
+ * The coding with the highest client q-value wins; `supported` order breaks ties. `q=0`
392
+ * excludes a coding, `*` matches every coding not listed explicitly. Returns `undefined` when
393
+ * the header is missing or none of `supported` is acceptable — send the body uncompressed then.
394
+ *
395
+ * @example
396
+ * ```ts
397
+ * negotiateEncoding('gzip, deflate, br', ['br', 'gzip']) // 'br'
398
+ * negotiateEncoding('br;q=0.5, gzip', ['br', 'gzip']) // 'gzip'
399
+ * negotiateEncoding('*;q=0, identity', ['br', 'gzip']) // undefined
400
+ * ```
401
+ */
402
+ declare function negotiateEncoding<T extends string>(acceptEncoding: string | string[] | undefined, supported: readonly T[]): T | undefined;
403
+
350
404
  /**
351
405
  * Manages response status, headers, cookies, cache control, and body for an HTTP request.
352
406
  *
@@ -370,8 +424,10 @@ declare class HttpResponse {
370
424
  * @param _req - The underlying Node.js `IncomingMessage`.
371
425
  * @param _logger - Logger instance for error reporting.
372
426
  * @param defaultHeaders - Optional headers to pre-populate on this response (e.g. from `securityHeaders()`).
427
+ * @param _captureMode - Finalize state on `send()` without writing to `_res` (programmatic fetch).
428
+ * @param compression - App-level response compression settings (`undefined` = off).
373
429
  */
374
- constructor(_res: ServerResponse, _req: IncomingMessage, _logger: Logger, defaultHeaders?: Record<string, string | string[]>, _captureMode?: boolean);
430
+ constructor(_res: ServerResponse, _req: IncomingMessage, _logger: Logger, defaultHeaders?: Record<string, string | string[]>, _captureMode?: boolean, compression?: TResolvedHttpCompression);
375
431
  protected _status: EHttpStatusCode;
376
432
  protected _body: unknown;
377
433
  protected _headers: Record<string, string | string[]>;
@@ -381,8 +437,10 @@ declare class HttpResponse {
381
437
  protected _rawCookies?: string[];
382
438
  protected _hasCookies: boolean;
383
439
  protected _responded: boolean;
384
- /** Weak ETag of the prerendered body picked by the last `renderBody()` (see `prerenderJson`). */
385
- private _prerenderedEtag?;
440
+ /** Registry entry of the prerendered body picked by the last `renderBody()` (see `prerenderJson`). */
441
+ private _prerendered?;
442
+ /** Effective compression settings for this response (`undefined` = off). */
443
+ protected _compression: TResolvedHttpCompression | undefined;
386
444
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
387
445
  get status(): EHttpStatusCode;
388
446
  set status(value: EHttpStatusCode);
@@ -437,6 +495,19 @@ declare class HttpResponse {
437
495
  setExpires(value: Date | string | number): this;
438
496
  /** Sets or clears the `Pragma: no-cache` header (chainable). */
439
497
  setPragmaNoCache(value?: boolean): this;
498
+ /**
499
+ * Overrides response compression for this response (chainable).
500
+ *
501
+ * - `false` — never compress this response (e.g. a body that mixes secrets with reflected input).
502
+ * - `true` — compress with the app settings, or the defaults when the app has compression off.
503
+ * - an options object — compress with these settings over the app settings (or the defaults).
504
+ *
505
+ * Only regular bodies (strings, numbers, booleans, objects, `Uint8Array`) are compressed; streams
506
+ * and fetch `Response` bodies are sent as is.
507
+ */
508
+ setCompression(value: boolean | THttpCompressionOptions): this;
509
+ /** Effective compression settings for this response, or `false` when compression is off. */
510
+ get compression(): Readonly<TResolvedHttpCompression> | false;
440
511
  /**
441
512
  * Returns the underlying Node.js `ServerResponse`.
442
513
  * @param passthrough - If `true`, the framework still manages the response lifecycle. If `false` (default), the response is marked as "responded" and the framework will not touch it.
@@ -467,6 +538,7 @@ declare class HttpResponse {
467
538
  *
468
539
  * Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
469
540
  * then writes the body. Supports `Readable` streams, `fetch` `Response` objects, and regular values.
541
+ * Returns a Promise (which never rejects) for streamed bodies and compressed regular bodies.
470
542
  *
471
543
  * @throws Error if the response was already sent.
472
544
  */
@@ -479,7 +551,10 @@ declare class HttpResponse {
479
551
  * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
480
552
  */
481
553
  private applyPrerenderedEtag;
482
- private hasHeaderIgnoreCase;
554
+ /** Returns the stored key of header `name` (lower-case) in whatever casing it was set with. */
555
+ private headerKeyIgnoreCase;
556
+ /** Returns and clears the prerender entry picked by the last `renderBody()`. */
557
+ private takePrerendered;
483
558
  private autoStatus;
484
559
  private sendStream;
485
560
  /**
@@ -493,6 +568,25 @@ declare class HttpResponse {
493
568
  private pipeToResponse;
494
569
  private sendFetchResponse;
495
570
  private sendRegular;
571
+ /**
572
+ * Whether a rendered regular body of `size` bytes may be compressed — independent of the
573
+ * request's `Accept-Encoding` (and of HEAD, which is answered uncompressed).
574
+ */
575
+ private isCompressible;
576
+ /** Adds `token` to the `Vary` header (case-insensitive merge, keeps existing entries). */
577
+ private appendVary;
578
+ /**
579
+ * Sends the body compressed with `encoding`. Prerendered bodies are compressed once per coding
580
+ * and level and the bytes reused (sent synchronously once ready). A compressor failure is
581
+ * logged and the body is sent uncompressed — headers are not written yet, so that is safe.
582
+ * Never rejects: a sync handler's `send()` is not awaited.
583
+ */
584
+ private sendCompressed;
585
+ /**
586
+ * Writes the body picked by `sendCompressed()` — the `encoding`-compressed bytes, or the identity
587
+ * body after a compressor failure — unless the client went away meanwhile.
588
+ */
589
+ private writeDeferred;
496
590
  }
497
591
  /** Converts a Record of headers to a Web Standard `Headers` object. */
498
592
  declare function recordToWebHeaders(record: Record<string, string | string[]>): Headers;
@@ -595,6 +689,18 @@ interface TWooksHttpOptions {
595
689
  * @default DEFAULT_FORWARD_HEADERS — ['authorization', 'cookie', 'accept-language', 'x-forwarded-for', 'x-request-id']
596
690
  */
597
691
  forwardHeaders?: string[] | false;
692
+ /**
693
+ * Compresses regular response bodies (JSON, text, HTML, …) with brotli or gzip, negotiated
694
+ * from the request's `Accept-Encoding`. Off by default. `true` uses the defaults
695
+ * (`threshold: 1024`, `encodings: ['br', 'gzip']`, `brotliQuality: 4`, `gzipLevel: 6`);
696
+ * an object overrides them. Streams, fetch `Response` bodies and programmatic `fetch()`
697
+ * responses are never compressed. Opt a single response out with
698
+ * `useResponse().setCompression(false)`.
699
+ *
700
+ * Do not enable it for responses that mix secrets with attacker-controlled input (BREACH).
701
+ * @default false
702
+ */
703
+ compression?: boolean | THttpCompressionOptions;
598
704
  }
599
705
  /** HTTP adapter for Wooks that provides route registration, server lifecycle, and request handling. */
600
706
  declare class WooksHttp extends WooksAdapterBase {
@@ -602,6 +708,7 @@ declare class WooksHttp extends WooksAdapterBase {
602
708
  protected logger: TConsoleBase;
603
709
  protected ResponseClass: typeof WooksHttpResponse;
604
710
  protected eventContextOptions: EventContextOptions;
711
+ protected compression: TResolvedHttpCompression | undefined;
605
712
  constructor(opts?: TWooksHttpOptions | undefined, wooks?: Wooks | WooksAdapterBase);
606
713
  /** Registers a handler for all HTTP methods on the given path. */
607
714
  all<ResType = unknown, ParamsType = Record<string, string | string[]>>(path: string, handler: TWooksHandler<ResType>): wooks.TProstoRouterPathHandle<ParamsType>;
@@ -848,5 +955,5 @@ interface TTestHttpContext {
848
955
  */
849
956
  declare function prepareTestHttpContext(options: TTestHttpContext): <T>(cb: (...a: any[]) => T) => T;
850
957
 
851
- export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useRequest, useResponse, useUrlParams };
852
- export type { KnownAcceptType, KnownAuthType, SecurityHeadersOptions, TCacheControl, TCookieAttributes, TCookieAttributesInput, THttpEventData, TPrerenderJsonOptions, TRequestLimits, TSetCookieData, TTestHttpContext, TWooksErrorBody, TWooksErrorBodyExt, TWooksHttpOptions };
958
+ export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, isCompressibleType, negotiateEncoding, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useRequest, useResponse, useUrlParams };
959
+ export type { KnownAcceptType, KnownAuthType, SecurityHeadersOptions, TCacheControl, TCookieAttributes, TCookieAttributesInput, THttpCompressionEncoding, THttpCompressionOptions, THttpEventData, TPrerenderJsonOptions, TRequestLimits, TResolvedHttpCompression, TSetCookieData, TTestHttpContext, TWooksErrorBody, TWooksErrorBodyExt, TWooksHttpOptions };
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@ import { EventContext, cached, cachedBy, createEventContext, current, defineEven
2
2
  import { Buffer as Buffer$1 } from "buffer";
3
3
  import { Readable, pipeline } from "node:stream";
4
4
  import { promisify } from "node:util";
5
- import { createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate } from "node:zlib";
5
+ import { brotliCompress, constants, createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate, gzip } from "node:zlib";
6
6
  import { URLSearchParams } from "url";
7
7
  import http, { IncomingMessage, ServerResponse } from "http";
8
8
  import { Duplex, Readable as Readable$1, pipeline as pipeline$1 } from "stream";
@@ -718,6 +718,120 @@ function useHttpContext(ctx) {
718
718
  return ctx ?? current();
719
719
  }
720
720
 
721
+ //#endregion
722
+ //#region packages/event-http/src/response/compression.ts
723
+ const COMPRESSIBLE_TYPE = /^(?:text\/(?!event-stream)|image\/svg\+xml|application\/(?:json|javascript|x-javascript|ecmascript|xml|xhtml\+xml|graphql|x-ndjson|ndjson|wasm|x-www-form-urlencoded)\b)|\+(?:json|xml)\b/i;
724
+ /**
725
+ * Default compression filter: `text/*` (except `text/event-stream`), JSON and `+json`, XML and
726
+ * `+xml`, JavaScript, SVG, NDJSON, WASM and form-urlencoded content types.
727
+ */
728
+ function isCompressibleType(contentType) {
729
+ return COMPRESSIBLE_TYPE.test(contentType);
730
+ }
731
+ const DEFAULTS = {
732
+ threshold: 1024,
733
+ encodings: ["br", "gzip"],
734
+ brotliQuality: 4,
735
+ gzipLevel: 6,
736
+ filter: isCompressibleType
737
+ };
738
+ /**
739
+ * @internal Resolves the `compression` option. `base` supplies the values `value` does not set
740
+ * (the app settings for a per-response override). Returns `undefined` when compression is off.
741
+ */
742
+ function resolveCompression(value, base = DEFAULTS) {
743
+ if (!value) return;
744
+ if (value === true) return base;
745
+ return {
746
+ threshold: value.threshold ?? base.threshold,
747
+ encodings: value.encodings ?? base.encodings,
748
+ brotliQuality: value.brotliQuality ?? base.brotliQuality,
749
+ gzipLevel: value.gzipLevel ?? base.gzipLevel,
750
+ filter: value.filter ?? base.filter
751
+ };
752
+ }
753
+ /**
754
+ * Picks the content coding for a response from the request's `Accept-Encoding` header.
755
+ *
756
+ * The coding with the highest client q-value wins; `supported` order breaks ties. `q=0`
757
+ * excludes a coding, `*` matches every coding not listed explicitly. Returns `undefined` when
758
+ * the header is missing or none of `supported` is acceptable — send the body uncompressed then.
759
+ *
760
+ * @example
761
+ * ```ts
762
+ * negotiateEncoding('gzip, deflate, br', ['br', 'gzip']) // 'br'
763
+ * negotiateEncoding('br;q=0.5, gzip', ['br', 'gzip']) // 'gzip'
764
+ * negotiateEncoding('*;q=0, identity', ['br', 'gzip']) // undefined
765
+ * ```
766
+ */
767
+ function negotiateEncoding(acceptEncoding, supported) {
768
+ if (!acceptEncoding) return;
769
+ const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(",") : acceptEncoding;
770
+ const listed = /* @__PURE__ */ new Map();
771
+ let wildcard;
772
+ for (const part of header.split(",")) {
773
+ const semi = part.indexOf(";");
774
+ const name = (semi === -1 ? part : part.slice(0, semi)).trim().toLowerCase();
775
+ if (!name) continue;
776
+ const q = semi === -1 ? 1 : parseQ(part.slice(semi + 1));
777
+ if (name === "*") wildcard = q;
778
+ else listed.set(name, q);
779
+ }
780
+ let best;
781
+ let bestQ = 0;
782
+ for (const enc of supported) {
783
+ const q = listed.get(enc) ?? wildcard ?? 0;
784
+ if (q > bestQ) {
785
+ best = enc;
786
+ bestQ = q;
787
+ }
788
+ }
789
+ return best;
790
+ }
791
+ function parseQ(params) {
792
+ for (const param of params.split(";")) {
793
+ const eq = param.indexOf("=");
794
+ if (eq !== -1 && param.slice(0, eq).trim().toLowerCase() === "q") {
795
+ const q = Number(param.slice(eq + 1).trim());
796
+ return Number.isFinite(q) && q > 0 ? Math.min(q, 1) : 0;
797
+ }
798
+ }
799
+ return 1;
800
+ }
801
+ /** @internal Compresses a rendered body of `size` bytes with the given coding (async, libuv threadpool). */
802
+ function compressResponseBody(body, size, encoding, opts) {
803
+ return new Promise((resolve, reject) => {
804
+ const done = (error, result) => {
805
+ if (error) reject(error);
806
+ else resolve(result);
807
+ };
808
+ if (encoding === "br") brotliCompress(body, { params: {
809
+ [constants.BROTLI_PARAM_QUALITY]: opts.brotliQuality,
810
+ [constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
811
+ [constants.BROTLI_PARAM_SIZE_HINT]: size
812
+ } }, done);
813
+ else gzip(body, { level: opts.gzipLevel }, done);
814
+ });
815
+ }
816
+ /** @internal Cache key for compressed bytes of a prerendered body (coding + level). */
817
+ function compressionCacheKey(encoding, opts) {
818
+ return encoding === "br" ? `br${opts.brotliQuality}` : `gzip${opts.gzipLevel}`;
819
+ }
820
+ /**
821
+ * @internal Appends `token` to a `Vary` header value unless it (or `*`) is already listed.
822
+ * Returns `undefined` when nothing has to change.
823
+ */
824
+ function mergeVary(existing, token) {
825
+ if (!existing || existing.length === 0) return token;
826
+ const value = Array.isArray(existing) ? existing.join(", ") : existing;
827
+ const lower = token.toLowerCase();
828
+ for (const part of value.split(",")) {
829
+ const name = part.trim().toLowerCase();
830
+ if (name === lower || name === "*") return;
831
+ }
832
+ return value.trim() ? `${value}, ${token}` : token;
833
+ }
834
+
721
835
  //#endregion
722
836
  //#region packages/event-http/src/utils/time.ts
723
837
  function convertTime(time, unit = "ms") {
@@ -887,8 +1001,10 @@ var HttpResponse = class {
887
1001
  * @param _req - The underlying Node.js `IncomingMessage`.
888
1002
  * @param _logger - Logger instance for error reporting.
889
1003
  * @param defaultHeaders - Optional headers to pre-populate on this response (e.g. from `securityHeaders()`).
1004
+ * @param _captureMode - Finalize state on `send()` without writing to `_res` (programmatic fetch).
1005
+ * @param compression - App-level response compression settings (`undefined` = off).
890
1006
  */
891
- constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
1007
+ constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
892
1008
  this._res = _res;
893
1009
  this._req = _req;
894
1010
  this._logger = _logger;
@@ -898,6 +1014,7 @@ var HttpResponse = class {
898
1014
  this._headers = {};
899
1015
  this._hasCookies = false;
900
1016
  this._responded = false;
1017
+ this._compression = compression;
901
1018
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
902
1019
  }
903
1020
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
@@ -1030,6 +1147,24 @@ var HttpResponse = class {
1030
1147
  return this;
1031
1148
  }
1032
1149
  /**
1150
+ * Overrides response compression for this response (chainable).
1151
+ *
1152
+ * - `false` — never compress this response (e.g. a body that mixes secrets with reflected input).
1153
+ * - `true` — compress with the app settings, or the defaults when the app has compression off.
1154
+ * - an options object — compress with these settings over the app settings (or the defaults).
1155
+ *
1156
+ * Only regular bodies (strings, numbers, booleans, objects, `Uint8Array`) are compressed; streams
1157
+ * and fetch `Response` bodies are sent as is.
1158
+ */
1159
+ setCompression(value) {
1160
+ this._compression = value === true ? this._compression ?? resolveCompression(true) : resolveCompression(value, this._compression);
1161
+ return this;
1162
+ }
1163
+ /** Effective compression settings for this response, or `false` when compression is off. */
1164
+ get compression() {
1165
+ return this._compression ?? false;
1166
+ }
1167
+ /**
1033
1168
  * Returns the underlying Node.js `ServerResponse`.
1034
1169
  * @param passthrough - If `true`, the framework still manages the response lifecycle. If `false` (default), the response is marked as "responded" and the framework will not touch it.
1035
1170
  */
@@ -1067,15 +1202,13 @@ var HttpResponse = class {
1067
1202
  });
1068
1203
  }
1069
1204
  const rendered = this.renderBody();
1205
+ const prerendered = this.takePrerendered();
1070
1206
  this.autoStatus(!!rendered);
1071
- if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
1207
+ if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
1072
1208
  status: this._status,
1073
1209
  headers: this._buildWebHeaders()
1074
1210
  });
1075
- if (rendered) {
1076
- const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
1077
- this._headers["content-length"] = contentLength.toString();
1078
- }
1211
+ if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
1079
1212
  const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
1080
1213
  const webResponse = new globalThis.Response(webBody, {
1081
1214
  status: this._status,
@@ -1122,7 +1255,7 @@ var HttpResponse = class {
1122
1255
  if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
1123
1256
  const prerendered = getPrerenderedJson(body);
1124
1257
  if (prerendered) {
1125
- this._prerenderedEtag = prerendered.etag;
1258
+ this._prerendered = prerendered;
1126
1259
  return prerendered.json;
1127
1260
  }
1128
1261
  return JSON.stringify(body);
@@ -1145,6 +1278,7 @@ var HttpResponse = class {
1145
1278
  *
1146
1279
  * Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
1147
1280
  * then writes the body. Supports `Readable` streams, `fetch` `Response` objects, and regular values.
1281
+ * Returns a Promise (which never rejects) for streamed bodies and compressed regular bodies.
1148
1282
  *
1149
1283
  * @throws Error if the response was already sent.
1150
1284
  */
@@ -1164,7 +1298,7 @@ var HttpResponse = class {
1164
1298
  const method = this._req.method;
1165
1299
  if (body instanceof Readable$1) return this.sendStream(body, method);
1166
1300
  if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
1167
- this.sendRegular(method);
1301
+ return this.sendRegular(method);
1168
1302
  }
1169
1303
  finalizeCookies() {
1170
1304
  if (!this._hasCookies) return;
@@ -1182,12 +1316,9 @@ var HttpResponse = class {
1182
1316
  * matches into a bodiless `304`. Returns `true` when the response became `304`.
1183
1317
  * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
1184
1318
  */
1185
- applyPrerenderedEtag() {
1186
- const etag = this._prerenderedEtag;
1187
- if (!etag) return false;
1188
- this._prerenderedEtag = void 0;
1319
+ applyPrerenderedEtag(etag) {
1189
1320
  const method = this._req.method;
1190
- if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.hasHeaderIgnoreCase("etag")) return false;
1321
+ if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
1191
1322
  this._headers.etag = etag;
1192
1323
  if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
1193
1324
  this._status = EHttpStatusCode.NotModified;
@@ -1197,9 +1328,16 @@ var HttpResponse = class {
1197
1328
  }
1198
1329
  return true;
1199
1330
  }
1200
- hasHeaderIgnoreCase(name) {
1201
- for (const key in this._headers) if (key.toLowerCase() === name) return true;
1202
- return false;
1331
+ /** Returns the stored key of header `name` (lower-case) in whatever casing it was set with. */
1332
+ headerKeyIgnoreCase(name) {
1333
+ if (name in this._headers) return name;
1334
+ for (const key in this._headers) if (key.toLowerCase() === name) return key;
1335
+ }
1336
+ /** Returns and clears the prerender entry picked by the last `renderBody()`. */
1337
+ takePrerendered() {
1338
+ const prerendered = this._prerendered;
1339
+ this._prerendered = void 0;
1340
+ return prerendered;
1203
1341
  }
1204
1342
  autoStatus(hasBody) {
1205
1343
  if (this._status) return;
@@ -1246,17 +1384,101 @@ var HttpResponse = class {
1246
1384
  return this.pipeToResponse(Readable$1.fromWeb(fetchResponse.body), "Error streaming fetch response body");
1247
1385
  }
1248
1386
  sendRegular(method) {
1249
- const renderedBody = this.renderBody();
1250
- this.autoStatus(!!renderedBody);
1251
- if (this.applyPrerenderedEtag()) {
1387
+ const body = this.renderBody();
1388
+ const prerendered = this.takePrerendered();
1389
+ const size = renderedSize(body, prerendered);
1390
+ this.autoStatus(!!body);
1391
+ const compression = this._compression && this.isCompressible(size, this._compression) ? this._compression : void 0;
1392
+ if (compression) this.appendVary("Accept-Encoding");
1393
+ if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) {
1252
1394
  this._res.writeHead(this._status, this._headers).end();
1253
1395
  return;
1254
1396
  }
1255
- const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1256
- this._headers["content-length"] = contentLength.toString();
1257
- this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
1397
+ if (compression && method !== "HEAD") {
1398
+ const encoding = negotiateEncoding(this._req.headers["accept-encoding"], compression.encodings);
1399
+ if (encoding) return this.sendCompressed(body, size, encoding, compression, prerendered);
1400
+ }
1401
+ this._headers["content-length"] = size.toString();
1402
+ this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : body);
1403
+ }
1404
+ /**
1405
+ * Whether a rendered regular body of `size` bytes may be compressed — independent of the
1406
+ * request's `Accept-Encoding` (and of HEAD, which is answered uncompressed).
1407
+ */
1408
+ isCompressible(size, opts) {
1409
+ const status = this._status;
1410
+ if (!size || size < opts.threshold || status < 200 || status === 204 || status === 206 || status === 304) return false;
1411
+ let contentType;
1412
+ for (const key in this._headers) {
1413
+ const lower = key.toLowerCase();
1414
+ if (lower === "content-type") contentType = this._headers[key];
1415
+ else if (lower === "content-encoding") return false;
1416
+ else if (lower === "cache-control" && /no-transform/i.test(String(this._headers[key]))) return false;
1417
+ }
1418
+ return typeof contentType === "string" && opts.filter(contentType, this);
1419
+ }
1420
+ /** Adds `token` to the `Vary` header (case-insensitive merge, keeps existing entries). */
1421
+ appendVary(token) {
1422
+ const key = this.headerKeyIgnoreCase("vary") ?? "vary";
1423
+ const merged = mergeVary(this._headers[key], token);
1424
+ if (merged !== void 0) this._headers[key] = merged;
1425
+ }
1426
+ /**
1427
+ * Sends the body compressed with `encoding`. Prerendered bodies are compressed once per coding
1428
+ * and level and the bytes reused (sent synchronously once ready). A compressor failure is
1429
+ * logged and the body is sent uncompressed — headers are not written yet, so that is safe.
1430
+ * Never rejects: a sync handler's `send()` is not awaited.
1431
+ */
1432
+ sendCompressed(body, size, encoding, opts, prerendered) {
1433
+ let pending;
1434
+ if (prerendered?.json === body) {
1435
+ const cache = prerendered.compressed ??= {};
1436
+ const cacheKey = compressionCacheKey(encoding, opts);
1437
+ let entry = cache[cacheKey];
1438
+ if (!entry) {
1439
+ const promise = compressResponseBody(body, size, encoding, opts);
1440
+ promise.then((bytes) => cache[cacheKey] = bytes, () => delete cache[cacheKey]);
1441
+ entry = cache[cacheKey] = promise;
1442
+ }
1443
+ pending = entry;
1444
+ } else pending = compressResponseBody(body, size, encoding, opts);
1445
+ if (Buffer.isBuffer(pending)) {
1446
+ this.writeDeferred(pending, pending.byteLength, encoding);
1447
+ return;
1448
+ }
1449
+ return pending.then((bytes) => this.writeDeferred(bytes, bytes.byteLength, encoding), (error) => {
1450
+ this._logger.error("Response compression failed, sending uncompressed body", error);
1451
+ this.writeDeferred(body, size);
1452
+ }).catch((error) => {
1453
+ this._logger.error("Failed to send compressed response", error);
1454
+ });
1455
+ }
1456
+ /**
1457
+ * Writes the body picked by `sendCompressed()` — the `encoding`-compressed bytes, or the identity
1458
+ * body after a compressor failure — unless the client went away meanwhile.
1459
+ */
1460
+ writeDeferred(body, length, encoding) {
1461
+ if (this._res.destroyed) return;
1462
+ if (encoding) {
1463
+ this._headers["content-encoding"] = encoding;
1464
+ for (const key in this._headers) {
1465
+ const lower = key.toLowerCase();
1466
+ if (lower === "content-length" && key !== lower) delete this._headers[key];
1467
+ else if (lower === "etag") {
1468
+ const etag = this._headers[key];
1469
+ if (typeof etag === "string" && etag && !etag.startsWith("W/")) this._headers[key] = `W/${etag}`;
1470
+ }
1471
+ }
1472
+ }
1473
+ this._headers["content-length"] = length.toString();
1474
+ this._res.writeHead(this._status, this._headers).end(body);
1258
1475
  }
1259
1476
  };
1477
+ /** UTF-8 size of a rendered body — computed once per registration for a prerendered body. */
1478
+ function renderedSize(body, prerendered) {
1479
+ if (typeof body !== "string") return body.byteLength;
1480
+ return prerendered?.json === body ? prerendered.size ??= Buffer.byteLength(body) : Buffer.byteLength(body);
1481
+ }
1260
1482
  /** Converts a Record of headers to a Web Standard `Headers` object. */
1261
1483
  function recordToWebHeaders(record) {
1262
1484
  const headers = new Headers();
@@ -1630,7 +1852,7 @@ function escapeHtml(value) {
1630
1852
  //#endregion
1631
1853
  //#region packages/event-http/src/response/wooks-http-response.ts
1632
1854
  let framework = {
1633
- version: "0.7.27",
1855
+ version: "0.7.28",
1634
1856
  poweredBy: "wooksjs",
1635
1857
  link: "https://wooks.moost.org/",
1636
1858
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1726,6 +1948,7 @@ var WooksHttp = class extends WooksAdapterBase {
1726
1948
  this.logger = opts?.logger || this.getLogger(`[wooks-http]`);
1727
1949
  this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
1728
1950
  this.eventContextOptions = this.getEventContextOptions();
1951
+ this.compression = resolveCompression(opts?.compression);
1729
1952
  }
1730
1953
  /** Registers a handler for all HTTP methods on the given path. */
1731
1954
  all(path, handler) {
@@ -1848,8 +2071,9 @@ var WooksHttp = class extends WooksAdapterBase {
1848
2071
  const RequestLimits = this.opts?.requestLimits;
1849
2072
  const notFoundHandler = this.opts?.onNotFound;
1850
2073
  const defaultHeaders = this.opts?.defaultHeaders;
2074
+ const compression = this.compression;
1851
2075
  return (req, res) => {
1852
- const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
2076
+ const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
1853
2077
  const method = req.method || "";
1854
2078
  const url = req.url || "";
1855
2079
  createHttpContext(ctxOptions, {
@@ -2236,4 +2460,4 @@ function prepareTestHttpContext(options) {
2236
2460
  }
2237
2461
 
2238
2462
  //#endregion
2239
- export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useLogger, useRequest, useResponse, useRouteParams, useUrlParams };
2463
+ export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, isCompressibleType, negotiateEncoding, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useLogger, useRequest, useResponse, useRouteParams, useUrlParams };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-http",
3
- "version": "0.7.27",
3
+ "version": "0.7.28",
4
4
  "description": "@wooksjs/event-http",
5
5
  "keywords": [
6
6
  "api",
@@ -42,14 +42,14 @@
42
42
  "devDependencies": {
43
43
  "typescript": "^5.9.3",
44
44
  "vitest": "^3.2.7",
45
- "@wooksjs/event-core": "^0.7.27",
46
- "wooks": "^0.7.27"
45
+ "@wooksjs/event-core": "^0.7.28",
46
+ "wooks": "^0.7.28"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@prostojs/logger": "^0.4.3",
50
50
  "@prostojs/router": "^0.3.5",
51
- "wooks": "^0.7.27",
52
- "@wooksjs/event-core": "^0.7.27"
51
+ "@wooksjs/event-core": "^0.7.28",
52
+ "wooks": "^0.7.28"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "rolldown -c ../../rolldown.config.mjs"