@wooksjs/event-http 0.7.25 → 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,19 +434,73 @@ 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
  });
431
- /** @internal Exported for test pre-seeding via `ctx.set(rawBodySlot, ...)`. */
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
+ }
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);
434
506
  const encs = ctx.get(contentEncodingsSlot);
@@ -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);
@@ -568,18 +641,28 @@ const useRequest = (0, _wooksjs_event_core.defineWook)((ctx) => {
568
641
  setMaxRatio
569
642
  };
570
643
  });
571
-
572
- //#endregion
573
- //#region packages/event-http/src/composables/headers.ts
574
644
  /**
575
- * Returns the incoming request headers.
645
+ * Seeds the raw request body of `ctx`: `useRequest(ctx).rawBody()` resolves to `raw` instead of
646
+ * reading the incoming request stream. To seed what `useBody()` (`@wooksjs/http-body`) parses,
647
+ * use its `seedBody()` — a parent's already parsed body is otherwise read through.
648
+ *
649
+ * Use it for a child event context (`new EventContext({ logger, parent })`) that runs a handler
650
+ * with its own payload: the child still reads everything else (request, headers, auth) through
651
+ * its parent, but never the parent's body. Call it before anything in the child reads the body.
652
+ *
653
+ * @param ctx - The context to seed — usually a child of the current HTTP event
654
+ * @param raw - The body bytes (a string is encoded as UTF-8)
655
+ *
576
656
  * @example
577
657
  * ```ts
578
- * const { host, authorization } = useHeaders()
658
+ * const child = new EventContext({ logger: parent.logger, parent })
659
+ * seedRawBody(child, JSON.stringify({ ids: [1, 2] }))
660
+ * await run(child, () => useRequest().rawBody()) // Buffer of '{"ids":[1,2]}'
579
661
  * ```
580
662
  */
581
- function useHeaders(ctx) {
582
- return useRequest(ctx).headers;
663
+ function seedRawBody(ctx, raw) {
664
+ ctx.setOwn(rawBodySlot, Promise.resolve(buffer.Buffer.isBuffer(raw) ? raw : buffer.Buffer.from(raw)));
665
+ if (ctx.parent) ctx.getOwn(useRequest._slot);
583
666
  }
584
667
 
585
668
  //#endregion
@@ -740,6 +823,70 @@ const cookieAttrFunc = {
740
823
  sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
741
824
  };
742
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
+
743
890
  //#endregion
744
891
  //#region packages/event-http/src/response/http-response.ts
745
892
  const hasFetchResponse = typeof globalThis.Response === "function";
@@ -778,8 +925,6 @@ var HttpResponse = class {
778
925
  this._status = 0;
779
926
  this._body = void 0;
780
927
  this._headers = {};
781
- this._cookies = {};
782
- this._rawCookies = [];
783
928
  this._hasCookies = false;
784
929
  this._responded = false;
785
930
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
@@ -847,7 +992,7 @@ var HttpResponse = class {
847
992
  }
848
993
  /** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
849
994
  setCookie(name, value, attrs) {
850
- this._cookies[name] = {
995
+ (this._cookies ??= {})[name] = {
851
996
  value,
852
997
  attrs: attrs || {}
853
998
  };
@@ -856,23 +1001,23 @@ var HttpResponse = class {
856
1001
  }
857
1002
  /** Returns a previously set cookie's data, or `undefined` if not set. */
858
1003
  getCookie(name) {
859
- return this._cookies[name];
1004
+ return this._cookies?.[name];
860
1005
  }
861
1006
  /** Removes a cookie from the outgoing set list (chainable). */
862
1007
  removeCookie(name) {
863
- delete this._cookies[name];
1008
+ if (this._cookies) delete this._cookies[name];
864
1009
  return this;
865
1010
  }
866
1011
  /** Removes all outgoing cookies (chainable). */
867
1012
  clearCookies() {
868
- this._cookies = {};
869
- this._rawCookies = [];
1013
+ this._cookies = void 0;
1014
+ this._rawCookies = void 0;
870
1015
  this._hasCookies = false;
871
1016
  return this;
872
1017
  }
873
1018
  /** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
874
1019
  setCookieRaw(rawValue) {
875
- this._rawCookies.push(rawValue);
1020
+ (this._rawCookies ??= []).push(rawValue);
876
1021
  this._hasCookies = true;
877
1022
  return this;
878
1023
  }
@@ -887,8 +1032,10 @@ var HttpResponse = class {
887
1032
  */
888
1033
  getSetCookieStrings() {
889
1034
  const rendered = [];
890
- for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
891
- 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);
892
1039
  return rendered;
893
1040
  }
894
1041
  /** Sets the `Cache-Control` header from a directive object (chainable). */
@@ -950,6 +1097,10 @@ var HttpResponse = class {
950
1097
  }
951
1098
  const rendered = this.renderBody();
952
1099
  this.autoStatus(!!rendered);
1100
+ if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
1101
+ status: this._status,
1102
+ headers: this._buildWebHeaders()
1103
+ });
953
1104
  if (rendered) {
954
1105
  const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
955
1106
  this._headers["content-length"] = contentLength.toString();
@@ -998,6 +1149,11 @@ var HttpResponse = class {
998
1149
  if (body instanceof Uint8Array) return body;
999
1150
  if (typeof body === "object") {
1000
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
+ }
1001
1157
  return JSON.stringify(body);
1002
1158
  }
1003
1159
  throw new Error(`Unsupported body format "${typeof body}"`);
@@ -1049,6 +1205,31 @@ var HttpResponse = class {
1049
1205
  }
1050
1206
  this._hasCookies = false;
1051
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
+ }
1052
1233
  autoStatus(hasBody) {
1053
1234
  if (this._status) return;
1054
1235
  if (!hasBody) {
@@ -1096,6 +1277,10 @@ var HttpResponse = class {
1096
1277
  sendRegular(method) {
1097
1278
  const renderedBody = this.renderBody();
1098
1279
  this.autoStatus(!!renderedBody);
1280
+ if (this.applyPrerenderedEtag()) {
1281
+ this._res.writeHead(this._status, this._headers).end();
1282
+ return;
1283
+ }
1099
1284
  const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1100
1285
  this._headers["content-length"] = contentLength.toString();
1101
1286
  this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
@@ -1474,7 +1659,7 @@ function escapeHtml(value) {
1474
1659
  //#endregion
1475
1660
  //#region packages/event-http/src/response/wooks-http-response.ts
1476
1661
  let framework = {
1477
- version: "0.7.25",
1662
+ version: "0.7.27",
1478
1663
  poweredBy: "wooksjs",
1479
1664
  link: "https://wooks.moost.org/",
1480
1665
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1498,7 +1683,8 @@ var WooksHttpResponse = class extends HttpResponse {
1498
1683
  }
1499
1684
  renderError(data, ctx) {
1500
1685
  this._status = data.statusCode || 500;
1501
- const { has } = useAccept(ctx);
1686
+ const accept = ctx.get(httpKind.keys.req).headers.accept;
1687
+ const has = (type) => acceptHeaderHas(accept, type);
1502
1688
  if (has("json")) {
1503
1689
  this._headers["content-type"] = "application/json";
1504
1690
  this._body = JSON.stringify(data);
@@ -1794,33 +1980,19 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1794
1980
  }
1795
1981
  }
1796
1982
  async processAsyncResult(promise, handlers, startIndex, ctx, response) {
1797
- try {
1798
- const result = await promise;
1799
- 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;
1800
1987
  return result;
1801
1988
  } catch (error) {
1802
- const isLastHandler = startIndex === handlers.length - 1;
1803
1989
  if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1804
- if (isLastHandler) {
1805
- 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;
1806
1993
  return error;
1807
1994
  }
1808
1995
  }
1809
- for (let i = startIndex + 1; i < handlers.length; i++) {
1810
- const handler = handlers[i];
1811
- const isLastHandler = i === handlers.length - 1;
1812
- try {
1813
- const result = await handler();
1814
- await this.respond(result, response, ctx);
1815
- return result;
1816
- } catch (error) {
1817
- if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1818
- if (isLastHandler) {
1819
- await this.respond(error, response, ctx);
1820
- return error;
1821
- }
1822
- }
1823
- }
1824
1996
  }
1825
1997
  /**
1826
1998
  * Programmatic route invocation using the Web Standard fetch API.
@@ -1884,7 +2056,7 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
1884
2056
  requestLimits
1885
2057
  }, async () => {
1886
2058
  const ctx = (0, _wooksjs_event_core.current)();
1887
- if (bodyBuffer) ctx.set(rawBodySlot, Promise.resolve(bodyBuffer));
2059
+ if (bodyBuffer) seedRawBody(ctx, bodyBuffer);
1888
2060
  try {
1889
2061
  const handlers = this.wooks.lookupHandlers(method, pathname, ctx);
1890
2062
  if (handlers) {
@@ -2088,10 +2260,7 @@ function prepareTestHttpContext(options) {
2088
2260
  requestLimits: options.requestLimits
2089
2261
  });
2090
2262
  if (options.params) ctx.set(_wooksjs_event_core.routeParamsKey, options.params);
2091
- if (options.rawBody !== void 0) {
2092
- const buf = buffer.Buffer.isBuffer(options.rawBody) ? options.rawBody : buffer.Buffer.from(options.rawBody);
2093
- ctx.set(rawBodySlot, Promise.resolve(buf));
2094
- }
2263
+ if (options.rawBody !== void 0) seedRawBody(ctx, options.rawBody);
2095
2264
  return (cb) => (0, _wooksjs_event_core.run)(ctx, cb);
2096
2265
  }
2097
2266
 
@@ -2109,10 +2278,12 @@ exports.createHttpContext = createHttpContext;
2109
2278
  exports.httpKind = httpKind;
2110
2279
  exports.httpStatusCodes = httpStatusCodes;
2111
2280
  exports.prepareTestHttpContext = prepareTestHttpContext;
2281
+ exports.prerenderJson = prerenderJson;
2112
2282
  exports.rawBodySlot = rawBodySlot;
2113
2283
  exports.recordToWebHeaders = recordToWebHeaders;
2114
2284
  exports.renderCacheControl = renderCacheControl;
2115
2285
  exports.securityHeaders = securityHeaders;
2286
+ exports.seedRawBody = seedRawBody;
2116
2287
  exports.useAccept = useAccept;
2117
2288
  exports.useAuthorization = useAuthorization;
2118
2289
  exports.useCookies = useCookies;
package/dist/index.d.ts CHANGED
@@ -69,7 +69,7 @@ declare const DEFAULT_LIMITS: {
69
69
  readonly maxRatio: 100;
70
70
  readonly readTimeoutMs: 10000;
71
71
  };
72
- /** @internal Exported for test pre-seeding via `ctx.set(rawBodySlot, ...)`. */
72
+ /** @internal Exported for test pre-seeding — use {@link seedRawBody} instead. */
73
73
  declare const rawBodySlot: _wooksjs_event_core.Cached<Promise<Buffer<ArrayBufferLike>>>;
74
74
  /**
75
75
  * Provides access to the incoming HTTP request (method, url, headers, body, IP).
@@ -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;
@@ -103,6 +103,26 @@ declare const useRequest: _wooksjs_event_core.WookComposable<{
103
103
  getMaxRatio: () => number;
104
104
  setMaxRatio: (limit: number) => void;
105
105
  }>;
106
+ /**
107
+ * Seeds the raw request body of `ctx`: `useRequest(ctx).rawBody()` resolves to `raw` instead of
108
+ * reading the incoming request stream. To seed what `useBody()` (`@wooksjs/http-body`) parses,
109
+ * use its `seedBody()` — a parent's already parsed body is otherwise read through.
110
+ *
111
+ * Use it for a child event context (`new EventContext({ logger, parent })`) that runs a handler
112
+ * with its own payload: the child still reads everything else (request, headers, auth) through
113
+ * its parent, but never the parent's body. Call it before anything in the child reads the body.
114
+ *
115
+ * @param ctx - The context to seed — usually a child of the current HTTP event
116
+ * @param raw - The body bytes (a string is encoded as UTF-8)
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * const child = new EventContext({ logger: parent.logger, parent })
121
+ * seedRawBody(child, JSON.stringify({ ids: [1, 2] }))
122
+ * await run(child, () => useRequest().rawBody()) // Buffer of '{"ids":[1,2]}'
123
+ * ```
124
+ */
125
+ declare function seedRawBody(ctx: EventContext, raw: Buffer | string): void;
106
126
 
107
127
  /** Maps numeric HTTP status codes to their human-readable descriptions. */
108
128
  declare const httpStatusCodes: {
@@ -355,10 +375,14 @@ declare class HttpResponse {
355
375
  protected _status: EHttpStatusCode;
356
376
  protected _body: unknown;
357
377
  protected _headers: Record<string, string | string[]>;
358
- protected _cookies: Record<string, TSetCookieData>;
359
- 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[];
360
382
  protected _hasCookies: boolean;
361
383
  protected _responded: boolean;
384
+ /** Weak ETag of the prerendered body picked by the last `renderBody()` (see `prerenderJson`). */
385
+ private _prerenderedEtag?;
362
386
  /** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
363
387
  get status(): EHttpStatusCode;
364
388
  set status(value: EHttpStatusCode);
@@ -448,6 +472,14 @@ declare class HttpResponse {
448
472
  */
449
473
  send(): void | Promise<void>;
450
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;
451
483
  private autoStatus;
452
484
  private sendStream;
453
485
  /**
@@ -709,6 +741,49 @@ declare class WooksHttp extends WooksAdapterBase {
709
741
  */
710
742
  declare function createHttpApp(opts?: TWooksHttpOptions, wooks?: Wooks | WooksAdapterBase): WooksHttp;
711
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
+
712
787
  /**
713
788
  * Configuration for `securityHeaders()`. Each option accepts a `string` (override value),
714
789
  * `false` (disable), or `undefined` (use default). `strictTransportSecurity` has no default (opt-in only).
@@ -773,5 +848,5 @@ interface TTestHttpContext {
773
848
  */
774
849
  declare function prepareTestHttpContext(options: TTestHttpContext): <T>(cb: (...a: any[]) => T) => T;
775
850
 
776
- export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useRequest, useResponse, useUrlParams };
777
- 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,19 +405,73 @@ 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
  });
402
- /** @internal Exported for test pre-seeding via `ctx.set(rawBodySlot, ...)`. */
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
+ }
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);
405
477
  const encs = ctx.get(contentEncodingsSlot);
@@ -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);
@@ -539,18 +612,28 @@ const useRequest = defineWook((ctx) => {
539
612
  setMaxRatio
540
613
  };
541
614
  });
542
-
543
- //#endregion
544
- //#region packages/event-http/src/composables/headers.ts
545
615
  /**
546
- * Returns the incoming request headers.
616
+ * Seeds the raw request body of `ctx`: `useRequest(ctx).rawBody()` resolves to `raw` instead of
617
+ * reading the incoming request stream. To seed what `useBody()` (`@wooksjs/http-body`) parses,
618
+ * use its `seedBody()` — a parent's already parsed body is otherwise read through.
619
+ *
620
+ * Use it for a child event context (`new EventContext({ logger, parent })`) that runs a handler
621
+ * with its own payload: the child still reads everything else (request, headers, auth) through
622
+ * its parent, but never the parent's body. Call it before anything in the child reads the body.
623
+ *
624
+ * @param ctx - The context to seed — usually a child of the current HTTP event
625
+ * @param raw - The body bytes (a string is encoded as UTF-8)
626
+ *
547
627
  * @example
548
628
  * ```ts
549
- * const { host, authorization } = useHeaders()
629
+ * const child = new EventContext({ logger: parent.logger, parent })
630
+ * seedRawBody(child, JSON.stringify({ ids: [1, 2] }))
631
+ * await run(child, () => useRequest().rawBody()) // Buffer of '{"ids":[1,2]}'
550
632
  * ```
551
633
  */
552
- function useHeaders(ctx) {
553
- return useRequest(ctx).headers;
634
+ function seedRawBody(ctx, raw) {
635
+ ctx.setOwn(rawBodySlot, Promise.resolve(Buffer$1.isBuffer(raw) ? raw : Buffer$1.from(raw)));
636
+ if (ctx.parent) ctx.getOwn(useRequest._slot);
554
637
  }
555
638
 
556
639
  //#endregion
@@ -711,6 +794,70 @@ const cookieAttrFunc = {
711
794
  sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
712
795
  };
713
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
+
714
861
  //#endregion
715
862
  //#region packages/event-http/src/response/http-response.ts
716
863
  const hasFetchResponse = typeof globalThis.Response === "function";
@@ -749,8 +896,6 @@ var HttpResponse = class {
749
896
  this._status = 0;
750
897
  this._body = void 0;
751
898
  this._headers = {};
752
- this._cookies = {};
753
- this._rawCookies = [];
754
899
  this._hasCookies = false;
755
900
  this._responded = false;
756
901
  if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
@@ -818,7 +963,7 @@ var HttpResponse = class {
818
963
  }
819
964
  /** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
820
965
  setCookie(name, value, attrs) {
821
- this._cookies[name] = {
966
+ (this._cookies ??= {})[name] = {
822
967
  value,
823
968
  attrs: attrs || {}
824
969
  };
@@ -827,23 +972,23 @@ var HttpResponse = class {
827
972
  }
828
973
  /** Returns a previously set cookie's data, or `undefined` if not set. */
829
974
  getCookie(name) {
830
- return this._cookies[name];
975
+ return this._cookies?.[name];
831
976
  }
832
977
  /** Removes a cookie from the outgoing set list (chainable). */
833
978
  removeCookie(name) {
834
- delete this._cookies[name];
979
+ if (this._cookies) delete this._cookies[name];
835
980
  return this;
836
981
  }
837
982
  /** Removes all outgoing cookies (chainable). */
838
983
  clearCookies() {
839
- this._cookies = {};
840
- this._rawCookies = [];
984
+ this._cookies = void 0;
985
+ this._rawCookies = void 0;
841
986
  this._hasCookies = false;
842
987
  return this;
843
988
  }
844
989
  /** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
845
990
  setCookieRaw(rawValue) {
846
- this._rawCookies.push(rawValue);
991
+ (this._rawCookies ??= []).push(rawValue);
847
992
  this._hasCookies = true;
848
993
  return this;
849
994
  }
@@ -858,8 +1003,10 @@ var HttpResponse = class {
858
1003
  */
859
1004
  getSetCookieStrings() {
860
1005
  const rendered = [];
861
- for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
862
- 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);
863
1010
  return rendered;
864
1011
  }
865
1012
  /** Sets the `Cache-Control` header from a directive object (chainable). */
@@ -921,6 +1068,10 @@ var HttpResponse = class {
921
1068
  }
922
1069
  const rendered = this.renderBody();
923
1070
  this.autoStatus(!!rendered);
1071
+ if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
1072
+ status: this._status,
1073
+ headers: this._buildWebHeaders()
1074
+ });
924
1075
  if (rendered) {
925
1076
  const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
926
1077
  this._headers["content-length"] = contentLength.toString();
@@ -969,6 +1120,11 @@ var HttpResponse = class {
969
1120
  if (body instanceof Uint8Array) return body;
970
1121
  if (typeof body === "object") {
971
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
+ }
972
1128
  return JSON.stringify(body);
973
1129
  }
974
1130
  throw new Error(`Unsupported body format "${typeof body}"`);
@@ -1020,6 +1176,31 @@ var HttpResponse = class {
1020
1176
  }
1021
1177
  this._hasCookies = false;
1022
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
+ }
1023
1204
  autoStatus(hasBody) {
1024
1205
  if (this._status) return;
1025
1206
  if (!hasBody) {
@@ -1067,6 +1248,10 @@ var HttpResponse = class {
1067
1248
  sendRegular(method) {
1068
1249
  const renderedBody = this.renderBody();
1069
1250
  this.autoStatus(!!renderedBody);
1251
+ if (this.applyPrerenderedEtag()) {
1252
+ this._res.writeHead(this._status, this._headers).end();
1253
+ return;
1254
+ }
1070
1255
  const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
1071
1256
  this._headers["content-length"] = contentLength.toString();
1072
1257
  this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
@@ -1445,7 +1630,7 @@ function escapeHtml(value) {
1445
1630
  //#endregion
1446
1631
  //#region packages/event-http/src/response/wooks-http-response.ts
1447
1632
  let framework = {
1448
- version: "0.7.25",
1633
+ version: "0.7.27",
1449
1634
  poweredBy: "wooksjs",
1450
1635
  link: "https://wooks.moost.org/",
1451
1636
  image: "https://wooks.moost.org/wooks-full-logo.svg"
@@ -1469,7 +1654,8 @@ var WooksHttpResponse = class extends HttpResponse {
1469
1654
  }
1470
1655
  renderError(data, ctx) {
1471
1656
  this._status = data.statusCode || 500;
1472
- const { has } = useAccept(ctx);
1657
+ const accept = ctx.get(httpKind.keys.req).headers.accept;
1658
+ const has = (type) => acceptHeaderHas(accept, type);
1473
1659
  if (has("json")) {
1474
1660
  this._headers["content-type"] = "application/json";
1475
1661
  this._body = JSON.stringify(data);
@@ -1765,33 +1951,19 @@ var WooksHttp = class extends WooksAdapterBase {
1765
1951
  }
1766
1952
  }
1767
1953
  async processAsyncResult(promise, handlers, startIndex, ctx, response) {
1768
- try {
1769
- const result = await promise;
1770
- 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;
1771
1958
  return result;
1772
1959
  } catch (error) {
1773
- const isLastHandler = startIndex === handlers.length - 1;
1774
1960
  if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1775
- if (isLastHandler) {
1776
- 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;
1777
1964
  return error;
1778
1965
  }
1779
1966
  }
1780
- for (let i = startIndex + 1; i < handlers.length; i++) {
1781
- const handler = handlers[i];
1782
- const isLastHandler = i === handlers.length - 1;
1783
- try {
1784
- const result = await handler();
1785
- await this.respond(result, response, ctx);
1786
- return result;
1787
- } catch (error) {
1788
- if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
1789
- if (isLastHandler) {
1790
- await this.respond(error, response, ctx);
1791
- return error;
1792
- }
1793
- }
1794
- }
1795
1967
  }
1796
1968
  /**
1797
1969
  * Programmatic route invocation using the Web Standard fetch API.
@@ -1855,7 +2027,7 @@ var WooksHttp = class extends WooksAdapterBase {
1855
2027
  requestLimits
1856
2028
  }, async () => {
1857
2029
  const ctx = current();
1858
- if (bodyBuffer) ctx.set(rawBodySlot, Promise.resolve(bodyBuffer));
2030
+ if (bodyBuffer) seedRawBody(ctx, bodyBuffer);
1859
2031
  try {
1860
2032
  const handlers = this.wooks.lookupHandlers(method, pathname, ctx);
1861
2033
  if (handlers) {
@@ -2059,12 +2231,9 @@ function prepareTestHttpContext(options) {
2059
2231
  requestLimits: options.requestLimits
2060
2232
  });
2061
2233
  if (options.params) ctx.set(routeParamsKey, options.params);
2062
- if (options.rawBody !== void 0) {
2063
- const buf = Buffer$1.isBuffer(options.rawBody) ? options.rawBody : Buffer$1.from(options.rawBody);
2064
- ctx.set(rawBodySlot, Promise.resolve(buf));
2065
- }
2234
+ if (options.rawBody !== void 0) seedRawBody(ctx, options.rawBody);
2066
2235
  return (cb) => run(ctx, cb);
2067
2236
  }
2068
2237
 
2069
2238
  //#endregion
2070
- export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, 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.25",
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.25",
46
- "wooks": "^0.7.25"
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.25",
52
- "wooks": "^0.7.25"
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"