@wooksjs/event-http 0.7.27 → 0.7.29

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.29",
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) {
@@ -1871,25 +2094,36 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1871
2094
  * const server = http.createServer(app.getServerCb())
1872
2095
  * server.listen(3000)
1873
2096
  * ```
2097
+ *
2098
+ * Pass `onNoMatch` to mount Wooks as a middleware in front of another handler
2099
+ * (e.g. a Vite dev server). The request is routed first; when no route matches,
2100
+ * `onNoMatch(req, res)` is called directly — before and outside any event context:
2101
+ * no response wrapper is created, no event starts (`current()` throws, no
2102
+ * `ContextInjector` hooks fire) and `onNotFound` is skipped.
1874
2103
  */
1875
2104
  getServerCb(onNoMatch) {
1876
2105
  const ctxOptions = this.eventContextOptions;
1877
2106
  const RequestLimits = this.opts?.requestLimits;
1878
2107
  const notFoundHandler = this.opts?.onNotFound;
1879
2108
  const defaultHeaders = this.opts?.defaultHeaders;
2109
+ const compression = this.compression;
1880
2110
  return (req, res) => {
1881
- const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
1882
2111
  const method = req.method || "";
1883
2112
  const url = req.url || "";
2113
+ const match = this.wooks.matchRoute(method, url);
2114
+ if (!match && onNoMatch) {
2115
+ onNoMatch(req, res);
2116
+ return;
2117
+ }
2118
+ const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
1884
2119
  createHttpContext(ctxOptions, {
1885
2120
  req,
1886
2121
  response,
1887
2122
  requestLimits: RequestLimits
1888
2123
  }, () => {
1889
2124
  const ctx = (0, _wooksjs_event_core.current)();
1890
- const handlers = this.wooks.lookupHandlers(method, url, ctx);
2125
+ const handlers = this.wooks.applyRoute(method, match, ctx);
1891
2126
  if (handlers) return this.processAndCatch(handlers, ctx, response);
1892
- else if (onNoMatch) onNoMatch(req, res);
1893
2127
  else if (notFoundHandler) return this.processAndCatch([notFoundHandler], ctx, response);
1894
2128
  else {
1895
2129
  this.logger.debug(`404 Not found (${method})${url}`);
@@ -1996,20 +2230,23 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1996
2230
  }
1997
2231
  /**
1998
2232
  * Programmatic route invocation using the Web Standard fetch API.
1999
- * Goes through the full dispatch pipeline: context creation, route matching,
2000
- * handler execution, response finalization.
2233
+ * Goes through the full dispatch pipeline: route matching, context creation,
2234
+ * handler execution, response finalization. An unmatched route returns `null`
2235
+ * before any event context is created (the request body is left unread).
2001
2236
  *
2002
2237
  * When called from within an existing HTTP context (e.g. during SSR),
2003
2238
  * identity headers (authorization, cookie) are automatically forwarded
2004
2239
  * from the calling request unless already present on the given Request.
2005
2240
  *
2006
2241
  * @param request - A Web Standard Request object.
2007
- * @returns A Web Standard Response, or `null` if no route matched (and no `onNotFound` handler is set).
2242
+ * @returns A Web Standard Response, or `null` if no route matched (`onNotFound` is not used here).
2008
2243
  */
2009
2244
  async fetch(request) {
2010
2245
  const url = new URL(request.url);
2011
2246
  const method = request.method;
2012
2247
  const pathname = url.pathname + url.search;
2248
+ const match = this.wooks.matchRoute(method, pathname);
2249
+ if (!match) return null;
2013
2250
  const callerCtx = (0, _wooksjs_event_core.tryGetCurrent)();
2014
2251
  let callerReq;
2015
2252
  if (callerCtx) try {
@@ -2058,13 +2295,11 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
2058
2295
  const ctx = (0, _wooksjs_event_core.current)();
2059
2296
  if (bodyBuffer) seedRawBody(ctx, bodyBuffer);
2060
2297
  try {
2061
- const handlers = this.wooks.lookupHandlers(method, pathname, ctx);
2062
- if (handlers) {
2063
- const result = this.processHandlers(handlers, ctx, response);
2064
- if (result !== null && result !== void 0 && typeof result.then === "function") await result.catch((error) => {
2065
- if (!response.responded) this.respond(error, response, ctx);
2066
- });
2067
- } else return null;
2298
+ const handlers = this.wooks.applyRoute(method, match, ctx);
2299
+ const result = this.processHandlers(handlers, ctx, response);
2300
+ if (result !== null && result !== void 0 && typeof result.then === "function") await result.catch((error) => {
2301
+ if (!response.responded) this.respond(error, response, ctx);
2302
+ });
2068
2303
  } finally {
2069
2304
  fakeReq.emit("end");
2070
2305
  fakeReq.emit("close");
@@ -2277,6 +2512,8 @@ exports.createHttpApp = createHttpApp;
2277
2512
  exports.createHttpContext = createHttpContext;
2278
2513
  exports.httpKind = httpKind;
2279
2514
  exports.httpStatusCodes = httpStatusCodes;
2515
+ exports.isCompressibleType = isCompressibleType;
2516
+ exports.negotiateEncoding = negotiateEncoding;
2280
2517
  exports.prepareTestHttpContext = prepareTestHttpContext;
2281
2518
  exports.prerenderJson = prerenderJson;
2282
2519
  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>;
@@ -671,6 +778,12 @@ declare class WooksHttp extends WooksAdapterBase {
671
778
  * const server = http.createServer(app.getServerCb())
672
779
  * server.listen(3000)
673
780
  * ```
781
+ *
782
+ * Pass `onNoMatch` to mount Wooks as a middleware in front of another handler
783
+ * (e.g. a Vite dev server). The request is routed first; when no route matches,
784
+ * `onNoMatch(req, res)` is called directly — before and outside any event context:
785
+ * no response wrapper is created, no event starts (`current()` throws, no
786
+ * `ContextInjector` hooks fire) and `onNotFound` is skipped.
674
787
  */
675
788
  getServerCb(onNoMatch?: (req: IncomingMessage, res: ServerResponse) => void): (req: IncomingMessage, res: ServerResponse) => void;
676
789
  /**
@@ -685,15 +798,16 @@ declare class WooksHttp extends WooksAdapterBase {
685
798
  private processAsyncResult;
686
799
  /**
687
800
  * Programmatic route invocation using the Web Standard fetch API.
688
- * Goes through the full dispatch pipeline: context creation, route matching,
689
- * handler execution, response finalization.
801
+ * Goes through the full dispatch pipeline: route matching, context creation,
802
+ * handler execution, response finalization. An unmatched route returns `null`
803
+ * before any event context is created (the request body is left unread).
690
804
  *
691
805
  * When called from within an existing HTTP context (e.g. during SSR),
692
806
  * identity headers (authorization, cookie) are automatically forwarded
693
807
  * from the calling request unless already present on the given Request.
694
808
  *
695
809
  * @param request - A Web Standard Request object.
696
- * @returns A Web Standard Response, or `null` if no route matched (and no `onNotFound` handler is set).
810
+ * @returns A Web Standard Response, or `null` if no route matched (`onNotFound` is not used here).
697
811
  */
698
812
  fetch(request: Request): Promise<Response | null>;
699
813
  /**
@@ -848,5 +962,5 @@ interface TTestHttpContext {
848
962
  */
849
963
  declare function prepareTestHttpContext(options: TTestHttpContext): <T>(cb: (...a: any[]) => T) => T;
850
964
 
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 };
965
+ 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 };
966
+ 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.29",
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) {
@@ -1842,25 +2065,36 @@ var WooksHttp = class extends WooksAdapterBase {
1842
2065
  * const server = http.createServer(app.getServerCb())
1843
2066
  * server.listen(3000)
1844
2067
  * ```
2068
+ *
2069
+ * Pass `onNoMatch` to mount Wooks as a middleware in front of another handler
2070
+ * (e.g. a Vite dev server). The request is routed first; when no route matches,
2071
+ * `onNoMatch(req, res)` is called directly — before and outside any event context:
2072
+ * no response wrapper is created, no event starts (`current()` throws, no
2073
+ * `ContextInjector` hooks fire) and `onNotFound` is skipped.
1845
2074
  */
1846
2075
  getServerCb(onNoMatch) {
1847
2076
  const ctxOptions = this.eventContextOptions;
1848
2077
  const RequestLimits = this.opts?.requestLimits;
1849
2078
  const notFoundHandler = this.opts?.onNotFound;
1850
2079
  const defaultHeaders = this.opts?.defaultHeaders;
2080
+ const compression = this.compression;
1851
2081
  return (req, res) => {
1852
- const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
1853
2082
  const method = req.method || "";
1854
2083
  const url = req.url || "";
2084
+ const match = this.wooks.matchRoute(method, url);
2085
+ if (!match && onNoMatch) {
2086
+ onNoMatch(req, res);
2087
+ return;
2088
+ }
2089
+ const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
1855
2090
  createHttpContext(ctxOptions, {
1856
2091
  req,
1857
2092
  response,
1858
2093
  requestLimits: RequestLimits
1859
2094
  }, () => {
1860
2095
  const ctx = current();
1861
- const handlers = this.wooks.lookupHandlers(method, url, ctx);
2096
+ const handlers = this.wooks.applyRoute(method, match, ctx);
1862
2097
  if (handlers) return this.processAndCatch(handlers, ctx, response);
1863
- else if (onNoMatch) onNoMatch(req, res);
1864
2098
  else if (notFoundHandler) return this.processAndCatch([notFoundHandler], ctx, response);
1865
2099
  else {
1866
2100
  this.logger.debug(`404 Not found (${method})${url}`);
@@ -1967,20 +2201,23 @@ var WooksHttp = class extends WooksAdapterBase {
1967
2201
  }
1968
2202
  /**
1969
2203
  * Programmatic route invocation using the Web Standard fetch API.
1970
- * Goes through the full dispatch pipeline: context creation, route matching,
1971
- * handler execution, response finalization.
2204
+ * Goes through the full dispatch pipeline: route matching, context creation,
2205
+ * handler execution, response finalization. An unmatched route returns `null`
2206
+ * before any event context is created (the request body is left unread).
1972
2207
  *
1973
2208
  * When called from within an existing HTTP context (e.g. during SSR),
1974
2209
  * identity headers (authorization, cookie) are automatically forwarded
1975
2210
  * from the calling request unless already present on the given Request.
1976
2211
  *
1977
2212
  * @param request - A Web Standard Request object.
1978
- * @returns A Web Standard Response, or `null` if no route matched (and no `onNotFound` handler is set).
2213
+ * @returns A Web Standard Response, or `null` if no route matched (`onNotFound` is not used here).
1979
2214
  */
1980
2215
  async fetch(request) {
1981
2216
  const url = new URL(request.url);
1982
2217
  const method = request.method;
1983
2218
  const pathname = url.pathname + url.search;
2219
+ const match = this.wooks.matchRoute(method, pathname);
2220
+ if (!match) return null;
1984
2221
  const callerCtx = tryGetCurrent();
1985
2222
  let callerReq;
1986
2223
  if (callerCtx) try {
@@ -2029,13 +2266,11 @@ var WooksHttp = class extends WooksAdapterBase {
2029
2266
  const ctx = current();
2030
2267
  if (bodyBuffer) seedRawBody(ctx, bodyBuffer);
2031
2268
  try {
2032
- const handlers = this.wooks.lookupHandlers(method, pathname, ctx);
2033
- if (handlers) {
2034
- const result = this.processHandlers(handlers, ctx, response);
2035
- if (result !== null && result !== void 0 && typeof result.then === "function") await result.catch((error) => {
2036
- if (!response.responded) this.respond(error, response, ctx);
2037
- });
2038
- } else return null;
2269
+ const handlers = this.wooks.applyRoute(method, match, ctx);
2270
+ const result = this.processHandlers(handlers, ctx, response);
2271
+ if (result !== null && result !== void 0 && typeof result.then === "function") await result.catch((error) => {
2272
+ if (!response.responded) this.respond(error, response, ctx);
2273
+ });
2039
2274
  } finally {
2040
2275
  fakeReq.emit("end");
2041
2276
  fakeReq.emit("close");
@@ -2236,4 +2471,4 @@ function prepareTestHttpContext(options) {
2236
2471
  }
2237
2472
 
2238
2473
  //#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 };
2474
+ 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.29",
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.29",
46
+ "wooks": "^0.7.29"
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.29",
52
+ "wooks": "^0.7.29"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "rolldown -c ../../rolldown.config.mjs"