@wooksjs/event-http 0.7.26 → 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.mjs CHANGED
@@ -2,11 +2,12 @@ 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";
9
9
  import { WooksAdapterBase } from "wooks";
10
+ import { createHash } from "crypto";
10
11
  import { Socket } from "net";
11
12
 
12
13
  //#region packages/event-http/src/http-kind.ts
@@ -67,18 +68,22 @@ const useCookies = defineWook((ctx) => ({
67
68
  }));
68
69
 
69
70
  //#endregion
70
- //#region packages/event-http/src/composables/header-accept.ts
71
+ //#region packages/event-http/src/utils/accept.ts
72
+ /** Short Accept names mapped to their MIME types. */
71
73
  const ACCEPT_TYPE_MAP = {
72
74
  json: "application/json",
73
75
  html: "text/html",
74
76
  xml: "application/xml",
75
77
  text: "text/plain"
76
78
  };
77
- const acceptsMime = cachedBy((type, ctx) => {
78
- const accept = ctx.get(httpKind.keys.req).headers.accept;
79
- const mime = ACCEPT_TYPE_MAP[type] || type;
80
- return !!(accept && (accept === "*/*" || accept.includes(mime)));
81
- });
79
+ /** Whether an `Accept` header value accepts `type` (a short name from {@link ACCEPT_TYPE_MAP} or a MIME type). */
80
+ function acceptHeaderHas(accept, type) {
81
+ return !!(accept && (accept === "*/*" || accept.includes(ACCEPT_TYPE_MAP[type] || type)));
82
+ }
83
+
84
+ //#endregion
85
+ //#region packages/event-http/src/composables/header-accept.ts
86
+ const acceptsMime = cachedBy((type, ctx) => acceptHeaderHas(ctx.get(httpKind.keys.req).headers.accept, type));
82
87
  /** Provides helpers to check the request's Accept header for supported MIME types. */
83
88
  const useAccept = defineWook((ctx) => {
84
89
  return {
@@ -138,6 +143,19 @@ const useAuthorization = defineWook((ctx) => {
138
143
  };
139
144
  });
140
145
 
146
+ //#endregion
147
+ //#region packages/event-http/src/composables/headers.ts
148
+ /**
149
+ * Returns the incoming request headers.
150
+ * @example
151
+ * ```ts
152
+ * const { host, authorization } = useHeaders()
153
+ * ```
154
+ */
155
+ function useHeaders(ctx) {
156
+ return (ctx ?? current()).get(httpKind.keys.req).headers;
157
+ }
158
+
141
159
  //#endregion
142
160
  //#region packages/event-http/src/compressor/body-compressor.ts
143
161
  const compressors = { identity: {
@@ -387,18 +405,72 @@ const DEFAULT_LIMITS = {
387
405
  maxRatio: 100,
388
406
  readTimeoutMs: 1e4
389
407
  };
408
+ const NO_ENCODINGS = Object.freeze([]);
390
409
  const contentEncodingsSlot = cached((ctx) => {
391
- return (ctx.get(httpKind.keys.req).headers["content-encoding"] || "").split(",").map((p) => p.trim()).filter((p) => !!p);
410
+ const contentEncoding = ctx.get(httpKind.keys.req).headers["content-encoding"];
411
+ if (!contentEncoding) return NO_ENCODINGS;
412
+ return contentEncoding.split(",").map((p) => p.trim()).filter((p) => !!p);
392
413
  });
393
414
  const isCompressedSlot = cached((ctx) => {
394
- const parts = ctx.get(contentEncodingsSlot);
395
- for (const p of parts) if ([
396
- "deflate",
397
- "gzip",
398
- "br"
399
- ].includes(p)) return true;
415
+ for (const p of ctx.get(contentEncodingsSlot)) if (p === "deflate" || p === "gzip" || p === "br") return true;
400
416
  return false;
401
417
  });
418
+ /**
419
+ * Reads the whole request body with plain stream events (one listener set, one timer).
420
+ * Raw bytes are checked against `upfrontLimit` first, then against `maxInflated`
421
+ * (the same order as the streaming path). A stall longer than `timeoutMs` destroys the
422
+ * request; a premature close or stream error becomes 408. The request is destroyed
423
+ * once reading settles, as async iteration did.
424
+ */
425
+ function readRawBody(req, upfrontLimit, maxInflated, timeoutMs) {
426
+ return new Promise((resolve, reject) => {
427
+ const chunks = [];
428
+ let bytes = 0;
429
+ if (req.readableEnded) {
430
+ req.destroy();
431
+ resolve(Buffer$1.alloc(0));
432
+ return;
433
+ }
434
+ if (req.destroyed) {
435
+ reject(new HttpError(408, "Request body timeout"));
436
+ return;
437
+ }
438
+ let done = false;
439
+ const timer = timeoutMs === 0 ? null : setTimeout(() => req.destroy(), timeoutMs);
440
+ const finish = (error) => {
441
+ if (done) return;
442
+ done = true;
443
+ if (timer) clearTimeout(timer);
444
+ req.off("data", onData);
445
+ req.off("end", onEnd);
446
+ req.off("error", onAbort);
447
+ req.off("close", onAbort);
448
+ req.destroy();
449
+ if (error) reject(error);
450
+ else resolve(chunks.length === 1 ? chunks[0] : Buffer$1.concat(chunks, bytes));
451
+ };
452
+ const onData = (chunk) => {
453
+ bytes += chunk.length;
454
+ if (bytes > upfrontLimit) {
455
+ finish(new HttpError(413, "Payload Too Large"));
456
+ return;
457
+ }
458
+ if (bytes > maxInflated) {
459
+ finish(new HttpError(413, "Inflated body too large"));
460
+ return;
461
+ }
462
+ timer?.refresh();
463
+ chunks.push(chunk);
464
+ };
465
+ const onEnd = () => finish();
466
+ const onAbort = () => finish(new HttpError(408, "Request body timeout"));
467
+ req.on("data", onData);
468
+ req.on("end", onEnd);
469
+ req.on("error", onAbort);
470
+ req.on("close", onAbort);
471
+ req.resume();
472
+ });
473
+ }
402
474
  /** @internal Exported for test pre-seeding — use {@link seedRawBody} instead. */
403
475
  const rawBodySlot = cached(async (ctx) => {
404
476
  const req = ctx.get(httpKind.keys.req);
@@ -414,6 +486,14 @@ const rawBodySlot = cached(async (ctx) => {
414
486
  const upfrontLimit = isZip ? maxCompressed : maxInflated;
415
487
  if (cl && cl > upfrontLimit) throw new HttpError(413, "Payload Too Large");
416
488
  for (const enc of encs) if (!compressors[enc]) throw new HttpError(415, `Unsupported Content-Encoding "${enc}"`);
489
+ if (!streamable) {
490
+ const raw = await readRawBody(req, upfrontLimit, maxInflated, timeoutMs);
491
+ if (!isZip) return raw;
492
+ const body = await uncompressBody(encs, raw);
493
+ if (body.byteLength > maxInflated) throw new HttpError(413, "Inflated body too large");
494
+ if (raw.byteLength > 0 && body.byteLength / raw.byteLength > maxRatio) throw new HttpError(413, "Compression ratio too high");
495
+ return body;
496
+ }
417
497
  let timer = null;
418
498
  function resetTimer() {
419
499
  if (timeoutMs === 0) return;
@@ -446,8 +526,7 @@ const rawBodySlot = cached(async (ctx) => {
446
526
  clearTimer();
447
527
  }
448
528
  }
449
- let stream = limitedCompressed();
450
- if (streamable) stream = await uncompressBodyStream(encs, stream);
529
+ const stream = await uncompressBodyStream(encs, limitedCompressed());
451
530
  const chunks = [];
452
531
  let inflatedBytes = 0;
453
532
  try {
@@ -460,14 +539,8 @@ const rawBodySlot = cached(async (ctx) => {
460
539
  if (error instanceof HttpError) throw error;
461
540
  throw new HttpError(408, "Request body timeout");
462
541
  }
463
- let body = Buffer$1.concat(chunks);
464
- if (!streamable && isZip) {
465
- body = await uncompressBody(encs, body);
466
- inflatedBytes = body.byteLength;
467
- if (inflatedBytes > maxInflated) throw new HttpError(413, "Inflated body too large");
468
- }
469
- if (isZip && rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
470
- return body;
542
+ if (rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
543
+ return Buffer$1.concat(chunks);
471
544
  });
472
545
  const forwardedIpSlot = cached((ctx) => {
473
546
  const req = ctx.get(httpKind.keys.req);
@@ -563,19 +636,6 @@ function seedRawBody(ctx, raw) {
563
636
  if (ctx.parent) ctx.getOwn(useRequest._slot);
564
637
  }
565
638
 
566
- //#endregion
567
- //#region packages/event-http/src/composables/headers.ts
568
- /**
569
- * Returns the incoming request headers.
570
- * @example
571
- * ```ts
572
- * const { host, authorization } = useHeaders()
573
- * ```
574
- */
575
- function useHeaders(ctx) {
576
- return useRequest(ctx).headers;
577
- }
578
-
579
639
  //#endregion
580
640
  //#region packages/event-http/src/composables/response.ts
581
641
  /**
@@ -658,6 +718,120 @@ function useHttpContext(ctx) {
658
718
  return ctx ?? current();
659
719
  }
660
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
+
661
835
  //#endregion
662
836
  //#region packages/event-http/src/utils/time.ts
663
837
  function convertTime(time, unit = "ms") {
@@ -734,6 +908,70 @@ const cookieAttrFunc = {
734
908
  sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
735
909
  };
736
910
 
911
+ //#endregion
912
+ //#region packages/event-http/src/response/prerender.ts
913
+ const registry = /* @__PURE__ */ new WeakMap();
914
+ function weakEtag(json) {
915
+ return `W/"${createHash("sha256").update(json).digest("base64url").slice(0, 22)}"`;
916
+ }
917
+ /**
918
+ * Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
919
+ * (or an interceptor) responds with that same object, the response reuses the stored JSON instead
920
+ * of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
921
+ * requests return as is — e.g. a schema or metadata envelope built once and cached.
922
+ *
923
+ * With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
924
+ * that responds `200` with the object sets the `ETag` header and answers a matching
925
+ * `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
926
+ * and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
927
+ * on the response wins and disables the `304` handling for that response.
928
+ *
929
+ * **Contract: never mutate a registered object (or anything it references).** The stored JSON
930
+ * is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
931
+ * freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
932
+ *
933
+ * Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
934
+ * the first time). Entries are held weakly and disappear with the object.
935
+ *
936
+ * @param obj - A JSON-serializable object or array
937
+ * @param options - `{ etag: true }` to also compute a weak ETag
938
+ * @returns The same `obj`, so it can wrap a return value
939
+ *
940
+ * @example
941
+ * ```ts
942
+ * const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
943
+ * app.get('/meta', () => {
944
+ * useResponse().setHeader('cache-control', 'private, no-cache')
945
+ * return envelope // sent without re-serializing; 304 on a matching If-None-Match
946
+ * })
947
+ * ```
948
+ */
949
+ function prerenderJson(obj, options) {
950
+ let entry = registry.get(obj);
951
+ if (!entry) {
952
+ const json = JSON.stringify(obj);
953
+ if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
954
+ entry = { json };
955
+ registry.set(obj, entry);
956
+ }
957
+ if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
958
+ return obj;
959
+ }
960
+ /** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
961
+ function getPrerenderedJson(obj) {
962
+ return registry.get(obj);
963
+ }
964
+ /** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
965
+ function ifNoneMatchHas(header, etag) {
966
+ if (!header) return false;
967
+ const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
968
+ for (const part of header.split(",")) {
969
+ const tag = part.trim();
970
+ if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
971
+ }
972
+ return false;
973
+ }
974
+
737
975
  //#endregion
738
976
  //#region packages/event-http/src/response/http-response.ts
739
977
  const hasFetchResponse = typeof globalThis.Response === "function";
@@ -763,8 +1001,10 @@ var HttpResponse = class {
763
1001
  * @param _req - The underlying Node.js `IncomingMessage`.
764
1002
  * @param _logger - Logger instance for error reporting.
765
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).
766
1006
  */
767
- constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
1007
+ constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
768
1008
  this._res = _res;
769
1009
  this._req = _req;
770
1010
  this._logger = _logger;
@@ -772,10 +1012,9 @@ var HttpResponse = class {
772
1012
  this._status = 0;
773
1013
  this._body = void 0;
774
1014
  this._headers = {};
775
- this._cookies = {};
776
- this._rawCookies = [];
777
1015
  this._hasCookies = false;
778
1016
  this._responded = false;
1017
+ this._compression = compression;
779
1018
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
780
1019
  }
781
1020
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
@@ -841,7 +1080,7 @@ var HttpResponse = class {
841
1080
  }
842
1081
  /** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
843
1082
  setCookie(name, value, attrs) {
844
- this._cookies[name] = {
1083
+ (this._cookies ??= {})[name] = {
845
1084
  value,
846
1085
  attrs: attrs || {}
847
1086
  };
@@ -850,23 +1089,23 @@ var HttpResponse = class {
850
1089
  }
851
1090
  /** Returns a previously set cookie's data, or `undefined` if not set. */
852
1091
  getCookie(name) {
853
- return this._cookies[name];
1092
+ return this._cookies?.[name];
854
1093
  }
855
1094
  /** Removes a cookie from the outgoing set list (chainable). */
856
1095
  removeCookie(name) {
857
- delete this._cookies[name];
1096
+ if (this._cookies) delete this._cookies[name];
858
1097
  return this;
859
1098
  }
860
1099
  /** Removes all outgoing cookies (chainable). */
861
1100
  clearCookies() {
862
- this._cookies = {};
863
- this._rawCookies = [];
1101
+ this._cookies = void 0;
1102
+ this._rawCookies = void 0;
864
1103
  this._hasCookies = false;
865
1104
  return this;
866
1105
  }
867
1106
  /** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
868
1107
  setCookieRaw(rawValue) {
869
- this._rawCookies.push(rawValue);
1108
+ (this._rawCookies ??= []).push(rawValue);
870
1109
  this._hasCookies = true;
871
1110
  return this;
872
1111
  }
@@ -881,8 +1120,10 @@ var HttpResponse = class {
881
1120
  */
882
1121
  getSetCookieStrings() {
883
1122
  const rendered = [];
884
- for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
885
- rendered.push(...this._rawCookies);
1123
+ if (this._cookies) {
1124
+ for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
1125
+ }
1126
+ if (this._rawCookies) rendered.push(...this._rawCookies);
886
1127
  return rendered;
887
1128
  }
888
1129
  /** Sets the `Cache-Control` header from a directive object (chainable). */
@@ -906,6 +1147,24 @@ var HttpResponse = class {
906
1147
  return this;
907
1148
  }
908
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
+ /**
909
1168
  * Returns the underlying Node.js `ServerResponse`.
910
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.
911
1170
  */
@@ -943,11 +1202,13 @@ var HttpResponse = class {
943
1202
  });
944
1203
  }
945
1204
  const rendered = this.renderBody();
1205
+ const prerendered = this.takePrerendered();
946
1206
  this.autoStatus(!!rendered);
947
- if (rendered) {
948
- const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
949
- this._headers["content-length"] = contentLength.toString();
950
- }
1207
+ if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
1208
+ status: this._status,
1209
+ headers: this._buildWebHeaders()
1210
+ });
1211
+ if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
951
1212
  const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
952
1213
  const webResponse = new globalThis.Response(webBody, {
953
1214
  status: this._status,
@@ -992,6 +1253,11 @@ var HttpResponse = class {
992
1253
  if (body instanceof Uint8Array) return body;
993
1254
  if (typeof body === "object") {
994
1255
  if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
1256
+ const prerendered = getPrerenderedJson(body);
1257
+ if (prerendered) {
1258
+ this._prerendered = prerendered;
1259
+ return prerendered.json;
1260
+ }
995
1261
  return JSON.stringify(body);
996
1262
  }
997
1263
  throw new Error(`Unsupported body format "${typeof body}"`);
@@ -1012,6 +1278,7 @@ var HttpResponse = class {
1012
1278
  *
1013
1279
  * Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
1014
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.
1015
1282
  *
1016
1283
  * @throws Error if the response was already sent.
1017
1284
  */
@@ -1031,7 +1298,7 @@ var HttpResponse = class {
1031
1298
  const method = this._req.method;
1032
1299
  if (body instanceof Readable$1) return this.sendStream(body, method);
1033
1300
  if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
1034
- this.sendRegular(method);
1301
+ return this.sendRegular(method);
1035
1302
  }
1036
1303
  finalizeCookies() {
1037
1304
  if (!this._hasCookies) return;
@@ -1043,6 +1310,35 @@ var HttpResponse = class {
1043
1310
  }
1044
1311
  this._hasCookies = false;
1045
1312
  }
1313
+ /**
1314
+ * For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
1315
+ * GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
1316
+ * matches into a bodiless `304`. Returns `true` when the response became `304`.
1317
+ * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
1318
+ */
1319
+ applyPrerenderedEtag(etag) {
1320
+ const method = this._req.method;
1321
+ if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
1322
+ this._headers.etag = etag;
1323
+ if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
1324
+ this._status = EHttpStatusCode.NotModified;
1325
+ for (const key in this._headers) {
1326
+ const lower = key.toLowerCase();
1327
+ if (lower === "content-type" || lower === "content-length") delete this._headers[key];
1328
+ }
1329
+ return true;
1330
+ }
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;
1341
+ }
1046
1342
  autoStatus(hasBody) {
1047
1343
  if (this._status) return;
1048
1344
  if (!hasBody) {
@@ -1088,13 +1384,101 @@ var HttpResponse = class {
1088
1384
  return this.pipeToResponse(Readable$1.fromWeb(fetchResponse.body), "Error streaming fetch response body");
1089
1385
  }
1090
1386
  sendRegular(method) {
1091
- const renderedBody = this.renderBody();
1092
- this.autoStatus(!!renderedBody);
1093
- const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1094
- this._headers["content-length"] = contentLength.toString();
1095
- this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
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)) {
1394
+ this._res.writeHead(this._status, this._headers).end();
1395
+ return;
1396
+ }
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);
1096
1475
  }
1097
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
+ }
1098
1482
  /** Converts a Record of headers to a Web Standard `Headers` object. */
1099
1483
  function recordToWebHeaders(record) {
1100
1484
  const headers = new Headers();
@@ -1468,7 +1852,7 @@ function escapeHtml(value) {
1468
1852
  //#endregion
1469
1853
  //#region packages/event-http/src/response/wooks-http-response.ts
1470
1854
  let framework = {
1471
- version: "0.7.26",
1855
+ version: "0.7.28",
1472
1856
  poweredBy: "wooksjs",
1473
1857
  link: "https://wooks.moost.org/",
1474
1858
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1492,7 +1876,8 @@ var WooksHttpResponse = class extends HttpResponse {
1492
1876
  }
1493
1877
  renderError(data, ctx) {
1494
1878
  this._status = data.statusCode || 500;
1495
- const { has } = useAccept(ctx);
1879
+ const accept = ctx.get(httpKind.keys.req).headers.accept;
1880
+ const has = (type) => acceptHeaderHas(accept, type);
1496
1881
  if (has("json")) {
1497
1882
  this._headers["content-type"] = "application/json";
1498
1883
  this._body = JSON.stringify(data);
@@ -1563,6 +1948,7 @@ var WooksHttp = class extends WooksAdapterBase {
1563
1948
  this.logger = opts?.logger || this.getLogger(`[wooks-http]`);
1564
1949
  this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
1565
1950
  this.eventContextOptions = this.getEventContextOptions();
1951
+ this.compression = resolveCompression(opts?.compression);
1566
1952
  }
1567
1953
  /** Registers a handler for all HTTP methods on the given path. */
1568
1954
  all(path, handler) {
@@ -1685,8 +2071,9 @@ var WooksHttp = class extends WooksAdapterBase {
1685
2071
  const RequestLimits = this.opts?.requestLimits;
1686
2072
  const notFoundHandler = this.opts?.onNotFound;
1687
2073
  const defaultHeaders = this.opts?.defaultHeaders;
2074
+ const compression = this.compression;
1688
2075
  return (req, res) => {
1689
- const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
2076
+ const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
1690
2077
  const method = req.method || "";
1691
2078
  const url = req.url || "";
1692
2079
  createHttpContext(ctxOptions, {
@@ -1788,33 +2175,19 @@ var WooksHttp = class extends WooksAdapterBase {
1788
2175
  }
1789
2176
  }
1790
2177
  async processAsyncResult(promise, handlers, startIndex, ctx, response) {
1791
- try {
1792
- const result = await promise;
1793
- await this.respond(result, response, ctx);
2178
+ for (let i = startIndex; i < handlers.length; i++) try {
2179
+ const result = await (i === startIndex ? promise : handlers[i]());
2180
+ const sent = this.respond(result, response, ctx);
2181
+ if (sent) await sent;
1794
2182
  return result;
1795
2183
  } catch (error) {
1796
- const isLastHandler = startIndex === handlers.length - 1;
1797
2184
  if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1798
- if (isLastHandler) {
1799
- await this.respond(error, response, ctx);
2185
+ if (i === handlers.length - 1) {
2186
+ const sent = this.respond(error, response, ctx);
2187
+ if (sent) await sent;
1800
2188
  return error;
1801
2189
  }
1802
2190
  }
1803
- for (let i = startIndex + 1; i < handlers.length; i++) {
1804
- const handler = handlers[i];
1805
- const isLastHandler = i === handlers.length - 1;
1806
- try {
1807
- const result = await handler();
1808
- await this.respond(result, response, ctx);
1809
- return result;
1810
- } catch (error) {
1811
- if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1812
- if (isLastHandler) {
1813
- await this.respond(error, response, ctx);
1814
- return error;
1815
- }
1816
- }
1817
- }
1818
2191
  }
1819
2192
  /**
1820
2193
  * Programmatic route invocation using the Web Standard fetch API.
@@ -2087,4 +2460,4 @@ function prepareTestHttpContext(options) {
2087
2460
  }
2088
2461
 
2089
2462
  //#endregion
2090
- export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, 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 };