@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.cjs CHANGED
@@ -36,6 +36,7 @@ let http = require("http");
36
36
  http = __toESM(http);
37
37
  let stream = require("stream");
38
38
  let wooks = require("wooks");
39
+ let crypto = require("crypto");
39
40
  let net = require("net");
40
41
 
41
42
  //#region packages/event-http/src/http-kind.ts
@@ -96,18 +97,22 @@ const useCookies = (0, _wooksjs_event_core.defineWook)((ctx) => ({
96
97
  }));
97
98
 
98
99
  //#endregion
99
- //#region packages/event-http/src/composables/header-accept.ts
100
+ //#region packages/event-http/src/utils/accept.ts
101
+ /** Short Accept names mapped to their MIME types. */
100
102
  const ACCEPT_TYPE_MAP = {
101
103
  json: "application/json",
102
104
  html: "text/html",
103
105
  xml: "application/xml",
104
106
  text: "text/plain"
105
107
  };
106
- const acceptsMime = (0, _wooksjs_event_core.cachedBy)((type, ctx) => {
107
- const accept = ctx.get(httpKind.keys.req).headers.accept;
108
- const mime = ACCEPT_TYPE_MAP[type] || type;
109
- return !!(accept && (accept === "*/*" || accept.includes(mime)));
110
- });
108
+ /** Whether an `Accept` header value accepts `type` (a short name from {@link ACCEPT_TYPE_MAP} or a MIME type). */
109
+ function acceptHeaderHas(accept, type) {
110
+ return !!(accept && (accept === "*/*" || accept.includes(ACCEPT_TYPE_MAP[type] || type)));
111
+ }
112
+
113
+ //#endregion
114
+ //#region packages/event-http/src/composables/header-accept.ts
115
+ const acceptsMime = (0, _wooksjs_event_core.cachedBy)((type, ctx) => acceptHeaderHas(ctx.get(httpKind.keys.req).headers.accept, type));
111
116
  /** Provides helpers to check the request's Accept header for supported MIME types. */
112
117
  const useAccept = (0, _wooksjs_event_core.defineWook)((ctx) => {
113
118
  return {
@@ -167,6 +172,19 @@ const useAuthorization = (0, _wooksjs_event_core.defineWook)((ctx) => {
167
172
  };
168
173
  });
169
174
 
175
+ //#endregion
176
+ //#region packages/event-http/src/composables/headers.ts
177
+ /**
178
+ * Returns the incoming request headers.
179
+ * @example
180
+ * ```ts
181
+ * const { host, authorization } = useHeaders()
182
+ * ```
183
+ */
184
+ function useHeaders(ctx) {
185
+ return (ctx ?? (0, _wooksjs_event_core.current)()).get(httpKind.keys.req).headers;
186
+ }
187
+
170
188
  //#endregion
171
189
  //#region packages/event-http/src/compressor/body-compressor.ts
172
190
  const compressors = { identity: {
@@ -416,18 +434,72 @@ const DEFAULT_LIMITS = {
416
434
  maxRatio: 100,
417
435
  readTimeoutMs: 1e4
418
436
  };
437
+ const NO_ENCODINGS = Object.freeze([]);
419
438
  const contentEncodingsSlot = (0, _wooksjs_event_core.cached)((ctx) => {
420
- return (ctx.get(httpKind.keys.req).headers["content-encoding"] || "").split(",").map((p) => p.trim()).filter((p) => !!p);
439
+ const contentEncoding = ctx.get(httpKind.keys.req).headers["content-encoding"];
440
+ if (!contentEncoding) return NO_ENCODINGS;
441
+ return contentEncoding.split(",").map((p) => p.trim()).filter((p) => !!p);
421
442
  });
422
443
  const isCompressedSlot = (0, _wooksjs_event_core.cached)((ctx) => {
423
- const parts = ctx.get(contentEncodingsSlot);
424
- for (const p of parts) if ([
425
- "deflate",
426
- "gzip",
427
- "br"
428
- ].includes(p)) return true;
444
+ for (const p of ctx.get(contentEncodingsSlot)) if (p === "deflate" || p === "gzip" || p === "br") return true;
429
445
  return false;
430
446
  });
447
+ /**
448
+ * Reads the whole request body with plain stream events (one listener set, one timer).
449
+ * Raw bytes are checked against `upfrontLimit` first, then against `maxInflated`
450
+ * (the same order as the streaming path). A stall longer than `timeoutMs` destroys the
451
+ * request; a premature close or stream error becomes 408. The request is destroyed
452
+ * once reading settles, as async iteration did.
453
+ */
454
+ function readRawBody(req, upfrontLimit, maxInflated, timeoutMs) {
455
+ return new Promise((resolve, reject) => {
456
+ const chunks = [];
457
+ let bytes = 0;
458
+ if (req.readableEnded) {
459
+ req.destroy();
460
+ resolve(buffer.Buffer.alloc(0));
461
+ return;
462
+ }
463
+ if (req.destroyed) {
464
+ reject(new HttpError(408, "Request body timeout"));
465
+ return;
466
+ }
467
+ let done = false;
468
+ const timer = timeoutMs === 0 ? null : setTimeout(() => req.destroy(), timeoutMs);
469
+ const finish = (error) => {
470
+ if (done) return;
471
+ done = true;
472
+ if (timer) clearTimeout(timer);
473
+ req.off("data", onData);
474
+ req.off("end", onEnd);
475
+ req.off("error", onAbort);
476
+ req.off("close", onAbort);
477
+ req.destroy();
478
+ if (error) reject(error);
479
+ else resolve(chunks.length === 1 ? chunks[0] : buffer.Buffer.concat(chunks, bytes));
480
+ };
481
+ const onData = (chunk) => {
482
+ bytes += chunk.length;
483
+ if (bytes > upfrontLimit) {
484
+ finish(new HttpError(413, "Payload Too Large"));
485
+ return;
486
+ }
487
+ if (bytes > maxInflated) {
488
+ finish(new HttpError(413, "Inflated body too large"));
489
+ return;
490
+ }
491
+ timer?.refresh();
492
+ chunks.push(chunk);
493
+ };
494
+ const onEnd = () => finish();
495
+ const onAbort = () => finish(new HttpError(408, "Request body timeout"));
496
+ req.on("data", onData);
497
+ req.on("end", onEnd);
498
+ req.on("error", onAbort);
499
+ req.on("close", onAbort);
500
+ req.resume();
501
+ });
502
+ }
431
503
  /** @internal Exported for test pre-seeding — use {@link seedRawBody} instead. */
432
504
  const rawBodySlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
433
505
  const req = ctx.get(httpKind.keys.req);
@@ -443,6 +515,14 @@ const rawBodySlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
443
515
  const upfrontLimit = isZip ? maxCompressed : maxInflated;
444
516
  if (cl && cl > upfrontLimit) throw new HttpError(413, "Payload Too Large");
445
517
  for (const enc of encs) if (!compressors[enc]) throw new HttpError(415, `Unsupported Content-Encoding "${enc}"`);
518
+ if (!streamable) {
519
+ const raw = await readRawBody(req, upfrontLimit, maxInflated, timeoutMs);
520
+ if (!isZip) return raw;
521
+ const body = await uncompressBody(encs, raw);
522
+ if (body.byteLength > maxInflated) throw new HttpError(413, "Inflated body too large");
523
+ if (raw.byteLength > 0 && body.byteLength / raw.byteLength > maxRatio) throw new HttpError(413, "Compression ratio too high");
524
+ return body;
525
+ }
446
526
  let timer = null;
447
527
  function resetTimer() {
448
528
  if (timeoutMs === 0) return;
@@ -475,8 +555,7 @@ const rawBodySlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
475
555
  clearTimer();
476
556
  }
477
557
  }
478
- let stream = limitedCompressed();
479
- if (streamable) stream = await uncompressBodyStream(encs, stream);
558
+ const stream = await uncompressBodyStream(encs, limitedCompressed());
480
559
  const chunks = [];
481
560
  let inflatedBytes = 0;
482
561
  try {
@@ -489,14 +568,8 @@ const rawBodySlot = (0, _wooksjs_event_core.cached)(async (ctx) => {
489
568
  if (error instanceof HttpError) throw error;
490
569
  throw new HttpError(408, "Request body timeout");
491
570
  }
492
- let body = buffer.Buffer.concat(chunks);
493
- if (!streamable && isZip) {
494
- body = await uncompressBody(encs, body);
495
- inflatedBytes = body.byteLength;
496
- if (inflatedBytes > maxInflated) throw new HttpError(413, "Inflated body too large");
497
- }
498
- if (isZip && rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
499
- return body;
571
+ if (rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
572
+ return buffer.Buffer.concat(chunks);
500
573
  });
501
574
  const forwardedIpSlot = (0, _wooksjs_event_core.cached)((ctx) => {
502
575
  const req = ctx.get(httpKind.keys.req);
@@ -592,19 +665,6 @@ function seedRawBody(ctx, raw) {
592
665
  if (ctx.parent) ctx.getOwn(useRequest._slot);
593
666
  }
594
667
 
595
- //#endregion
596
- //#region packages/event-http/src/composables/headers.ts
597
- /**
598
- * Returns the incoming request headers.
599
- * @example
600
- * ```ts
601
- * const { host, authorization } = useHeaders()
602
- * ```
603
- */
604
- function useHeaders(ctx) {
605
- return useRequest(ctx).headers;
606
- }
607
-
608
668
  //#endregion
609
669
  //#region packages/event-http/src/composables/response.ts
610
670
  /**
@@ -687,6 +747,120 @@ function useHttpContext(ctx) {
687
747
  return ctx ?? (0, _wooksjs_event_core.current)();
688
748
  }
689
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
+
690
864
  //#endregion
691
865
  //#region packages/event-http/src/utils/time.ts
692
866
  function convertTime(time, unit = "ms") {
@@ -763,6 +937,70 @@ const cookieAttrFunc = {
763
937
  sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
764
938
  };
765
939
 
940
+ //#endregion
941
+ //#region packages/event-http/src/response/prerender.ts
942
+ const registry = /* @__PURE__ */ new WeakMap();
943
+ function weakEtag(json) {
944
+ return `W/"${(0, crypto.createHash)("sha256").update(json).digest("base64url").slice(0, 22)}"`;
945
+ }
946
+ /**
947
+ * Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
948
+ * (or an interceptor) responds with that same object, the response reuses the stored JSON instead
949
+ * of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
950
+ * requests return as is — e.g. a schema or metadata envelope built once and cached.
951
+ *
952
+ * With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
953
+ * that responds `200` with the object sets the `ETag` header and answers a matching
954
+ * `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
955
+ * and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
956
+ * on the response wins and disables the `304` handling for that response.
957
+ *
958
+ * **Contract: never mutate a registered object (or anything it references).** The stored JSON
959
+ * is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
960
+ * freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
961
+ *
962
+ * Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
963
+ * the first time). Entries are held weakly and disappear with the object.
964
+ *
965
+ * @param obj - A JSON-serializable object or array
966
+ * @param options - `{ etag: true }` to also compute a weak ETag
967
+ * @returns The same `obj`, so it can wrap a return value
968
+ *
969
+ * @example
970
+ * ```ts
971
+ * const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
972
+ * app.get('/meta', () => {
973
+ * useResponse().setHeader('cache-control', 'private, no-cache')
974
+ * return envelope // sent without re-serializing; 304 on a matching If-None-Match
975
+ * })
976
+ * ```
977
+ */
978
+ function prerenderJson(obj, options) {
979
+ let entry = registry.get(obj);
980
+ if (!entry) {
981
+ const json = JSON.stringify(obj);
982
+ if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
983
+ entry = { json };
984
+ registry.set(obj, entry);
985
+ }
986
+ if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
987
+ return obj;
988
+ }
989
+ /** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
990
+ function getPrerenderedJson(obj) {
991
+ return registry.get(obj);
992
+ }
993
+ /** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
994
+ function ifNoneMatchHas(header, etag) {
995
+ if (!header) return false;
996
+ const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
997
+ for (const part of header.split(",")) {
998
+ const tag = part.trim();
999
+ if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
1000
+ }
1001
+ return false;
1002
+ }
1003
+
766
1004
  //#endregion
767
1005
  //#region packages/event-http/src/response/http-response.ts
768
1006
  const hasFetchResponse = typeof globalThis.Response === "function";
@@ -792,8 +1030,10 @@ var HttpResponse = class {
792
1030
  * @param _req - The underlying Node.js `IncomingMessage`.
793
1031
  * @param _logger - Logger instance for error reporting.
794
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).
795
1035
  */
796
- constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
1036
+ constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
797
1037
  this._res = _res;
798
1038
  this._req = _req;
799
1039
  this._logger = _logger;
@@ -801,10 +1041,9 @@ var HttpResponse = class {
801
1041
  this._status = 0;
802
1042
  this._body = void 0;
803
1043
  this._headers = {};
804
- this._cookies = {};
805
- this._rawCookies = [];
806
1044
  this._hasCookies = false;
807
1045
  this._responded = false;
1046
+ this._compression = compression;
808
1047
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
809
1048
  }
810
1049
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
@@ -870,7 +1109,7 @@ var HttpResponse = class {
870
1109
  }
871
1110
  /** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
872
1111
  setCookie(name, value, attrs) {
873
- this._cookies[name] = {
1112
+ (this._cookies ??= {})[name] = {
874
1113
  value,
875
1114
  attrs: attrs || {}
876
1115
  };
@@ -879,23 +1118,23 @@ var HttpResponse = class {
879
1118
  }
880
1119
  /** Returns a previously set cookie's data, or `undefined` if not set. */
881
1120
  getCookie(name) {
882
- return this._cookies[name];
1121
+ return this._cookies?.[name];
883
1122
  }
884
1123
  /** Removes a cookie from the outgoing set list (chainable). */
885
1124
  removeCookie(name) {
886
- delete this._cookies[name];
1125
+ if (this._cookies) delete this._cookies[name];
887
1126
  return this;
888
1127
  }
889
1128
  /** Removes all outgoing cookies (chainable). */
890
1129
  clearCookies() {
891
- this._cookies = {};
892
- this._rawCookies = [];
1130
+ this._cookies = void 0;
1131
+ this._rawCookies = void 0;
893
1132
  this._hasCookies = false;
894
1133
  return this;
895
1134
  }
896
1135
  /** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
897
1136
  setCookieRaw(rawValue) {
898
- this._rawCookies.push(rawValue);
1137
+ (this._rawCookies ??= []).push(rawValue);
899
1138
  this._hasCookies = true;
900
1139
  return this;
901
1140
  }
@@ -910,8 +1149,10 @@ var HttpResponse = class {
910
1149
  */
911
1150
  getSetCookieStrings() {
912
1151
  const rendered = [];
913
- for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
914
- rendered.push(...this._rawCookies);
1152
+ if (this._cookies) {
1153
+ for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
1154
+ }
1155
+ if (this._rawCookies) rendered.push(...this._rawCookies);
915
1156
  return rendered;
916
1157
  }
917
1158
  /** Sets the `Cache-Control` header from a directive object (chainable). */
@@ -935,6 +1176,24 @@ var HttpResponse = class {
935
1176
  return this;
936
1177
  }
937
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
+ /**
938
1197
  * Returns the underlying Node.js `ServerResponse`.
939
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.
940
1199
  */
@@ -972,11 +1231,13 @@ var HttpResponse = class {
972
1231
  });
973
1232
  }
974
1233
  const rendered = this.renderBody();
1234
+ const prerendered = this.takePrerendered();
975
1235
  this.autoStatus(!!rendered);
976
- if (rendered) {
977
- const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
978
- this._headers["content-length"] = contentLength.toString();
979
- }
1236
+ if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
1237
+ status: this._status,
1238
+ headers: this._buildWebHeaders()
1239
+ });
1240
+ if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
980
1241
  const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
981
1242
  const webResponse = new globalThis.Response(webBody, {
982
1243
  status: this._status,
@@ -1021,6 +1282,11 @@ var HttpResponse = class {
1021
1282
  if (body instanceof Uint8Array) return body;
1022
1283
  if (typeof body === "object") {
1023
1284
  if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
1285
+ const prerendered = getPrerenderedJson(body);
1286
+ if (prerendered) {
1287
+ this._prerendered = prerendered;
1288
+ return prerendered.json;
1289
+ }
1024
1290
  return JSON.stringify(body);
1025
1291
  }
1026
1292
  throw new Error(`Unsupported body format "${typeof body}"`);
@@ -1041,6 +1307,7 @@ var HttpResponse = class {
1041
1307
  *
1042
1308
  * Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
1043
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.
1044
1311
  *
1045
1312
  * @throws Error if the response was already sent.
1046
1313
  */
@@ -1060,7 +1327,7 @@ var HttpResponse = class {
1060
1327
  const method = this._req.method;
1061
1328
  if (body instanceof stream.Readable) return this.sendStream(body, method);
1062
1329
  if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
1063
- this.sendRegular(method);
1330
+ return this.sendRegular(method);
1064
1331
  }
1065
1332
  finalizeCookies() {
1066
1333
  if (!this._hasCookies) return;
@@ -1072,6 +1339,35 @@ var HttpResponse = class {
1072
1339
  }
1073
1340
  this._hasCookies = false;
1074
1341
  }
1342
+ /**
1343
+ * For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
1344
+ * GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
1345
+ * matches into a bodiless `304`. Returns `true` when the response became `304`.
1346
+ * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
1347
+ */
1348
+ applyPrerenderedEtag(etag) {
1349
+ const method = this._req.method;
1350
+ if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
1351
+ this._headers.etag = etag;
1352
+ if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
1353
+ this._status = EHttpStatusCode.NotModified;
1354
+ for (const key in this._headers) {
1355
+ const lower = key.toLowerCase();
1356
+ if (lower === "content-type" || lower === "content-length") delete this._headers[key];
1357
+ }
1358
+ return true;
1359
+ }
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;
1370
+ }
1075
1371
  autoStatus(hasBody) {
1076
1372
  if (this._status) return;
1077
1373
  if (!hasBody) {
@@ -1117,13 +1413,101 @@ var HttpResponse = class {
1117
1413
  return this.pipeToResponse(stream.Readable.fromWeb(fetchResponse.body), "Error streaming fetch response body");
1118
1414
  }
1119
1415
  sendRegular(method) {
1120
- const renderedBody = this.renderBody();
1121
- this.autoStatus(!!renderedBody);
1122
- const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1123
- this._headers["content-length"] = contentLength.toString();
1124
- this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
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)) {
1423
+ this._res.writeHead(this._status, this._headers).end();
1424
+ return;
1425
+ }
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);
1125
1504
  }
1126
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
+ }
1127
1511
  /** Converts a Record of headers to a Web Standard `Headers` object. */
1128
1512
  function recordToWebHeaders(record) {
1129
1513
  const headers = new Headers();
@@ -1497,7 +1881,7 @@ function escapeHtml(value) {
1497
1881
  //#endregion
1498
1882
  //#region packages/event-http/src/response/wooks-http-response.ts
1499
1883
  let framework = {
1500
- version: "0.7.26",
1884
+ version: "0.7.28",
1501
1885
  poweredBy: "wooksjs",
1502
1886
  link: "https://wooks.moost.org/",
1503
1887
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1521,7 +1905,8 @@ var WooksHttpResponse = class extends HttpResponse {
1521
1905
  }
1522
1906
  renderError(data, ctx) {
1523
1907
  this._status = data.statusCode || 500;
1524
- const { has } = useAccept(ctx);
1908
+ const accept = ctx.get(httpKind.keys.req).headers.accept;
1909
+ const has = (type) => acceptHeaderHas(accept, type);
1525
1910
  if (has("json")) {
1526
1911
  this._headers["content-type"] = "application/json";
1527
1912
  this._body = JSON.stringify(data);
@@ -1592,6 +1977,7 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1592
1977
  this.logger = opts?.logger || this.getLogger(`[wooks-http]`);
1593
1978
  this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
1594
1979
  this.eventContextOptions = this.getEventContextOptions();
1980
+ this.compression = resolveCompression(opts?.compression);
1595
1981
  }
1596
1982
  /** Registers a handler for all HTTP methods on the given path. */
1597
1983
  all(path, handler) {
@@ -1714,8 +2100,9 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1714
2100
  const RequestLimits = this.opts?.requestLimits;
1715
2101
  const notFoundHandler = this.opts?.onNotFound;
1716
2102
  const defaultHeaders = this.opts?.defaultHeaders;
2103
+ const compression = this.compression;
1717
2104
  return (req, res) => {
1718
- const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
2105
+ const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
1719
2106
  const method = req.method || "";
1720
2107
  const url = req.url || "";
1721
2108
  createHttpContext(ctxOptions, {
@@ -1817,33 +2204,19 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1817
2204
  }
1818
2205
  }
1819
2206
  async processAsyncResult(promise, handlers, startIndex, ctx, response) {
1820
- try {
1821
- const result = await promise;
1822
- await this.respond(result, response, ctx);
2207
+ for (let i = startIndex; i < handlers.length; i++) try {
2208
+ const result = await (i === startIndex ? promise : handlers[i]());
2209
+ const sent = this.respond(result, response, ctx);
2210
+ if (sent) await sent;
1823
2211
  return result;
1824
2212
  } catch (error) {
1825
- const isLastHandler = startIndex === handlers.length - 1;
1826
2213
  if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1827
- if (isLastHandler) {
1828
- await this.respond(error, response, ctx);
2214
+ if (i === handlers.length - 1) {
2215
+ const sent = this.respond(error, response, ctx);
2216
+ if (sent) await sent;
1829
2217
  return error;
1830
2218
  }
1831
2219
  }
1832
- for (let i = startIndex + 1; i < handlers.length; i++) {
1833
- const handler = handlers[i];
1834
- const isLastHandler = i === handlers.length - 1;
1835
- try {
1836
- const result = await handler();
1837
- await this.respond(result, response, ctx);
1838
- return result;
1839
- } catch (error) {
1840
- if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1841
- if (isLastHandler) {
1842
- await this.respond(error, response, ctx);
1843
- return error;
1844
- }
1845
- }
1846
- }
1847
2220
  }
1848
2221
  /**
1849
2222
  * Programmatic route invocation using the Web Standard fetch API.
@@ -2128,7 +2501,10 @@ exports.createHttpApp = createHttpApp;
2128
2501
  exports.createHttpContext = createHttpContext;
2129
2502
  exports.httpKind = httpKind;
2130
2503
  exports.httpStatusCodes = httpStatusCodes;
2504
+ exports.isCompressibleType = isCompressibleType;
2505
+ exports.negotiateEncoding = negotiateEncoding;
2131
2506
  exports.prepareTestHttpContext = prepareTestHttpContext;
2507
+ exports.prerenderJson = prerenderJson;
2132
2508
  exports.rawBodySlot = rawBodySlot;
2133
2509
  exports.recordToWebHeaders = recordToWebHeaders;
2134
2510
  exports.renderCacheControl = renderCacheControl;