@wooksjs/event-http 0.7.26 → 0.7.27

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
  /**
@@ -763,6 +823,70 @@ const cookieAttrFunc = {
763
823
  sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
764
824
  };
765
825
 
826
+ //#endregion
827
+ //#region packages/event-http/src/response/prerender.ts
828
+ const registry = /* @__PURE__ */ new WeakMap();
829
+ function weakEtag(json) {
830
+ return `W/"${(0, crypto.createHash)("sha256").update(json).digest("base64url").slice(0, 22)}"`;
831
+ }
832
+ /**
833
+ * Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
834
+ * (or an interceptor) responds with that same object, the response reuses the stored JSON instead
835
+ * of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
836
+ * requests return as is — e.g. a schema or metadata envelope built once and cached.
837
+ *
838
+ * With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
839
+ * that responds `200` with the object sets the `ETag` header and answers a matching
840
+ * `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
841
+ * and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
842
+ * on the response wins and disables the `304` handling for that response.
843
+ *
844
+ * **Contract: never mutate a registered object (or anything it references).** The stored JSON
845
+ * is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
846
+ * freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
847
+ *
848
+ * Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
849
+ * the first time). Entries are held weakly and disappear with the object.
850
+ *
851
+ * @param obj - A JSON-serializable object or array
852
+ * @param options - `{ etag: true }` to also compute a weak ETag
853
+ * @returns The same `obj`, so it can wrap a return value
854
+ *
855
+ * @example
856
+ * ```ts
857
+ * const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
858
+ * app.get('/meta', () => {
859
+ * useResponse().setHeader('cache-control', 'private, no-cache')
860
+ * return envelope // sent without re-serializing; 304 on a matching If-None-Match
861
+ * })
862
+ * ```
863
+ */
864
+ function prerenderJson(obj, options) {
865
+ let entry = registry.get(obj);
866
+ if (!entry) {
867
+ const json = JSON.stringify(obj);
868
+ if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
869
+ entry = { json };
870
+ registry.set(obj, entry);
871
+ }
872
+ if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
873
+ return obj;
874
+ }
875
+ /** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
876
+ function getPrerenderedJson(obj) {
877
+ return registry.get(obj);
878
+ }
879
+ /** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
880
+ function ifNoneMatchHas(header, etag) {
881
+ if (!header) return false;
882
+ const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
883
+ for (const part of header.split(",")) {
884
+ const tag = part.trim();
885
+ if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
886
+ }
887
+ return false;
888
+ }
889
+
766
890
  //#endregion
767
891
  //#region packages/event-http/src/response/http-response.ts
768
892
  const hasFetchResponse = typeof globalThis.Response === "function";
@@ -801,8 +925,6 @@ var HttpResponse = class {
801
925
  this._status = 0;
802
926
  this._body = void 0;
803
927
  this._headers = {};
804
- this._cookies = {};
805
- this._rawCookies = [];
806
928
  this._hasCookies = false;
807
929
  this._responded = false;
808
930
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
@@ -870,7 +992,7 @@ var HttpResponse = class {
870
992
  }
871
993
  /** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
872
994
  setCookie(name, value, attrs) {
873
- this._cookies[name] = {
995
+ (this._cookies ??= {})[name] = {
874
996
  value,
875
997
  attrs: attrs || {}
876
998
  };
@@ -879,23 +1001,23 @@ var HttpResponse = class {
879
1001
  }
880
1002
  /** Returns a previously set cookie's data, or `undefined` if not set. */
881
1003
  getCookie(name) {
882
- return this._cookies[name];
1004
+ return this._cookies?.[name];
883
1005
  }
884
1006
  /** Removes a cookie from the outgoing set list (chainable). */
885
1007
  removeCookie(name) {
886
- delete this._cookies[name];
1008
+ if (this._cookies) delete this._cookies[name];
887
1009
  return this;
888
1010
  }
889
1011
  /** Removes all outgoing cookies (chainable). */
890
1012
  clearCookies() {
891
- this._cookies = {};
892
- this._rawCookies = [];
1013
+ this._cookies = void 0;
1014
+ this._rawCookies = void 0;
893
1015
  this._hasCookies = false;
894
1016
  return this;
895
1017
  }
896
1018
  /** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
897
1019
  setCookieRaw(rawValue) {
898
- this._rawCookies.push(rawValue);
1020
+ (this._rawCookies ??= []).push(rawValue);
899
1021
  this._hasCookies = true;
900
1022
  return this;
901
1023
  }
@@ -910,8 +1032,10 @@ var HttpResponse = class {
910
1032
  */
911
1033
  getSetCookieStrings() {
912
1034
  const rendered = [];
913
- for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
914
- rendered.push(...this._rawCookies);
1035
+ if (this._cookies) {
1036
+ for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
1037
+ }
1038
+ if (this._rawCookies) rendered.push(...this._rawCookies);
915
1039
  return rendered;
916
1040
  }
917
1041
  /** Sets the `Cache-Control` header from a directive object (chainable). */
@@ -973,6 +1097,10 @@ var HttpResponse = class {
973
1097
  }
974
1098
  const rendered = this.renderBody();
975
1099
  this.autoStatus(!!rendered);
1100
+ if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
1101
+ status: this._status,
1102
+ headers: this._buildWebHeaders()
1103
+ });
976
1104
  if (rendered) {
977
1105
  const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
978
1106
  this._headers["content-length"] = contentLength.toString();
@@ -1021,6 +1149,11 @@ var HttpResponse = class {
1021
1149
  if (body instanceof Uint8Array) return body;
1022
1150
  if (typeof body === "object") {
1023
1151
  if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
1152
+ const prerendered = getPrerenderedJson(body);
1153
+ if (prerendered) {
1154
+ this._prerenderedEtag = prerendered.etag;
1155
+ return prerendered.json;
1156
+ }
1024
1157
  return JSON.stringify(body);
1025
1158
  }
1026
1159
  throw new Error(`Unsupported body format "${typeof body}"`);
@@ -1072,6 +1205,31 @@ var HttpResponse = class {
1072
1205
  }
1073
1206
  this._hasCookies = false;
1074
1207
  }
1208
+ /**
1209
+ * For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
1210
+ * GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
1211
+ * matches into a bodiless `304`. Returns `true` when the response became `304`.
1212
+ * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
1213
+ */
1214
+ applyPrerenderedEtag() {
1215
+ const etag = this._prerenderedEtag;
1216
+ if (!etag) return false;
1217
+ this._prerenderedEtag = void 0;
1218
+ const method = this._req.method;
1219
+ if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.hasHeaderIgnoreCase("etag")) return false;
1220
+ this._headers.etag = etag;
1221
+ if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
1222
+ this._status = EHttpStatusCode.NotModified;
1223
+ for (const key in this._headers) {
1224
+ const lower = key.toLowerCase();
1225
+ if (lower === "content-type" || lower === "content-length") delete this._headers[key];
1226
+ }
1227
+ return true;
1228
+ }
1229
+ hasHeaderIgnoreCase(name) {
1230
+ for (const key in this._headers) if (key.toLowerCase() === name) return true;
1231
+ return false;
1232
+ }
1075
1233
  autoStatus(hasBody) {
1076
1234
  if (this._status) return;
1077
1235
  if (!hasBody) {
@@ -1119,6 +1277,10 @@ var HttpResponse = class {
1119
1277
  sendRegular(method) {
1120
1278
  const renderedBody = this.renderBody();
1121
1279
  this.autoStatus(!!renderedBody);
1280
+ if (this.applyPrerenderedEtag()) {
1281
+ this._res.writeHead(this._status, this._headers).end();
1282
+ return;
1283
+ }
1122
1284
  const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1123
1285
  this._headers["content-length"] = contentLength.toString();
1124
1286
  this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
@@ -1497,7 +1659,7 @@ function escapeHtml(value) {
1497
1659
  //#endregion
1498
1660
  //#region packages/event-http/src/response/wooks-http-response.ts
1499
1661
  let framework = {
1500
- version: "0.7.26",
1662
+ version: "0.7.27",
1501
1663
  poweredBy: "wooksjs",
1502
1664
  link: "https://wooks.moost.org/",
1503
1665
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1521,7 +1683,8 @@ var WooksHttpResponse = class extends HttpResponse {
1521
1683
  }
1522
1684
  renderError(data, ctx) {
1523
1685
  this._status = data.statusCode || 500;
1524
- const { has } = useAccept(ctx);
1686
+ const accept = ctx.get(httpKind.keys.req).headers.accept;
1687
+ const has = (type) => acceptHeaderHas(accept, type);
1525
1688
  if (has("json")) {
1526
1689
  this._headers["content-type"] = "application/json";
1527
1690
  this._body = JSON.stringify(data);
@@ -1817,33 +1980,19 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1817
1980
  }
1818
1981
  }
1819
1982
  async processAsyncResult(promise, handlers, startIndex, ctx, response) {
1820
- try {
1821
- const result = await promise;
1822
- await this.respond(result, response, ctx);
1983
+ for (let i = startIndex; i < handlers.length; i++) try {
1984
+ const result = await (i === startIndex ? promise : handlers[i]());
1985
+ const sent = this.respond(result, response, ctx);
1986
+ if (sent) await sent;
1823
1987
  return result;
1824
1988
  } catch (error) {
1825
- const isLastHandler = startIndex === handlers.length - 1;
1826
1989
  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);
1990
+ if (i === handlers.length - 1) {
1991
+ const sent = this.respond(error, response, ctx);
1992
+ if (sent) await sent;
1829
1993
  return error;
1830
1994
  }
1831
1995
  }
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
1996
  }
1848
1997
  /**
1849
1998
  * Programmatic route invocation using the Web Standard fetch API.
@@ -2129,6 +2278,7 @@ exports.createHttpContext = createHttpContext;
2129
2278
  exports.httpKind = httpKind;
2130
2279
  exports.httpStatusCodes = httpStatusCodes;
2131
2280
  exports.prepareTestHttpContext = prepareTestHttpContext;
2281
+ exports.prerenderJson = prerenderJson;
2132
2282
  exports.rawBodySlot = rawBodySlot;
2133
2283
  exports.recordToWebHeaders = recordToWebHeaders;
2134
2284
  exports.renderCacheControl = renderCacheControl;
package/dist/index.d.ts CHANGED
@@ -80,7 +80,7 @@ declare const rawBodySlot: _wooksjs_event_core.Cached<Promise<Buffer<ArrayBuffer
80
80
  * ```
81
81
  */
82
82
  declare const useRequest: _wooksjs_event_core.WookComposable<{
83
- raw: http.IncomingMessage;
83
+ raw: IncomingMessage;
84
84
  url: string | undefined;
85
85
  method: string | undefined;
86
86
  headers: http.IncomingHttpHeaders;
@@ -375,10 +375,14 @@ declare class HttpResponse {
375
375
  protected _status: EHttpStatusCode;
376
376
  protected _body: unknown;
377
377
  protected _headers: Record<string, string | string[]>;
378
- protected _cookies: Record<string, TSetCookieData>;
379
- protected _rawCookies: string[];
378
+ /** Outgoing named cookies — allocated on the first `setCookie()`. */
379
+ protected _cookies?: Record<string, TSetCookieData>;
380
+ /** Outgoing raw `Set-Cookie` strings — allocated on the first `setCookieRaw()`. */
381
+ protected _rawCookies?: string[];
380
382
  protected _hasCookies: boolean;
381
383
  protected _responded: boolean;
384
+ /** Weak ETag of the prerendered body picked by the last `renderBody()` (see `prerenderJson`). */
385
+ private _prerenderedEtag?;
382
386
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
383
387
  get status(): EHttpStatusCode;
384
388
  set status(value: EHttpStatusCode);
@@ -468,6 +472,14 @@ declare class HttpResponse {
468
472
  */
469
473
  send(): void | Promise<void>;
470
474
  private finalizeCookies;
475
+ /**
476
+ * For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
477
+ * GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
478
+ * matches into a bodiless `304`. Returns `true` when the response became `304`.
479
+ * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
480
+ */
481
+ private applyPrerenderedEtag;
482
+ private hasHeaderIgnoreCase;
471
483
  private autoStatus;
472
484
  private sendStream;
473
485
  /**
@@ -729,6 +741,49 @@ declare class WooksHttp extends WooksAdapterBase {
729
741
  */
730
742
  declare function createHttpApp(opts?: TWooksHttpOptions, wooks?: Wooks | WooksAdapterBase): WooksHttp;
731
743
 
744
+ /** Options for {@link prerenderJson}. */
745
+ interface TPrerenderJsonOptions {
746
+ /**
747
+ * Also compute a weak `ETag` from the serialized bytes. Responses that send the object then
748
+ * carry the `ETag` header and answer a matching `If-None-Match` (GET/HEAD, status 200) with
749
+ * `304 Not Modified`.
750
+ */
751
+ etag?: boolean;
752
+ }
753
+ /**
754
+ * Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
755
+ * (or an interceptor) responds with that same object, the response reuses the stored JSON instead
756
+ * of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
757
+ * requests return as is — e.g. a schema or metadata envelope built once and cached.
758
+ *
759
+ * With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
760
+ * that responds `200` with the object sets the `ETag` header and answers a matching
761
+ * `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
762
+ * and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
763
+ * on the response wins and disables the `304` handling for that response.
764
+ *
765
+ * **Contract: never mutate a registered object (or anything it references).** The stored JSON
766
+ * is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
767
+ * freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
768
+ *
769
+ * Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
770
+ * the first time). Entries are held weakly and disappear with the object.
771
+ *
772
+ * @param obj - A JSON-serializable object or array
773
+ * @param options - `{ etag: true }` to also compute a weak ETag
774
+ * @returns The same `obj`, so it can wrap a return value
775
+ *
776
+ * @example
777
+ * ```ts
778
+ * const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
779
+ * app.get('/meta', () => {
780
+ * useResponse().setHeader('cache-control', 'private, no-cache')
781
+ * return envelope // sent without re-serializing; 304 on a matching If-None-Match
782
+ * })
783
+ * ```
784
+ */
785
+ declare function prerenderJson<T extends object>(obj: T, options?: TPrerenderJsonOptions): T;
786
+
732
787
  /**
733
788
  * Configuration for `securityHeaders()`. Each option accepts a `string` (override value),
734
789
  * `false` (disable), or `undefined` (use default). `strictTransportSecurity` has no default (opt-in only).
@@ -793,5 +848,5 @@ interface TTestHttpContext {
793
848
  */
794
849
  declare function prepareTestHttpContext(options: TTestHttpContext): <T>(cb: (...a: any[]) => T) => T;
795
850
 
796
- 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, useRequest, useResponse, useUrlParams };
797
- export type { KnownAcceptType, KnownAuthType, SecurityHeadersOptions, TCacheControl, TCookieAttributes, TCookieAttributesInput, THttpEventData, TRequestLimits, TSetCookieData, TTestHttpContext, TWooksErrorBody, TWooksErrorBodyExt, TWooksHttpOptions };
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 };
package/dist/index.mjs CHANGED
@@ -7,6 +7,7 @@ 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
  /**
@@ -734,6 +794,70 @@ const cookieAttrFunc = {
734
794
  sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
735
795
  };
736
796
 
797
+ //#endregion
798
+ //#region packages/event-http/src/response/prerender.ts
799
+ const registry = /* @__PURE__ */ new WeakMap();
800
+ function weakEtag(json) {
801
+ return `W/"${createHash("sha256").update(json).digest("base64url").slice(0, 22)}"`;
802
+ }
803
+ /**
804
+ * Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
805
+ * (or an interceptor) responds with that same object, the response reuses the stored JSON instead
806
+ * of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
807
+ * requests return as is — e.g. a schema or metadata envelope built once and cached.
808
+ *
809
+ * With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
810
+ * that responds `200` with the object sets the `ETag` header and answers a matching
811
+ * `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
812
+ * and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
813
+ * on the response wins and disables the `304` handling for that response.
814
+ *
815
+ * **Contract: never mutate a registered object (or anything it references).** The stored JSON
816
+ * is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
817
+ * freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
818
+ *
819
+ * Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
820
+ * the first time). Entries are held weakly and disappear with the object.
821
+ *
822
+ * @param obj - A JSON-serializable object or array
823
+ * @param options - `{ etag: true }` to also compute a weak ETag
824
+ * @returns The same `obj`, so it can wrap a return value
825
+ *
826
+ * @example
827
+ * ```ts
828
+ * const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
829
+ * app.get('/meta', () => {
830
+ * useResponse().setHeader('cache-control', 'private, no-cache')
831
+ * return envelope // sent without re-serializing; 304 on a matching If-None-Match
832
+ * })
833
+ * ```
834
+ */
835
+ function prerenderJson(obj, options) {
836
+ let entry = registry.get(obj);
837
+ if (!entry) {
838
+ const json = JSON.stringify(obj);
839
+ if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
840
+ entry = { json };
841
+ registry.set(obj, entry);
842
+ }
843
+ if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
844
+ return obj;
845
+ }
846
+ /** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
847
+ function getPrerenderedJson(obj) {
848
+ return registry.get(obj);
849
+ }
850
+ /** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
851
+ function ifNoneMatchHas(header, etag) {
852
+ if (!header) return false;
853
+ const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
854
+ for (const part of header.split(",")) {
855
+ const tag = part.trim();
856
+ if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
857
+ }
858
+ return false;
859
+ }
860
+
737
861
  //#endregion
738
862
  //#region packages/event-http/src/response/http-response.ts
739
863
  const hasFetchResponse = typeof globalThis.Response === "function";
@@ -772,8 +896,6 @@ var HttpResponse = class {
772
896
  this._status = 0;
773
897
  this._body = void 0;
774
898
  this._headers = {};
775
- this._cookies = {};
776
- this._rawCookies = [];
777
899
  this._hasCookies = false;
778
900
  this._responded = false;
779
901
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
@@ -841,7 +963,7 @@ var HttpResponse = class {
841
963
  }
842
964
  /** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
843
965
  setCookie(name, value, attrs) {
844
- this._cookies[name] = {
966
+ (this._cookies ??= {})[name] = {
845
967
  value,
846
968
  attrs: attrs || {}
847
969
  };
@@ -850,23 +972,23 @@ var HttpResponse = class {
850
972
  }
851
973
  /** Returns a previously set cookie's data, or `undefined` if not set. */
852
974
  getCookie(name) {
853
- return this._cookies[name];
975
+ return this._cookies?.[name];
854
976
  }
855
977
  /** Removes a cookie from the outgoing set list (chainable). */
856
978
  removeCookie(name) {
857
- delete this._cookies[name];
979
+ if (this._cookies) delete this._cookies[name];
858
980
  return this;
859
981
  }
860
982
  /** Removes all outgoing cookies (chainable). */
861
983
  clearCookies() {
862
- this._cookies = {};
863
- this._rawCookies = [];
984
+ this._cookies = void 0;
985
+ this._rawCookies = void 0;
864
986
  this._hasCookies = false;
865
987
  return this;
866
988
  }
867
989
  /** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
868
990
  setCookieRaw(rawValue) {
869
- this._rawCookies.push(rawValue);
991
+ (this._rawCookies ??= []).push(rawValue);
870
992
  this._hasCookies = true;
871
993
  return this;
872
994
  }
@@ -881,8 +1003,10 @@ var HttpResponse = class {
881
1003
  */
882
1004
  getSetCookieStrings() {
883
1005
  const rendered = [];
884
- for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
885
- rendered.push(...this._rawCookies);
1006
+ if (this._cookies) {
1007
+ for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
1008
+ }
1009
+ if (this._rawCookies) rendered.push(...this._rawCookies);
886
1010
  return rendered;
887
1011
  }
888
1012
  /** Sets the `Cache-Control` header from a directive object (chainable). */
@@ -944,6 +1068,10 @@ var HttpResponse = class {
944
1068
  }
945
1069
  const rendered = this.renderBody();
946
1070
  this.autoStatus(!!rendered);
1071
+ if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
1072
+ status: this._status,
1073
+ headers: this._buildWebHeaders()
1074
+ });
947
1075
  if (rendered) {
948
1076
  const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
949
1077
  this._headers["content-length"] = contentLength.toString();
@@ -992,6 +1120,11 @@ var HttpResponse = class {
992
1120
  if (body instanceof Uint8Array) return body;
993
1121
  if (typeof body === "object") {
994
1122
  if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
1123
+ const prerendered = getPrerenderedJson(body);
1124
+ if (prerendered) {
1125
+ this._prerenderedEtag = prerendered.etag;
1126
+ return prerendered.json;
1127
+ }
995
1128
  return JSON.stringify(body);
996
1129
  }
997
1130
  throw new Error(`Unsupported body format "${typeof body}"`);
@@ -1043,6 +1176,31 @@ var HttpResponse = class {
1043
1176
  }
1044
1177
  this._hasCookies = false;
1045
1178
  }
1179
+ /**
1180
+ * For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
1181
+ * GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
1182
+ * matches into a bodiless `304`. Returns `true` when the response became `304`.
1183
+ * Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
1184
+ */
1185
+ applyPrerenderedEtag() {
1186
+ const etag = this._prerenderedEtag;
1187
+ if (!etag) return false;
1188
+ this._prerenderedEtag = void 0;
1189
+ const method = this._req.method;
1190
+ if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.hasHeaderIgnoreCase("etag")) return false;
1191
+ this._headers.etag = etag;
1192
+ if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
1193
+ this._status = EHttpStatusCode.NotModified;
1194
+ for (const key in this._headers) {
1195
+ const lower = key.toLowerCase();
1196
+ if (lower === "content-type" || lower === "content-length") delete this._headers[key];
1197
+ }
1198
+ return true;
1199
+ }
1200
+ hasHeaderIgnoreCase(name) {
1201
+ for (const key in this._headers) if (key.toLowerCase() === name) return true;
1202
+ return false;
1203
+ }
1046
1204
  autoStatus(hasBody) {
1047
1205
  if (this._status) return;
1048
1206
  if (!hasBody) {
@@ -1090,6 +1248,10 @@ var HttpResponse = class {
1090
1248
  sendRegular(method) {
1091
1249
  const renderedBody = this.renderBody();
1092
1250
  this.autoStatus(!!renderedBody);
1251
+ if (this.applyPrerenderedEtag()) {
1252
+ this._res.writeHead(this._status, this._headers).end();
1253
+ return;
1254
+ }
1093
1255
  const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1094
1256
  this._headers["content-length"] = contentLength.toString();
1095
1257
  this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
@@ -1468,7 +1630,7 @@ function escapeHtml(value) {
1468
1630
  //#endregion
1469
1631
  //#region packages/event-http/src/response/wooks-http-response.ts
1470
1632
  let framework = {
1471
- version: "0.7.26",
1633
+ version: "0.7.27",
1472
1634
  poweredBy: "wooksjs",
1473
1635
  link: "https://wooks.moost.org/",
1474
1636
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1492,7 +1654,8 @@ var WooksHttpResponse = class extends HttpResponse {
1492
1654
  }
1493
1655
  renderError(data, ctx) {
1494
1656
  this._status = data.statusCode || 500;
1495
- const { has } = useAccept(ctx);
1657
+ const accept = ctx.get(httpKind.keys.req).headers.accept;
1658
+ const has = (type) => acceptHeaderHas(accept, type);
1496
1659
  if (has("json")) {
1497
1660
  this._headers["content-type"] = "application/json";
1498
1661
  this._body = JSON.stringify(data);
@@ -1788,33 +1951,19 @@ var WooksHttp = class extends WooksAdapterBase {
1788
1951
  }
1789
1952
  }
1790
1953
  async processAsyncResult(promise, handlers, startIndex, ctx, response) {
1791
- try {
1792
- const result = await promise;
1793
- await this.respond(result, response, ctx);
1954
+ for (let i = startIndex; i < handlers.length; i++) try {
1955
+ const result = await (i === startIndex ? promise : handlers[i]());
1956
+ const sent = this.respond(result, response, ctx);
1957
+ if (sent) await sent;
1794
1958
  return result;
1795
1959
  } catch (error) {
1796
- const isLastHandler = startIndex === handlers.length - 1;
1797
1960
  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);
1961
+ if (i === handlers.length - 1) {
1962
+ const sent = this.respond(error, response, ctx);
1963
+ if (sent) await sent;
1800
1964
  return error;
1801
1965
  }
1802
1966
  }
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
1967
  }
1819
1968
  /**
1820
1969
  * Programmatic route invocation using the Web Standard fetch API.
@@ -2087,4 +2236,4 @@ function prepareTestHttpContext(options) {
2087
2236
  }
2088
2237
 
2089
2238
  //#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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-http",
3
- "version": "0.7.26",
3
+ "version": "0.7.27",
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.26",
46
- "wooks": "^0.7.26"
45
+ "@wooksjs/event-core": "^0.7.27",
46
+ "wooks": "^0.7.27"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@prostojs/logger": "^0.4.3",
50
- "@prostojs/router": "^0.3.4",
51
- "@wooksjs/event-core": "^0.7.26",
52
- "wooks": "^0.7.26"
50
+ "@prostojs/router": "^0.3.5",
51
+ "wooks": "^0.7.27",
52
+ "@wooksjs/event-core": "^0.7.27"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "rolldown -c ../../rolldown.config.mjs"