@wooksjs/event-http 0.7.26 → 0.7.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +457 -81
- package/dist/index.d.ts +168 -6
- package/dist/index.mjs +456 -83
- package/package.json +6 -6
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/
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
body = await uncompressBody(encs, body);
|
|
495
|
-
inflatedBytes = body.byteLength;
|
|
496
|
-
if (inflatedBytes > maxInflated) throw new HttpError(413, "Inflated body too large");
|
|
497
|
-
}
|
|
498
|
-
if (isZip && rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
|
|
499
|
-
return body;
|
|
571
|
+
if (rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
|
|
572
|
+
return buffer.Buffer.concat(chunks);
|
|
500
573
|
});
|
|
501
574
|
const forwardedIpSlot = (0, _wooksjs_event_core.cached)((ctx) => {
|
|
502
575
|
const req = ctx.get(httpKind.keys.req);
|
|
@@ -592,19 +665,6 @@ function seedRawBody(ctx, raw) {
|
|
|
592
665
|
if (ctx.parent) ctx.getOwn(useRequest._slot);
|
|
593
666
|
}
|
|
594
667
|
|
|
595
|
-
//#endregion
|
|
596
|
-
//#region packages/event-http/src/composables/headers.ts
|
|
597
|
-
/**
|
|
598
|
-
* Returns the incoming request headers.
|
|
599
|
-
* @example
|
|
600
|
-
* ```ts
|
|
601
|
-
* const { host, authorization } = useHeaders()
|
|
602
|
-
* ```
|
|
603
|
-
*/
|
|
604
|
-
function useHeaders(ctx) {
|
|
605
|
-
return useRequest(ctx).headers;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
668
|
//#endregion
|
|
609
669
|
//#region packages/event-http/src/composables/response.ts
|
|
610
670
|
/**
|
|
@@ -687,6 +747,120 @@ function useHttpContext(ctx) {
|
|
|
687
747
|
return ctx ?? (0, _wooksjs_event_core.current)();
|
|
688
748
|
}
|
|
689
749
|
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region packages/event-http/src/response/compression.ts
|
|
752
|
+
const COMPRESSIBLE_TYPE = /^(?:text\/(?!event-stream)|image\/svg\+xml|application\/(?:json|javascript|x-javascript|ecmascript|xml|xhtml\+xml|graphql|x-ndjson|ndjson|wasm|x-www-form-urlencoded)\b)|\+(?:json|xml)\b/i;
|
|
753
|
+
/**
|
|
754
|
+
* Default compression filter: `text/*` (except `text/event-stream`), JSON and `+json`, XML and
|
|
755
|
+
* `+xml`, JavaScript, SVG, NDJSON, WASM and form-urlencoded content types.
|
|
756
|
+
*/
|
|
757
|
+
function isCompressibleType(contentType) {
|
|
758
|
+
return COMPRESSIBLE_TYPE.test(contentType);
|
|
759
|
+
}
|
|
760
|
+
const DEFAULTS = {
|
|
761
|
+
threshold: 1024,
|
|
762
|
+
encodings: ["br", "gzip"],
|
|
763
|
+
brotliQuality: 4,
|
|
764
|
+
gzipLevel: 6,
|
|
765
|
+
filter: isCompressibleType
|
|
766
|
+
};
|
|
767
|
+
/**
|
|
768
|
+
* @internal Resolves the `compression` option. `base` supplies the values `value` does not set
|
|
769
|
+
* (the app settings for a per-response override). Returns `undefined` when compression is off.
|
|
770
|
+
*/
|
|
771
|
+
function resolveCompression(value, base = DEFAULTS) {
|
|
772
|
+
if (!value) return;
|
|
773
|
+
if (value === true) return base;
|
|
774
|
+
return {
|
|
775
|
+
threshold: value.threshold ?? base.threshold,
|
|
776
|
+
encodings: value.encodings ?? base.encodings,
|
|
777
|
+
brotliQuality: value.brotliQuality ?? base.brotliQuality,
|
|
778
|
+
gzipLevel: value.gzipLevel ?? base.gzipLevel,
|
|
779
|
+
filter: value.filter ?? base.filter
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Picks the content coding for a response from the request's `Accept-Encoding` header.
|
|
784
|
+
*
|
|
785
|
+
* The coding with the highest client q-value wins; `supported` order breaks ties. `q=0`
|
|
786
|
+
* excludes a coding, `*` matches every coding not listed explicitly. Returns `undefined` when
|
|
787
|
+
* the header is missing or none of `supported` is acceptable — send the body uncompressed then.
|
|
788
|
+
*
|
|
789
|
+
* @example
|
|
790
|
+
* ```ts
|
|
791
|
+
* negotiateEncoding('gzip, deflate, br', ['br', 'gzip']) // 'br'
|
|
792
|
+
* negotiateEncoding('br;q=0.5, gzip', ['br', 'gzip']) // 'gzip'
|
|
793
|
+
* negotiateEncoding('*;q=0, identity', ['br', 'gzip']) // undefined
|
|
794
|
+
* ```
|
|
795
|
+
*/
|
|
796
|
+
function negotiateEncoding(acceptEncoding, supported) {
|
|
797
|
+
if (!acceptEncoding) return;
|
|
798
|
+
const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(",") : acceptEncoding;
|
|
799
|
+
const listed = /* @__PURE__ */ new Map();
|
|
800
|
+
let wildcard;
|
|
801
|
+
for (const part of header.split(",")) {
|
|
802
|
+
const semi = part.indexOf(";");
|
|
803
|
+
const name = (semi === -1 ? part : part.slice(0, semi)).trim().toLowerCase();
|
|
804
|
+
if (!name) continue;
|
|
805
|
+
const q = semi === -1 ? 1 : parseQ(part.slice(semi + 1));
|
|
806
|
+
if (name === "*") wildcard = q;
|
|
807
|
+
else listed.set(name, q);
|
|
808
|
+
}
|
|
809
|
+
let best;
|
|
810
|
+
let bestQ = 0;
|
|
811
|
+
for (const enc of supported) {
|
|
812
|
+
const q = listed.get(enc) ?? wildcard ?? 0;
|
|
813
|
+
if (q > bestQ) {
|
|
814
|
+
best = enc;
|
|
815
|
+
bestQ = q;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return best;
|
|
819
|
+
}
|
|
820
|
+
function parseQ(params) {
|
|
821
|
+
for (const param of params.split(";")) {
|
|
822
|
+
const eq = param.indexOf("=");
|
|
823
|
+
if (eq !== -1 && param.slice(0, eq).trim().toLowerCase() === "q") {
|
|
824
|
+
const q = Number(param.slice(eq + 1).trim());
|
|
825
|
+
return Number.isFinite(q) && q > 0 ? Math.min(q, 1) : 0;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return 1;
|
|
829
|
+
}
|
|
830
|
+
/** @internal Compresses a rendered body of `size` bytes with the given coding (async, libuv threadpool). */
|
|
831
|
+
function compressResponseBody(body, size, encoding, opts) {
|
|
832
|
+
return new Promise((resolve, reject) => {
|
|
833
|
+
const done = (error, result) => {
|
|
834
|
+
if (error) reject(error);
|
|
835
|
+
else resolve(result);
|
|
836
|
+
};
|
|
837
|
+
if (encoding === "br") (0, node_zlib.brotliCompress)(body, { params: {
|
|
838
|
+
[node_zlib.constants.BROTLI_PARAM_QUALITY]: opts.brotliQuality,
|
|
839
|
+
[node_zlib.constants.BROTLI_PARAM_MODE]: node_zlib.constants.BROTLI_MODE_TEXT,
|
|
840
|
+
[node_zlib.constants.BROTLI_PARAM_SIZE_HINT]: size
|
|
841
|
+
} }, done);
|
|
842
|
+
else (0, node_zlib.gzip)(body, { level: opts.gzipLevel }, done);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
/** @internal Cache key for compressed bytes of a prerendered body (coding + level). */
|
|
846
|
+
function compressionCacheKey(encoding, opts) {
|
|
847
|
+
return encoding === "br" ? `br${opts.brotliQuality}` : `gzip${opts.gzipLevel}`;
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* @internal Appends `token` to a `Vary` header value unless it (or `*`) is already listed.
|
|
851
|
+
* Returns `undefined` when nothing has to change.
|
|
852
|
+
*/
|
|
853
|
+
function mergeVary(existing, token) {
|
|
854
|
+
if (!existing || existing.length === 0) return token;
|
|
855
|
+
const value = Array.isArray(existing) ? existing.join(", ") : existing;
|
|
856
|
+
const lower = token.toLowerCase();
|
|
857
|
+
for (const part of value.split(",")) {
|
|
858
|
+
const name = part.trim().toLowerCase();
|
|
859
|
+
if (name === lower || name === "*") return;
|
|
860
|
+
}
|
|
861
|
+
return value.trim() ? `${value}, ${token}` : token;
|
|
862
|
+
}
|
|
863
|
+
|
|
690
864
|
//#endregion
|
|
691
865
|
//#region packages/event-http/src/utils/time.ts
|
|
692
866
|
function convertTime(time, unit = "ms") {
|
|
@@ -763,6 +937,70 @@ const cookieAttrFunc = {
|
|
|
763
937
|
sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
|
|
764
938
|
};
|
|
765
939
|
|
|
940
|
+
//#endregion
|
|
941
|
+
//#region packages/event-http/src/response/prerender.ts
|
|
942
|
+
const registry = /* @__PURE__ */ new WeakMap();
|
|
943
|
+
function weakEtag(json) {
|
|
944
|
+
return `W/"${(0, crypto.createHash)("sha256").update(json).digest("base64url").slice(0, 22)}"`;
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
|
|
948
|
+
* (or an interceptor) responds with that same object, the response reuses the stored JSON instead
|
|
949
|
+
* of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
|
|
950
|
+
* requests return as is — e.g. a schema or metadata envelope built once and cached.
|
|
951
|
+
*
|
|
952
|
+
* With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
|
|
953
|
+
* that responds `200` with the object sets the `ETag` header and answers a matching
|
|
954
|
+
* `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
|
|
955
|
+
* and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
|
|
956
|
+
* on the response wins and disables the `304` handling for that response.
|
|
957
|
+
*
|
|
958
|
+
* **Contract: never mutate a registered object (or anything it references).** The stored JSON
|
|
959
|
+
* is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
|
|
960
|
+
* freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
|
|
961
|
+
*
|
|
962
|
+
* Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
|
|
963
|
+
* the first time). Entries are held weakly and disappear with the object.
|
|
964
|
+
*
|
|
965
|
+
* @param obj - A JSON-serializable object or array
|
|
966
|
+
* @param options - `{ etag: true }` to also compute a weak ETag
|
|
967
|
+
* @returns The same `obj`, so it can wrap a return value
|
|
968
|
+
*
|
|
969
|
+
* @example
|
|
970
|
+
* ```ts
|
|
971
|
+
* const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
|
|
972
|
+
* app.get('/meta', () => {
|
|
973
|
+
* useResponse().setHeader('cache-control', 'private, no-cache')
|
|
974
|
+
* return envelope // sent without re-serializing; 304 on a matching If-None-Match
|
|
975
|
+
* })
|
|
976
|
+
* ```
|
|
977
|
+
*/
|
|
978
|
+
function prerenderJson(obj, options) {
|
|
979
|
+
let entry = registry.get(obj);
|
|
980
|
+
if (!entry) {
|
|
981
|
+
const json = JSON.stringify(obj);
|
|
982
|
+
if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
|
|
983
|
+
entry = { json };
|
|
984
|
+
registry.set(obj, entry);
|
|
985
|
+
}
|
|
986
|
+
if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
|
|
987
|
+
return obj;
|
|
988
|
+
}
|
|
989
|
+
/** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
|
|
990
|
+
function getPrerenderedJson(obj) {
|
|
991
|
+
return registry.get(obj);
|
|
992
|
+
}
|
|
993
|
+
/** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
|
|
994
|
+
function ifNoneMatchHas(header, etag) {
|
|
995
|
+
if (!header) return false;
|
|
996
|
+
const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
|
|
997
|
+
for (const part of header.split(",")) {
|
|
998
|
+
const tag = part.trim();
|
|
999
|
+
if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
|
|
1000
|
+
}
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
766
1004
|
//#endregion
|
|
767
1005
|
//#region packages/event-http/src/response/http-response.ts
|
|
768
1006
|
const hasFetchResponse = typeof globalThis.Response === "function";
|
|
@@ -792,8 +1030,10 @@ var HttpResponse = class {
|
|
|
792
1030
|
* @param _req - The underlying Node.js `IncomingMessage`.
|
|
793
1031
|
* @param _logger - Logger instance for error reporting.
|
|
794
1032
|
* @param defaultHeaders - Optional headers to pre-populate on this response (e.g. from `securityHeaders()`).
|
|
1033
|
+
* @param _captureMode - Finalize state on `send()` without writing to `_res` (programmatic fetch).
|
|
1034
|
+
* @param compression - App-level response compression settings (`undefined` = off).
|
|
795
1035
|
*/
|
|
796
|
-
constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
|
|
1036
|
+
constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
|
|
797
1037
|
this._res = _res;
|
|
798
1038
|
this._req = _req;
|
|
799
1039
|
this._logger = _logger;
|
|
@@ -801,10 +1041,9 @@ var HttpResponse = class {
|
|
|
801
1041
|
this._status = 0;
|
|
802
1042
|
this._body = void 0;
|
|
803
1043
|
this._headers = {};
|
|
804
|
-
this._cookies = {};
|
|
805
|
-
this._rawCookies = [];
|
|
806
1044
|
this._hasCookies = false;
|
|
807
1045
|
this._responded = false;
|
|
1046
|
+
this._compression = compression;
|
|
808
1047
|
if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
|
|
809
1048
|
}
|
|
810
1049
|
/** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
|
|
@@ -870,7 +1109,7 @@ var HttpResponse = class {
|
|
|
870
1109
|
}
|
|
871
1110
|
/** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
|
|
872
1111
|
setCookie(name, value, attrs) {
|
|
873
|
-
this._cookies[name] = {
|
|
1112
|
+
(this._cookies ??= {})[name] = {
|
|
874
1113
|
value,
|
|
875
1114
|
attrs: attrs || {}
|
|
876
1115
|
};
|
|
@@ -879,23 +1118,23 @@ var HttpResponse = class {
|
|
|
879
1118
|
}
|
|
880
1119
|
/** Returns a previously set cookie's data, or `undefined` if not set. */
|
|
881
1120
|
getCookie(name) {
|
|
882
|
-
return this._cookies[name];
|
|
1121
|
+
return this._cookies?.[name];
|
|
883
1122
|
}
|
|
884
1123
|
/** Removes a cookie from the outgoing set list (chainable). */
|
|
885
1124
|
removeCookie(name) {
|
|
886
|
-
delete this._cookies[name];
|
|
1125
|
+
if (this._cookies) delete this._cookies[name];
|
|
887
1126
|
return this;
|
|
888
1127
|
}
|
|
889
1128
|
/** Removes all outgoing cookies (chainable). */
|
|
890
1129
|
clearCookies() {
|
|
891
|
-
this._cookies =
|
|
892
|
-
this._rawCookies =
|
|
1130
|
+
this._cookies = void 0;
|
|
1131
|
+
this._rawCookies = void 0;
|
|
893
1132
|
this._hasCookies = false;
|
|
894
1133
|
return this;
|
|
895
1134
|
}
|
|
896
1135
|
/** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
|
|
897
1136
|
setCookieRaw(rawValue) {
|
|
898
|
-
this._rawCookies.push(rawValue);
|
|
1137
|
+
(this._rawCookies ??= []).push(rawValue);
|
|
899
1138
|
this._hasCookies = true;
|
|
900
1139
|
return this;
|
|
901
1140
|
}
|
|
@@ -910,8 +1149,10 @@ var HttpResponse = class {
|
|
|
910
1149
|
*/
|
|
911
1150
|
getSetCookieStrings() {
|
|
912
1151
|
const rendered = [];
|
|
913
|
-
|
|
914
|
-
|
|
1152
|
+
if (this._cookies) {
|
|
1153
|
+
for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
|
|
1154
|
+
}
|
|
1155
|
+
if (this._rawCookies) rendered.push(...this._rawCookies);
|
|
915
1156
|
return rendered;
|
|
916
1157
|
}
|
|
917
1158
|
/** Sets the `Cache-Control` header from a directive object (chainable). */
|
|
@@ -935,6 +1176,24 @@ var HttpResponse = class {
|
|
|
935
1176
|
return this;
|
|
936
1177
|
}
|
|
937
1178
|
/**
|
|
1179
|
+
* Overrides response compression for this response (chainable).
|
|
1180
|
+
*
|
|
1181
|
+
* - `false` — never compress this response (e.g. a body that mixes secrets with reflected input).
|
|
1182
|
+
* - `true` — compress with the app settings, or the defaults when the app has compression off.
|
|
1183
|
+
* - an options object — compress with these settings over the app settings (or the defaults).
|
|
1184
|
+
*
|
|
1185
|
+
* Only regular bodies (strings, numbers, booleans, objects, `Uint8Array`) are compressed; streams
|
|
1186
|
+
* and fetch `Response` bodies are sent as is.
|
|
1187
|
+
*/
|
|
1188
|
+
setCompression(value) {
|
|
1189
|
+
this._compression = value === true ? this._compression ?? resolveCompression(true) : resolveCompression(value, this._compression);
|
|
1190
|
+
return this;
|
|
1191
|
+
}
|
|
1192
|
+
/** Effective compression settings for this response, or `false` when compression is off. */
|
|
1193
|
+
get compression() {
|
|
1194
|
+
return this._compression ?? false;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
938
1197
|
* Returns the underlying Node.js `ServerResponse`.
|
|
939
1198
|
* @param passthrough - If `true`, the framework still manages the response lifecycle. If `false` (default), the response is marked as "responded" and the framework will not touch it.
|
|
940
1199
|
*/
|
|
@@ -972,11 +1231,13 @@ var HttpResponse = class {
|
|
|
972
1231
|
});
|
|
973
1232
|
}
|
|
974
1233
|
const rendered = this.renderBody();
|
|
1234
|
+
const prerendered = this.takePrerendered();
|
|
975
1235
|
this.autoStatus(!!rendered);
|
|
976
|
-
if (
|
|
977
|
-
|
|
978
|
-
this.
|
|
979
|
-
}
|
|
1236
|
+
if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
|
|
1237
|
+
status: this._status,
|
|
1238
|
+
headers: this._buildWebHeaders()
|
|
1239
|
+
});
|
|
1240
|
+
if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
|
|
980
1241
|
const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
|
|
981
1242
|
const webResponse = new globalThis.Response(webBody, {
|
|
982
1243
|
status: this._status,
|
|
@@ -1021,6 +1282,11 @@ var HttpResponse = class {
|
|
|
1021
1282
|
if (body instanceof Uint8Array) return body;
|
|
1022
1283
|
if (typeof body === "object") {
|
|
1023
1284
|
if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
|
|
1285
|
+
const prerendered = getPrerenderedJson(body);
|
|
1286
|
+
if (prerendered) {
|
|
1287
|
+
this._prerendered = prerendered;
|
|
1288
|
+
return prerendered.json;
|
|
1289
|
+
}
|
|
1024
1290
|
return JSON.stringify(body);
|
|
1025
1291
|
}
|
|
1026
1292
|
throw new Error(`Unsupported body format "${typeof body}"`);
|
|
@@ -1041,6 +1307,7 @@ var HttpResponse = class {
|
|
|
1041
1307
|
*
|
|
1042
1308
|
* Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
|
|
1043
1309
|
* then writes the body. Supports `Readable` streams, `fetch` `Response` objects, and regular values.
|
|
1310
|
+
* Returns a Promise (which never rejects) for streamed bodies and compressed regular bodies.
|
|
1044
1311
|
*
|
|
1045
1312
|
* @throws Error if the response was already sent.
|
|
1046
1313
|
*/
|
|
@@ -1060,7 +1327,7 @@ var HttpResponse = class {
|
|
|
1060
1327
|
const method = this._req.method;
|
|
1061
1328
|
if (body instanceof stream.Readable) return this.sendStream(body, method);
|
|
1062
1329
|
if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
|
|
1063
|
-
this.sendRegular(method);
|
|
1330
|
+
return this.sendRegular(method);
|
|
1064
1331
|
}
|
|
1065
1332
|
finalizeCookies() {
|
|
1066
1333
|
if (!this._hasCookies) return;
|
|
@@ -1072,6 +1339,35 @@ var HttpResponse = class {
|
|
|
1072
1339
|
}
|
|
1073
1340
|
this._hasCookies = false;
|
|
1074
1341
|
}
|
|
1342
|
+
/**
|
|
1343
|
+
* For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
|
|
1344
|
+
* GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
|
|
1345
|
+
* matches into a bodiless `304`. Returns `true` when the response became `304`.
|
|
1346
|
+
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
1347
|
+
*/
|
|
1348
|
+
applyPrerenderedEtag(etag) {
|
|
1349
|
+
const method = this._req.method;
|
|
1350
|
+
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
|
|
1351
|
+
this._headers.etag = etag;
|
|
1352
|
+
if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
|
|
1353
|
+
this._status = EHttpStatusCode.NotModified;
|
|
1354
|
+
for (const key in this._headers) {
|
|
1355
|
+
const lower = key.toLowerCase();
|
|
1356
|
+
if (lower === "content-type" || lower === "content-length") delete this._headers[key];
|
|
1357
|
+
}
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
/** Returns the stored key of header `name` (lower-case) in whatever casing it was set with. */
|
|
1361
|
+
headerKeyIgnoreCase(name) {
|
|
1362
|
+
if (name in this._headers) return name;
|
|
1363
|
+
for (const key in this._headers) if (key.toLowerCase() === name) return key;
|
|
1364
|
+
}
|
|
1365
|
+
/** Returns and clears the prerender entry picked by the last `renderBody()`. */
|
|
1366
|
+
takePrerendered() {
|
|
1367
|
+
const prerendered = this._prerendered;
|
|
1368
|
+
this._prerendered = void 0;
|
|
1369
|
+
return prerendered;
|
|
1370
|
+
}
|
|
1075
1371
|
autoStatus(hasBody) {
|
|
1076
1372
|
if (this._status) return;
|
|
1077
1373
|
if (!hasBody) {
|
|
@@ -1117,13 +1413,101 @@ var HttpResponse = class {
|
|
|
1117
1413
|
return this.pipeToResponse(stream.Readable.fromWeb(fetchResponse.body), "Error streaming fetch response body");
|
|
1118
1414
|
}
|
|
1119
1415
|
sendRegular(method) {
|
|
1120
|
-
const
|
|
1121
|
-
this.
|
|
1122
|
-
const
|
|
1123
|
-
this.
|
|
1124
|
-
this.
|
|
1416
|
+
const body = this.renderBody();
|
|
1417
|
+
const prerendered = this.takePrerendered();
|
|
1418
|
+
const size = renderedSize(body, prerendered);
|
|
1419
|
+
this.autoStatus(!!body);
|
|
1420
|
+
const compression = this._compression && this.isCompressible(size, this._compression) ? this._compression : void 0;
|
|
1421
|
+
if (compression) this.appendVary("Accept-Encoding");
|
|
1422
|
+
if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) {
|
|
1423
|
+
this._res.writeHead(this._status, this._headers).end();
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
if (compression && method !== "HEAD") {
|
|
1427
|
+
const encoding = negotiateEncoding(this._req.headers["accept-encoding"], compression.encodings);
|
|
1428
|
+
if (encoding) return this.sendCompressed(body, size, encoding, compression, prerendered);
|
|
1429
|
+
}
|
|
1430
|
+
this._headers["content-length"] = size.toString();
|
|
1431
|
+
this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : body);
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Whether a rendered regular body of `size` bytes may be compressed — independent of the
|
|
1435
|
+
* request's `Accept-Encoding` (and of HEAD, which is answered uncompressed).
|
|
1436
|
+
*/
|
|
1437
|
+
isCompressible(size, opts) {
|
|
1438
|
+
const status = this._status;
|
|
1439
|
+
if (!size || size < opts.threshold || status < 200 || status === 204 || status === 206 || status === 304) return false;
|
|
1440
|
+
let contentType;
|
|
1441
|
+
for (const key in this._headers) {
|
|
1442
|
+
const lower = key.toLowerCase();
|
|
1443
|
+
if (lower === "content-type") contentType = this._headers[key];
|
|
1444
|
+
else if (lower === "content-encoding") return false;
|
|
1445
|
+
else if (lower === "cache-control" && /no-transform/i.test(String(this._headers[key]))) return false;
|
|
1446
|
+
}
|
|
1447
|
+
return typeof contentType === "string" && opts.filter(contentType, this);
|
|
1448
|
+
}
|
|
1449
|
+
/** Adds `token` to the `Vary` header (case-insensitive merge, keeps existing entries). */
|
|
1450
|
+
appendVary(token) {
|
|
1451
|
+
const key = this.headerKeyIgnoreCase("vary") ?? "vary";
|
|
1452
|
+
const merged = mergeVary(this._headers[key], token);
|
|
1453
|
+
if (merged !== void 0) this._headers[key] = merged;
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Sends the body compressed with `encoding`. Prerendered bodies are compressed once per coding
|
|
1457
|
+
* and level and the bytes reused (sent synchronously once ready). A compressor failure is
|
|
1458
|
+
* logged and the body is sent uncompressed — headers are not written yet, so that is safe.
|
|
1459
|
+
* Never rejects: a sync handler's `send()` is not awaited.
|
|
1460
|
+
*/
|
|
1461
|
+
sendCompressed(body, size, encoding, opts, prerendered) {
|
|
1462
|
+
let pending;
|
|
1463
|
+
if (prerendered?.json === body) {
|
|
1464
|
+
const cache = prerendered.compressed ??= {};
|
|
1465
|
+
const cacheKey = compressionCacheKey(encoding, opts);
|
|
1466
|
+
let entry = cache[cacheKey];
|
|
1467
|
+
if (!entry) {
|
|
1468
|
+
const promise = compressResponseBody(body, size, encoding, opts);
|
|
1469
|
+
promise.then((bytes) => cache[cacheKey] = bytes, () => delete cache[cacheKey]);
|
|
1470
|
+
entry = cache[cacheKey] = promise;
|
|
1471
|
+
}
|
|
1472
|
+
pending = entry;
|
|
1473
|
+
} else pending = compressResponseBody(body, size, encoding, opts);
|
|
1474
|
+
if (Buffer.isBuffer(pending)) {
|
|
1475
|
+
this.writeDeferred(pending, pending.byteLength, encoding);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
return pending.then((bytes) => this.writeDeferred(bytes, bytes.byteLength, encoding), (error) => {
|
|
1479
|
+
this._logger.error("Response compression failed, sending uncompressed body", error);
|
|
1480
|
+
this.writeDeferred(body, size);
|
|
1481
|
+
}).catch((error) => {
|
|
1482
|
+
this._logger.error("Failed to send compressed response", error);
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Writes the body picked by `sendCompressed()` — the `encoding`-compressed bytes, or the identity
|
|
1487
|
+
* body after a compressor failure — unless the client went away meanwhile.
|
|
1488
|
+
*/
|
|
1489
|
+
writeDeferred(body, length, encoding) {
|
|
1490
|
+
if (this._res.destroyed) return;
|
|
1491
|
+
if (encoding) {
|
|
1492
|
+
this._headers["content-encoding"] = encoding;
|
|
1493
|
+
for (const key in this._headers) {
|
|
1494
|
+
const lower = key.toLowerCase();
|
|
1495
|
+
if (lower === "content-length" && key !== lower) delete this._headers[key];
|
|
1496
|
+
else if (lower === "etag") {
|
|
1497
|
+
const etag = this._headers[key];
|
|
1498
|
+
if (typeof etag === "string" && etag && !etag.startsWith("W/")) this._headers[key] = `W/${etag}`;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
this._headers["content-length"] = length.toString();
|
|
1503
|
+
this._res.writeHead(this._status, this._headers).end(body);
|
|
1125
1504
|
}
|
|
1126
1505
|
};
|
|
1506
|
+
/** UTF-8 size of a rendered body — computed once per registration for a prerendered body. */
|
|
1507
|
+
function renderedSize(body, prerendered) {
|
|
1508
|
+
if (typeof body !== "string") return body.byteLength;
|
|
1509
|
+
return prerendered?.json === body ? prerendered.size ??= Buffer.byteLength(body) : Buffer.byteLength(body);
|
|
1510
|
+
}
|
|
1127
1511
|
/** Converts a Record of headers to a Web Standard `Headers` object. */
|
|
1128
1512
|
function recordToWebHeaders(record) {
|
|
1129
1513
|
const headers = new Headers();
|
|
@@ -1497,7 +1881,7 @@ function escapeHtml(value) {
|
|
|
1497
1881
|
//#endregion
|
|
1498
1882
|
//#region packages/event-http/src/response/wooks-http-response.ts
|
|
1499
1883
|
let framework = {
|
|
1500
|
-
version: "0.7.
|
|
1884
|
+
version: "0.7.28",
|
|
1501
1885
|
poweredBy: "wooksjs",
|
|
1502
1886
|
link: "https://wooks.moost.org/",
|
|
1503
1887
|
image: "https://wooks.moost.org/wooks-full-logo.svg"
|
|
@@ -1521,7 +1905,8 @@ var WooksHttpResponse = class extends HttpResponse {
|
|
|
1521
1905
|
}
|
|
1522
1906
|
renderError(data, ctx) {
|
|
1523
1907
|
this._status = data.statusCode || 500;
|
|
1524
|
-
const
|
|
1908
|
+
const accept = ctx.get(httpKind.keys.req).headers.accept;
|
|
1909
|
+
const has = (type) => acceptHeaderHas(accept, type);
|
|
1525
1910
|
if (has("json")) {
|
|
1526
1911
|
this._headers["content-type"] = "application/json";
|
|
1527
1912
|
this._body = JSON.stringify(data);
|
|
@@ -1592,6 +1977,7 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
|
|
|
1592
1977
|
this.logger = opts?.logger || this.getLogger(`[96m[wooks-http]`);
|
|
1593
1978
|
this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
|
|
1594
1979
|
this.eventContextOptions = this.getEventContextOptions();
|
|
1980
|
+
this.compression = resolveCompression(opts?.compression);
|
|
1595
1981
|
}
|
|
1596
1982
|
/** Registers a handler for all HTTP methods on the given path. */
|
|
1597
1983
|
all(path, handler) {
|
|
@@ -1714,8 +2100,9 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
|
|
|
1714
2100
|
const RequestLimits = this.opts?.requestLimits;
|
|
1715
2101
|
const notFoundHandler = this.opts?.onNotFound;
|
|
1716
2102
|
const defaultHeaders = this.opts?.defaultHeaders;
|
|
2103
|
+
const compression = this.compression;
|
|
1717
2104
|
return (req, res) => {
|
|
1718
|
-
const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
|
|
2105
|
+
const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
|
|
1719
2106
|
const method = req.method || "";
|
|
1720
2107
|
const url = req.url || "";
|
|
1721
2108
|
createHttpContext(ctxOptions, {
|
|
@@ -1817,33 +2204,19 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
|
|
|
1817
2204
|
}
|
|
1818
2205
|
}
|
|
1819
2206
|
async processAsyncResult(promise, handlers, startIndex, ctx, response) {
|
|
1820
|
-
try {
|
|
1821
|
-
const result = await promise;
|
|
1822
|
-
|
|
2207
|
+
for (let i = startIndex; i < handlers.length; i++) try {
|
|
2208
|
+
const result = await (i === startIndex ? promise : handlers[i]());
|
|
2209
|
+
const sent = this.respond(result, response, ctx);
|
|
2210
|
+
if (sent) await sent;
|
|
1823
2211
|
return result;
|
|
1824
2212
|
} catch (error) {
|
|
1825
|
-
const isLastHandler = startIndex === handlers.length - 1;
|
|
1826
2213
|
if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
|
|
1827
|
-
if (
|
|
1828
|
-
|
|
2214
|
+
if (i === handlers.length - 1) {
|
|
2215
|
+
const sent = this.respond(error, response, ctx);
|
|
2216
|
+
if (sent) await sent;
|
|
1829
2217
|
return error;
|
|
1830
2218
|
}
|
|
1831
2219
|
}
|
|
1832
|
-
for (let i = startIndex + 1; i < handlers.length; i++) {
|
|
1833
|
-
const handler = handlers[i];
|
|
1834
|
-
const isLastHandler = i === handlers.length - 1;
|
|
1835
|
-
try {
|
|
1836
|
-
const result = await handler();
|
|
1837
|
-
await this.respond(result, response, ctx);
|
|
1838
|
-
return result;
|
|
1839
|
-
} catch (error) {
|
|
1840
|
-
if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
|
|
1841
|
-
if (isLastHandler) {
|
|
1842
|
-
await this.respond(error, response, ctx);
|
|
1843
|
-
return error;
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
2220
|
}
|
|
1848
2221
|
/**
|
|
1849
2222
|
* Programmatic route invocation using the Web Standard fetch API.
|
|
@@ -2128,7 +2501,10 @@ exports.createHttpApp = createHttpApp;
|
|
|
2128
2501
|
exports.createHttpContext = createHttpContext;
|
|
2129
2502
|
exports.httpKind = httpKind;
|
|
2130
2503
|
exports.httpStatusCodes = httpStatusCodes;
|
|
2504
|
+
exports.isCompressibleType = isCompressibleType;
|
|
2505
|
+
exports.negotiateEncoding = negotiateEncoding;
|
|
2131
2506
|
exports.prepareTestHttpContext = prepareTestHttpContext;
|
|
2507
|
+
exports.prerenderJson = prerenderJson;
|
|
2132
2508
|
exports.rawBodySlot = rawBodySlot;
|
|
2133
2509
|
exports.recordToWebHeaders = recordToWebHeaders;
|
|
2134
2510
|
exports.renderCacheControl = renderCacheControl;
|