@wooksjs/event-http 0.7.26 → 0.7.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +219 -69
- package/dist/index.d.ts +60 -5
- package/dist/index.mjs +219 -70
- 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
|
/**
|
|
@@ -763,6 +823,70 @@ const cookieAttrFunc = {
|
|
|
763
823
|
sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
|
|
764
824
|
};
|
|
765
825
|
|
|
826
|
+
//#endregion
|
|
827
|
+
//#region packages/event-http/src/response/prerender.ts
|
|
828
|
+
const registry = /* @__PURE__ */ new WeakMap();
|
|
829
|
+
function weakEtag(json) {
|
|
830
|
+
return `W/"${(0, crypto.createHash)("sha256").update(json).digest("base64url").slice(0, 22)}"`;
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
|
|
834
|
+
* (or an interceptor) responds with that same object, the response reuses the stored JSON instead
|
|
835
|
+
* of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
|
|
836
|
+
* requests return as is — e.g. a schema or metadata envelope built once and cached.
|
|
837
|
+
*
|
|
838
|
+
* With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
|
|
839
|
+
* that responds `200` with the object sets the `ETag` header and answers a matching
|
|
840
|
+
* `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
|
|
841
|
+
* and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
|
|
842
|
+
* on the response wins and disables the `304` handling for that response.
|
|
843
|
+
*
|
|
844
|
+
* **Contract: never mutate a registered object (or anything it references).** The stored JSON
|
|
845
|
+
* is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
|
|
846
|
+
* freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
|
|
847
|
+
*
|
|
848
|
+
* Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
|
|
849
|
+
* the first time). Entries are held weakly and disappear with the object.
|
|
850
|
+
*
|
|
851
|
+
* @param obj - A JSON-serializable object or array
|
|
852
|
+
* @param options - `{ etag: true }` to also compute a weak ETag
|
|
853
|
+
* @returns The same `obj`, so it can wrap a return value
|
|
854
|
+
*
|
|
855
|
+
* @example
|
|
856
|
+
* ```ts
|
|
857
|
+
* const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
|
|
858
|
+
* app.get('/meta', () => {
|
|
859
|
+
* useResponse().setHeader('cache-control', 'private, no-cache')
|
|
860
|
+
* return envelope // sent without re-serializing; 304 on a matching If-None-Match
|
|
861
|
+
* })
|
|
862
|
+
* ```
|
|
863
|
+
*/
|
|
864
|
+
function prerenderJson(obj, options) {
|
|
865
|
+
let entry = registry.get(obj);
|
|
866
|
+
if (!entry) {
|
|
867
|
+
const json = JSON.stringify(obj);
|
|
868
|
+
if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
|
|
869
|
+
entry = { json };
|
|
870
|
+
registry.set(obj, entry);
|
|
871
|
+
}
|
|
872
|
+
if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
|
|
873
|
+
return obj;
|
|
874
|
+
}
|
|
875
|
+
/** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
|
|
876
|
+
function getPrerenderedJson(obj) {
|
|
877
|
+
return registry.get(obj);
|
|
878
|
+
}
|
|
879
|
+
/** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
|
|
880
|
+
function ifNoneMatchHas(header, etag) {
|
|
881
|
+
if (!header) return false;
|
|
882
|
+
const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
|
|
883
|
+
for (const part of header.split(",")) {
|
|
884
|
+
const tag = part.trim();
|
|
885
|
+
if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
|
|
886
|
+
}
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
|
|
766
890
|
//#endregion
|
|
767
891
|
//#region packages/event-http/src/response/http-response.ts
|
|
768
892
|
const hasFetchResponse = typeof globalThis.Response === "function";
|
|
@@ -801,8 +925,6 @@ var HttpResponse = class {
|
|
|
801
925
|
this._status = 0;
|
|
802
926
|
this._body = void 0;
|
|
803
927
|
this._headers = {};
|
|
804
|
-
this._cookies = {};
|
|
805
|
-
this._rawCookies = [];
|
|
806
928
|
this._hasCookies = false;
|
|
807
929
|
this._responded = false;
|
|
808
930
|
if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
|
|
@@ -870,7 +992,7 @@ var HttpResponse = class {
|
|
|
870
992
|
}
|
|
871
993
|
/** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
|
|
872
994
|
setCookie(name, value, attrs) {
|
|
873
|
-
this._cookies[name] = {
|
|
995
|
+
(this._cookies ??= {})[name] = {
|
|
874
996
|
value,
|
|
875
997
|
attrs: attrs || {}
|
|
876
998
|
};
|
|
@@ -879,23 +1001,23 @@ var HttpResponse = class {
|
|
|
879
1001
|
}
|
|
880
1002
|
/** Returns a previously set cookie's data, or `undefined` if not set. */
|
|
881
1003
|
getCookie(name) {
|
|
882
|
-
return this._cookies[name];
|
|
1004
|
+
return this._cookies?.[name];
|
|
883
1005
|
}
|
|
884
1006
|
/** Removes a cookie from the outgoing set list (chainable). */
|
|
885
1007
|
removeCookie(name) {
|
|
886
|
-
delete this._cookies[name];
|
|
1008
|
+
if (this._cookies) delete this._cookies[name];
|
|
887
1009
|
return this;
|
|
888
1010
|
}
|
|
889
1011
|
/** Removes all outgoing cookies (chainable). */
|
|
890
1012
|
clearCookies() {
|
|
891
|
-
this._cookies =
|
|
892
|
-
this._rawCookies =
|
|
1013
|
+
this._cookies = void 0;
|
|
1014
|
+
this._rawCookies = void 0;
|
|
893
1015
|
this._hasCookies = false;
|
|
894
1016
|
return this;
|
|
895
1017
|
}
|
|
896
1018
|
/** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
|
|
897
1019
|
setCookieRaw(rawValue) {
|
|
898
|
-
this._rawCookies.push(rawValue);
|
|
1020
|
+
(this._rawCookies ??= []).push(rawValue);
|
|
899
1021
|
this._hasCookies = true;
|
|
900
1022
|
return this;
|
|
901
1023
|
}
|
|
@@ -910,8 +1032,10 @@ var HttpResponse = class {
|
|
|
910
1032
|
*/
|
|
911
1033
|
getSetCookieStrings() {
|
|
912
1034
|
const rendered = [];
|
|
913
|
-
|
|
914
|
-
|
|
1035
|
+
if (this._cookies) {
|
|
1036
|
+
for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
|
|
1037
|
+
}
|
|
1038
|
+
if (this._rawCookies) rendered.push(...this._rawCookies);
|
|
915
1039
|
return rendered;
|
|
916
1040
|
}
|
|
917
1041
|
/** Sets the `Cache-Control` header from a directive object (chainable). */
|
|
@@ -973,6 +1097,10 @@ var HttpResponse = class {
|
|
|
973
1097
|
}
|
|
974
1098
|
const rendered = this.renderBody();
|
|
975
1099
|
this.autoStatus(!!rendered);
|
|
1100
|
+
if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
|
|
1101
|
+
status: this._status,
|
|
1102
|
+
headers: this._buildWebHeaders()
|
|
1103
|
+
});
|
|
976
1104
|
if (rendered) {
|
|
977
1105
|
const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
|
|
978
1106
|
this._headers["content-length"] = contentLength.toString();
|
|
@@ -1021,6 +1149,11 @@ var HttpResponse = class {
|
|
|
1021
1149
|
if (body instanceof Uint8Array) return body;
|
|
1022
1150
|
if (typeof body === "object") {
|
|
1023
1151
|
if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
|
|
1152
|
+
const prerendered = getPrerenderedJson(body);
|
|
1153
|
+
if (prerendered) {
|
|
1154
|
+
this._prerenderedEtag = prerendered.etag;
|
|
1155
|
+
return prerendered.json;
|
|
1156
|
+
}
|
|
1024
1157
|
return JSON.stringify(body);
|
|
1025
1158
|
}
|
|
1026
1159
|
throw new Error(`Unsupported body format "${typeof body}"`);
|
|
@@ -1072,6 +1205,31 @@ var HttpResponse = class {
|
|
|
1072
1205
|
}
|
|
1073
1206
|
this._hasCookies = false;
|
|
1074
1207
|
}
|
|
1208
|
+
/**
|
|
1209
|
+
* For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
|
|
1210
|
+
* GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
|
|
1211
|
+
* matches into a bodiless `304`. Returns `true` when the response became `304`.
|
|
1212
|
+
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
1213
|
+
*/
|
|
1214
|
+
applyPrerenderedEtag() {
|
|
1215
|
+
const etag = this._prerenderedEtag;
|
|
1216
|
+
if (!etag) return false;
|
|
1217
|
+
this._prerenderedEtag = void 0;
|
|
1218
|
+
const method = this._req.method;
|
|
1219
|
+
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.hasHeaderIgnoreCase("etag")) return false;
|
|
1220
|
+
this._headers.etag = etag;
|
|
1221
|
+
if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
|
|
1222
|
+
this._status = EHttpStatusCode.NotModified;
|
|
1223
|
+
for (const key in this._headers) {
|
|
1224
|
+
const lower = key.toLowerCase();
|
|
1225
|
+
if (lower === "content-type" || lower === "content-length") delete this._headers[key];
|
|
1226
|
+
}
|
|
1227
|
+
return true;
|
|
1228
|
+
}
|
|
1229
|
+
hasHeaderIgnoreCase(name) {
|
|
1230
|
+
for (const key in this._headers) if (key.toLowerCase() === name) return true;
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1075
1233
|
autoStatus(hasBody) {
|
|
1076
1234
|
if (this._status) return;
|
|
1077
1235
|
if (!hasBody) {
|
|
@@ -1119,6 +1277,10 @@ var HttpResponse = class {
|
|
|
1119
1277
|
sendRegular(method) {
|
|
1120
1278
|
const renderedBody = this.renderBody();
|
|
1121
1279
|
this.autoStatus(!!renderedBody);
|
|
1280
|
+
if (this.applyPrerenderedEtag()) {
|
|
1281
|
+
this._res.writeHead(this._status, this._headers).end();
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1122
1284
|
const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
|
|
1123
1285
|
this._headers["content-length"] = contentLength.toString();
|
|
1124
1286
|
this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
|
|
@@ -1497,7 +1659,7 @@ function escapeHtml(value) {
|
|
|
1497
1659
|
//#endregion
|
|
1498
1660
|
//#region packages/event-http/src/response/wooks-http-response.ts
|
|
1499
1661
|
let framework = {
|
|
1500
|
-
version: "0.7.
|
|
1662
|
+
version: "0.7.27",
|
|
1501
1663
|
poweredBy: "wooksjs",
|
|
1502
1664
|
link: "https://wooks.moost.org/",
|
|
1503
1665
|
image: "https://wooks.moost.org/wooks-full-logo.svg"
|
|
@@ -1521,7 +1683,8 @@ var WooksHttpResponse = class extends HttpResponse {
|
|
|
1521
1683
|
}
|
|
1522
1684
|
renderError(data, ctx) {
|
|
1523
1685
|
this._status = data.statusCode || 500;
|
|
1524
|
-
const
|
|
1686
|
+
const accept = ctx.get(httpKind.keys.req).headers.accept;
|
|
1687
|
+
const has = (type) => acceptHeaderHas(accept, type);
|
|
1525
1688
|
if (has("json")) {
|
|
1526
1689
|
this._headers["content-type"] = "application/json";
|
|
1527
1690
|
this._body = JSON.stringify(data);
|
|
@@ -1817,33 +1980,19 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
|
|
|
1817
1980
|
}
|
|
1818
1981
|
}
|
|
1819
1982
|
async processAsyncResult(promise, handlers, startIndex, ctx, response) {
|
|
1820
|
-
try {
|
|
1821
|
-
const result = await promise;
|
|
1822
|
-
|
|
1983
|
+
for (let i = startIndex; i < handlers.length; i++) try {
|
|
1984
|
+
const result = await (i === startIndex ? promise : handlers[i]());
|
|
1985
|
+
const sent = this.respond(result, response, ctx);
|
|
1986
|
+
if (sent) await sent;
|
|
1823
1987
|
return result;
|
|
1824
1988
|
} catch (error) {
|
|
1825
|
-
const isLastHandler = startIndex === handlers.length - 1;
|
|
1826
1989
|
if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
|
|
1827
|
-
if (
|
|
1828
|
-
|
|
1990
|
+
if (i === handlers.length - 1) {
|
|
1991
|
+
const sent = this.respond(error, response, ctx);
|
|
1992
|
+
if (sent) await sent;
|
|
1829
1993
|
return error;
|
|
1830
1994
|
}
|
|
1831
1995
|
}
|
|
1832
|
-
for (let i = startIndex + 1; i < handlers.length; i++) {
|
|
1833
|
-
const handler = handlers[i];
|
|
1834
|
-
const isLastHandler = i === handlers.length - 1;
|
|
1835
|
-
try {
|
|
1836
|
-
const result = await handler();
|
|
1837
|
-
await this.respond(result, response, ctx);
|
|
1838
|
-
return result;
|
|
1839
|
-
} catch (error) {
|
|
1840
|
-
if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
|
|
1841
|
-
if (isLastHandler) {
|
|
1842
|
-
await this.respond(error, response, ctx);
|
|
1843
|
-
return error;
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
1996
|
}
|
|
1848
1997
|
/**
|
|
1849
1998
|
* Programmatic route invocation using the Web Standard fetch API.
|
|
@@ -2129,6 +2278,7 @@ exports.createHttpContext = createHttpContext;
|
|
|
2129
2278
|
exports.httpKind = httpKind;
|
|
2130
2279
|
exports.httpStatusCodes = httpStatusCodes;
|
|
2131
2280
|
exports.prepareTestHttpContext = prepareTestHttpContext;
|
|
2281
|
+
exports.prerenderJson = prerenderJson;
|
|
2132
2282
|
exports.rawBodySlot = rawBodySlot;
|
|
2133
2283
|
exports.recordToWebHeaders = recordToWebHeaders;
|
|
2134
2284
|
exports.renderCacheControl = renderCacheControl;
|
package/dist/index.d.ts
CHANGED
|
@@ -80,7 +80,7 @@ declare const rawBodySlot: _wooksjs_event_core.Cached<Promise<Buffer<ArrayBuffer
|
|
|
80
80
|
* ```
|
|
81
81
|
*/
|
|
82
82
|
declare const useRequest: _wooksjs_event_core.WookComposable<{
|
|
83
|
-
raw:
|
|
83
|
+
raw: IncomingMessage;
|
|
84
84
|
url: string | undefined;
|
|
85
85
|
method: string | undefined;
|
|
86
86
|
headers: http.IncomingHttpHeaders;
|
|
@@ -375,10 +375,14 @@ declare class HttpResponse {
|
|
|
375
375
|
protected _status: EHttpStatusCode;
|
|
376
376
|
protected _body: unknown;
|
|
377
377
|
protected _headers: Record<string, string | string[]>;
|
|
378
|
-
|
|
379
|
-
protected
|
|
378
|
+
/** Outgoing named cookies — allocated on the first `setCookie()`. */
|
|
379
|
+
protected _cookies?: Record<string, TSetCookieData>;
|
|
380
|
+
/** Outgoing raw `Set-Cookie` strings — allocated on the first `setCookieRaw()`. */
|
|
381
|
+
protected _rawCookies?: string[];
|
|
380
382
|
protected _hasCookies: boolean;
|
|
381
383
|
protected _responded: boolean;
|
|
384
|
+
/** Weak ETag of the prerendered body picked by the last `renderBody()` (see `prerenderJson`). */
|
|
385
|
+
private _prerenderedEtag?;
|
|
382
386
|
/** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
|
|
383
387
|
get status(): EHttpStatusCode;
|
|
384
388
|
set status(value: EHttpStatusCode);
|
|
@@ -468,6 +472,14 @@ declare class HttpResponse {
|
|
|
468
472
|
*/
|
|
469
473
|
send(): void | Promise<void>;
|
|
470
474
|
private finalizeCookies;
|
|
475
|
+
/**
|
|
476
|
+
* For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
|
|
477
|
+
* GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
|
|
478
|
+
* matches into a bodiless `304`. Returns `true` when the response became `304`.
|
|
479
|
+
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
480
|
+
*/
|
|
481
|
+
private applyPrerenderedEtag;
|
|
482
|
+
private hasHeaderIgnoreCase;
|
|
471
483
|
private autoStatus;
|
|
472
484
|
private sendStream;
|
|
473
485
|
/**
|
|
@@ -729,6 +741,49 @@ declare class WooksHttp extends WooksAdapterBase {
|
|
|
729
741
|
*/
|
|
730
742
|
declare function createHttpApp(opts?: TWooksHttpOptions, wooks?: Wooks | WooksAdapterBase): WooksHttp;
|
|
731
743
|
|
|
744
|
+
/** Options for {@link prerenderJson}. */
|
|
745
|
+
interface TPrerenderJsonOptions {
|
|
746
|
+
/**
|
|
747
|
+
* Also compute a weak `ETag` from the serialized bytes. Responses that send the object then
|
|
748
|
+
* carry the `ETag` header and answer a matching `If-None-Match` (GET/HEAD, status 200) with
|
|
749
|
+
* `304 Not Modified`.
|
|
750
|
+
*/
|
|
751
|
+
etag?: boolean;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
|
|
755
|
+
* (or an interceptor) responds with that same object, the response reuses the stored JSON instead
|
|
756
|
+
* of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
|
|
757
|
+
* requests return as is — e.g. a schema or metadata envelope built once and cached.
|
|
758
|
+
*
|
|
759
|
+
* With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
|
|
760
|
+
* that responds `200` with the object sets the `ETag` header and answers a matching
|
|
761
|
+
* `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
|
|
762
|
+
* and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
|
|
763
|
+
* on the response wins and disables the `304` handling for that response.
|
|
764
|
+
*
|
|
765
|
+
* **Contract: never mutate a registered object (or anything it references).** The stored JSON
|
|
766
|
+
* is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
|
|
767
|
+
* freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
|
|
768
|
+
*
|
|
769
|
+
* Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
|
|
770
|
+
* the first time). Entries are held weakly and disappear with the object.
|
|
771
|
+
*
|
|
772
|
+
* @param obj - A JSON-serializable object or array
|
|
773
|
+
* @param options - `{ etag: true }` to also compute a weak ETag
|
|
774
|
+
* @returns The same `obj`, so it can wrap a return value
|
|
775
|
+
*
|
|
776
|
+
* @example
|
|
777
|
+
* ```ts
|
|
778
|
+
* const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
|
|
779
|
+
* app.get('/meta', () => {
|
|
780
|
+
* useResponse().setHeader('cache-control', 'private, no-cache')
|
|
781
|
+
* return envelope // sent without re-serializing; 304 on a matching If-None-Match
|
|
782
|
+
* })
|
|
783
|
+
* ```
|
|
784
|
+
*/
|
|
785
|
+
declare function prerenderJson<T extends object>(obj: T, options?: TPrerenderJsonOptions): T;
|
|
786
|
+
|
|
732
787
|
/**
|
|
733
788
|
* Configuration for `securityHeaders()`. Each option accepts a `string` (override value),
|
|
734
789
|
* `false` (disable), or `undefined` (use default). `strictTransportSecurity` has no default (opt-in only).
|
|
@@ -793,5 +848,5 @@ interface TTestHttpContext {
|
|
|
793
848
|
*/
|
|
794
849
|
declare function prepareTestHttpContext(options: TTestHttpContext): <T>(cb: (...a: any[]) => T) => T;
|
|
795
850
|
|
|
796
|
-
export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useRequest, useResponse, useUrlParams };
|
|
797
|
-
export type { KnownAcceptType, KnownAuthType, SecurityHeadersOptions, TCacheControl, TCookieAttributes, TCookieAttributesInput, THttpEventData, TRequestLimits, TSetCookieData, TTestHttpContext, TWooksErrorBody, TWooksErrorBodyExt, TWooksHttpOptions };
|
|
851
|
+
export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useRequest, useResponse, useUrlParams };
|
|
852
|
+
export type { KnownAcceptType, KnownAuthType, SecurityHeadersOptions, TCacheControl, TCookieAttributes, TCookieAttributesInput, THttpEventData, TPrerenderJsonOptions, TRequestLimits, TSetCookieData, TTestHttpContext, TWooksErrorBody, TWooksErrorBodyExt, TWooksHttpOptions };
|
package/dist/index.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { URLSearchParams } from "url";
|
|
|
7
7
|
import http, { IncomingMessage, ServerResponse } from "http";
|
|
8
8
|
import { Duplex, Readable as Readable$1, pipeline as pipeline$1 } from "stream";
|
|
9
9
|
import { WooksAdapterBase } from "wooks";
|
|
10
|
+
import { createHash } from "crypto";
|
|
10
11
|
import { Socket } from "net";
|
|
11
12
|
|
|
12
13
|
//#region packages/event-http/src/http-kind.ts
|
|
@@ -67,18 +68,22 @@ const useCookies = defineWook((ctx) => ({
|
|
|
67
68
|
}));
|
|
68
69
|
|
|
69
70
|
//#endregion
|
|
70
|
-
//#region packages/event-http/src/
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/** Whether an `Accept` header value accepts `type` (a short name from {@link ACCEPT_TYPE_MAP} or a MIME type). */
|
|
80
|
+
function acceptHeaderHas(accept, type) {
|
|
81
|
+
return !!(accept && (accept === "*/*" || accept.includes(ACCEPT_TYPE_MAP[type] || type)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region packages/event-http/src/composables/header-accept.ts
|
|
86
|
+
const acceptsMime = cachedBy((type, ctx) => acceptHeaderHas(ctx.get(httpKind.keys.req).headers.accept, type));
|
|
82
87
|
/** Provides helpers to check the request's Accept header for supported MIME types. */
|
|
83
88
|
const useAccept = defineWook((ctx) => {
|
|
84
89
|
return {
|
|
@@ -138,6 +143,19 @@ const useAuthorization = defineWook((ctx) => {
|
|
|
138
143
|
};
|
|
139
144
|
});
|
|
140
145
|
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region packages/event-http/src/composables/headers.ts
|
|
148
|
+
/**
|
|
149
|
+
* Returns the incoming request headers.
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* const { host, authorization } = useHeaders()
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
function useHeaders(ctx) {
|
|
156
|
+
return (ctx ?? current()).get(httpKind.keys.req).headers;
|
|
157
|
+
}
|
|
158
|
+
|
|
141
159
|
//#endregion
|
|
142
160
|
//#region packages/event-http/src/compressor/body-compressor.ts
|
|
143
161
|
const compressors = { identity: {
|
|
@@ -387,18 +405,72 @@ const DEFAULT_LIMITS = {
|
|
|
387
405
|
maxRatio: 100,
|
|
388
406
|
readTimeoutMs: 1e4
|
|
389
407
|
};
|
|
408
|
+
const NO_ENCODINGS = Object.freeze([]);
|
|
390
409
|
const contentEncodingsSlot = cached((ctx) => {
|
|
391
|
-
|
|
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
|
|
395
|
-
for (const p of parts) if ([
|
|
396
|
-
"deflate",
|
|
397
|
-
"gzip",
|
|
398
|
-
"br"
|
|
399
|
-
].includes(p)) return true;
|
|
415
|
+
for (const p of ctx.get(contentEncodingsSlot)) if (p === "deflate" || p === "gzip" || p === "br") return true;
|
|
400
416
|
return false;
|
|
401
417
|
});
|
|
418
|
+
/**
|
|
419
|
+
* Reads the whole request body with plain stream events (one listener set, one timer).
|
|
420
|
+
* Raw bytes are checked against `upfrontLimit` first, then against `maxInflated`
|
|
421
|
+
* (the same order as the streaming path). A stall longer than `timeoutMs` destroys the
|
|
422
|
+
* request; a premature close or stream error becomes 408. The request is destroyed
|
|
423
|
+
* once reading settles, as async iteration did.
|
|
424
|
+
*/
|
|
425
|
+
function readRawBody(req, upfrontLimit, maxInflated, timeoutMs) {
|
|
426
|
+
return new Promise((resolve, reject) => {
|
|
427
|
+
const chunks = [];
|
|
428
|
+
let bytes = 0;
|
|
429
|
+
if (req.readableEnded) {
|
|
430
|
+
req.destroy();
|
|
431
|
+
resolve(Buffer$1.alloc(0));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (req.destroyed) {
|
|
435
|
+
reject(new HttpError(408, "Request body timeout"));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let done = false;
|
|
439
|
+
const timer = timeoutMs === 0 ? null : setTimeout(() => req.destroy(), timeoutMs);
|
|
440
|
+
const finish = (error) => {
|
|
441
|
+
if (done) return;
|
|
442
|
+
done = true;
|
|
443
|
+
if (timer) clearTimeout(timer);
|
|
444
|
+
req.off("data", onData);
|
|
445
|
+
req.off("end", onEnd);
|
|
446
|
+
req.off("error", onAbort);
|
|
447
|
+
req.off("close", onAbort);
|
|
448
|
+
req.destroy();
|
|
449
|
+
if (error) reject(error);
|
|
450
|
+
else resolve(chunks.length === 1 ? chunks[0] : Buffer$1.concat(chunks, bytes));
|
|
451
|
+
};
|
|
452
|
+
const onData = (chunk) => {
|
|
453
|
+
bytes += chunk.length;
|
|
454
|
+
if (bytes > upfrontLimit) {
|
|
455
|
+
finish(new HttpError(413, "Payload Too Large"));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (bytes > maxInflated) {
|
|
459
|
+
finish(new HttpError(413, "Inflated body too large"));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
timer?.refresh();
|
|
463
|
+
chunks.push(chunk);
|
|
464
|
+
};
|
|
465
|
+
const onEnd = () => finish();
|
|
466
|
+
const onAbort = () => finish(new HttpError(408, "Request body timeout"));
|
|
467
|
+
req.on("data", onData);
|
|
468
|
+
req.on("end", onEnd);
|
|
469
|
+
req.on("error", onAbort);
|
|
470
|
+
req.on("close", onAbort);
|
|
471
|
+
req.resume();
|
|
472
|
+
});
|
|
473
|
+
}
|
|
402
474
|
/** @internal Exported for test pre-seeding — use {@link seedRawBody} instead. */
|
|
403
475
|
const rawBodySlot = cached(async (ctx) => {
|
|
404
476
|
const req = ctx.get(httpKind.keys.req);
|
|
@@ -414,6 +486,14 @@ const rawBodySlot = cached(async (ctx) => {
|
|
|
414
486
|
const upfrontLimit = isZip ? maxCompressed : maxInflated;
|
|
415
487
|
if (cl && cl > upfrontLimit) throw new HttpError(413, "Payload Too Large");
|
|
416
488
|
for (const enc of encs) if (!compressors[enc]) throw new HttpError(415, `Unsupported Content-Encoding "${enc}"`);
|
|
489
|
+
if (!streamable) {
|
|
490
|
+
const raw = await readRawBody(req, upfrontLimit, maxInflated, timeoutMs);
|
|
491
|
+
if (!isZip) return raw;
|
|
492
|
+
const body = await uncompressBody(encs, raw);
|
|
493
|
+
if (body.byteLength > maxInflated) throw new HttpError(413, "Inflated body too large");
|
|
494
|
+
if (raw.byteLength > 0 && body.byteLength / raw.byteLength > maxRatio) throw new HttpError(413, "Compression ratio too high");
|
|
495
|
+
return body;
|
|
496
|
+
}
|
|
417
497
|
let timer = null;
|
|
418
498
|
function resetTimer() {
|
|
419
499
|
if (timeoutMs === 0) return;
|
|
@@ -446,8 +526,7 @@ const rawBodySlot = cached(async (ctx) => {
|
|
|
446
526
|
clearTimer();
|
|
447
527
|
}
|
|
448
528
|
}
|
|
449
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
body = await uncompressBody(encs, body);
|
|
466
|
-
inflatedBytes = body.byteLength;
|
|
467
|
-
if (inflatedBytes > maxInflated) throw new HttpError(413, "Inflated body too large");
|
|
468
|
-
}
|
|
469
|
-
if (isZip && rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
|
|
470
|
-
return body;
|
|
542
|
+
if (rawBytes > 0 && inflatedBytes / rawBytes > maxRatio) throw new HttpError(413, "Compression ratio too high");
|
|
543
|
+
return Buffer$1.concat(chunks);
|
|
471
544
|
});
|
|
472
545
|
const forwardedIpSlot = cached((ctx) => {
|
|
473
546
|
const req = ctx.get(httpKind.keys.req);
|
|
@@ -563,19 +636,6 @@ function seedRawBody(ctx, raw) {
|
|
|
563
636
|
if (ctx.parent) ctx.getOwn(useRequest._slot);
|
|
564
637
|
}
|
|
565
638
|
|
|
566
|
-
//#endregion
|
|
567
|
-
//#region packages/event-http/src/composables/headers.ts
|
|
568
|
-
/**
|
|
569
|
-
* Returns the incoming request headers.
|
|
570
|
-
* @example
|
|
571
|
-
* ```ts
|
|
572
|
-
* const { host, authorization } = useHeaders()
|
|
573
|
-
* ```
|
|
574
|
-
*/
|
|
575
|
-
function useHeaders(ctx) {
|
|
576
|
-
return useRequest(ctx).headers;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
639
|
//#endregion
|
|
580
640
|
//#region packages/event-http/src/composables/response.ts
|
|
581
641
|
/**
|
|
@@ -734,6 +794,70 @@ const cookieAttrFunc = {
|
|
|
734
794
|
sameSite: (v) => v ? `SameSite=${typeof v === "string" ? v : "Strict"}` : ""
|
|
735
795
|
};
|
|
736
796
|
|
|
797
|
+
//#endregion
|
|
798
|
+
//#region packages/event-http/src/response/prerender.ts
|
|
799
|
+
const registry = /* @__PURE__ */ new WeakMap();
|
|
800
|
+
function weakEtag(json) {
|
|
801
|
+
return `W/"${createHash("sha256").update(json).digest("base64url").slice(0, 22)}"`;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Serializes `obj` to JSON once and remembers the string by object identity. Whenever a handler
|
|
805
|
+
* (or an interceptor) responds with that same object, the response reuses the stored JSON instead
|
|
806
|
+
* of calling `JSON.stringify` again. Use it for large, long-lived response objects that many
|
|
807
|
+
* requests return as is — e.g. a schema or metadata envelope built once and cached.
|
|
808
|
+
*
|
|
809
|
+
* With `{ etag: true }` a weak `ETag` is derived from the serialized bytes; a `GET`/`HEAD`
|
|
810
|
+
* that responds `200` with the object sets the `ETag` header and answers a matching
|
|
811
|
+
* `If-None-Match` with `304 Not Modified` (no body; other headers such as `Cache-Control`
|
|
812
|
+
* and `Vary` are kept). Error responses never become `304`. An `ETag` header set explicitly
|
|
813
|
+
* on the response wins and disables the `304` handling for that response.
|
|
814
|
+
*
|
|
815
|
+
* **Contract: never mutate a registered object (or anything it references).** The stored JSON
|
|
816
|
+
* is not refreshed, so a mutation would keep serving the old bytes. Build a new object instead —
|
|
817
|
+
* freezing registered objects (`Object.freeze`, deeply) in development makes violations throw.
|
|
818
|
+
*
|
|
819
|
+
* Calling it again for the same object is a no-op (it adds the `ETag` if it was not requested
|
|
820
|
+
* the first time). Entries are held weakly and disappear with the object.
|
|
821
|
+
*
|
|
822
|
+
* @param obj - A JSON-serializable object or array
|
|
823
|
+
* @param options - `{ etag: true }` to also compute a weak ETag
|
|
824
|
+
* @returns The same `obj`, so it can wrap a return value
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```ts
|
|
828
|
+
* const envelope = prerenderJson(Object.freeze(buildMeta()), { etag: true })
|
|
829
|
+
* app.get('/meta', () => {
|
|
830
|
+
* useResponse().setHeader('cache-control', 'private, no-cache')
|
|
831
|
+
* return envelope // sent without re-serializing; 304 on a matching If-None-Match
|
|
832
|
+
* })
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
function prerenderJson(obj, options) {
|
|
836
|
+
let entry = registry.get(obj);
|
|
837
|
+
if (!entry) {
|
|
838
|
+
const json = JSON.stringify(obj);
|
|
839
|
+
if (typeof json !== "string") throw new TypeError("prerenderJson: value does not serialize to JSON");
|
|
840
|
+
entry = { json };
|
|
841
|
+
registry.set(obj, entry);
|
|
842
|
+
}
|
|
843
|
+
if (options?.etag && !entry.etag) entry.etag = weakEtag(entry.json);
|
|
844
|
+
return obj;
|
|
845
|
+
}
|
|
846
|
+
/** @internal Returns the pre-serialized JSON (and weak ETag) registered for `obj`, if any. */
|
|
847
|
+
function getPrerenderedJson(obj) {
|
|
848
|
+
return registry.get(obj);
|
|
849
|
+
}
|
|
850
|
+
/** @internal Weak comparison of `etag` against an `If-None-Match` header value. */
|
|
851
|
+
function ifNoneMatchHas(header, etag) {
|
|
852
|
+
if (!header) return false;
|
|
853
|
+
const opaque = etag.startsWith("W/") ? etag.slice(2) : etag;
|
|
854
|
+
for (const part of header.split(",")) {
|
|
855
|
+
const tag = part.trim();
|
|
856
|
+
if (tag === "*" || (tag.startsWith("W/") ? tag.slice(2) : tag) === opaque) return true;
|
|
857
|
+
}
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
|
|
737
861
|
//#endregion
|
|
738
862
|
//#region packages/event-http/src/response/http-response.ts
|
|
739
863
|
const hasFetchResponse = typeof globalThis.Response === "function";
|
|
@@ -772,8 +896,6 @@ var HttpResponse = class {
|
|
|
772
896
|
this._status = 0;
|
|
773
897
|
this._body = void 0;
|
|
774
898
|
this._headers = {};
|
|
775
|
-
this._cookies = {};
|
|
776
|
-
this._rawCookies = [];
|
|
777
899
|
this._hasCookies = false;
|
|
778
900
|
this._responded = false;
|
|
779
901
|
if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
|
|
@@ -841,7 +963,7 @@ var HttpResponse = class {
|
|
|
841
963
|
}
|
|
842
964
|
/** Sets an outgoing `Set-Cookie` header with optional attributes (chainable). */
|
|
843
965
|
setCookie(name, value, attrs) {
|
|
844
|
-
this._cookies[name] = {
|
|
966
|
+
(this._cookies ??= {})[name] = {
|
|
845
967
|
value,
|
|
846
968
|
attrs: attrs || {}
|
|
847
969
|
};
|
|
@@ -850,23 +972,23 @@ var HttpResponse = class {
|
|
|
850
972
|
}
|
|
851
973
|
/** Returns a previously set cookie's data, or `undefined` if not set. */
|
|
852
974
|
getCookie(name) {
|
|
853
|
-
return this._cookies[name];
|
|
975
|
+
return this._cookies?.[name];
|
|
854
976
|
}
|
|
855
977
|
/** Removes a cookie from the outgoing set list (chainable). */
|
|
856
978
|
removeCookie(name) {
|
|
857
|
-
delete this._cookies[name];
|
|
979
|
+
if (this._cookies) delete this._cookies[name];
|
|
858
980
|
return this;
|
|
859
981
|
}
|
|
860
982
|
/** Removes all outgoing cookies (chainable). */
|
|
861
983
|
clearCookies() {
|
|
862
|
-
this._cookies =
|
|
863
|
-
this._rawCookies =
|
|
984
|
+
this._cookies = void 0;
|
|
985
|
+
this._rawCookies = void 0;
|
|
864
986
|
this._hasCookies = false;
|
|
865
987
|
return this;
|
|
866
988
|
}
|
|
867
989
|
/** Appends a raw `Set-Cookie` header string (chainable). Use when you need full control over the cookie format. */
|
|
868
990
|
setCookieRaw(rawValue) {
|
|
869
|
-
this._rawCookies.push(rawValue);
|
|
991
|
+
(this._rawCookies ??= []).push(rawValue);
|
|
870
992
|
this._hasCookies = true;
|
|
871
993
|
return this;
|
|
872
994
|
}
|
|
@@ -881,8 +1003,10 @@ var HttpResponse = class {
|
|
|
881
1003
|
*/
|
|
882
1004
|
getSetCookieStrings() {
|
|
883
1005
|
const rendered = [];
|
|
884
|
-
|
|
885
|
-
|
|
1006
|
+
if (this._cookies) {
|
|
1007
|
+
for (const [name, data] of Object.entries(this._cookies)) if (data) rendered.push(renderCookie(name, data));
|
|
1008
|
+
}
|
|
1009
|
+
if (this._rawCookies) rendered.push(...this._rawCookies);
|
|
886
1010
|
return rendered;
|
|
887
1011
|
}
|
|
888
1012
|
/** Sets the `Cache-Control` header from a directive object (chainable). */
|
|
@@ -944,6 +1068,10 @@ var HttpResponse = class {
|
|
|
944
1068
|
}
|
|
945
1069
|
const rendered = this.renderBody();
|
|
946
1070
|
this.autoStatus(!!rendered);
|
|
1071
|
+
if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
|
|
1072
|
+
status: this._status,
|
|
1073
|
+
headers: this._buildWebHeaders()
|
|
1074
|
+
});
|
|
947
1075
|
if (rendered) {
|
|
948
1076
|
const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
|
|
949
1077
|
this._headers["content-length"] = contentLength.toString();
|
|
@@ -992,6 +1120,11 @@ var HttpResponse = class {
|
|
|
992
1120
|
if (body instanceof Uint8Array) return body;
|
|
993
1121
|
if (typeof body === "object") {
|
|
994
1122
|
if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
|
|
1123
|
+
const prerendered = getPrerenderedJson(body);
|
|
1124
|
+
if (prerendered) {
|
|
1125
|
+
this._prerenderedEtag = prerendered.etag;
|
|
1126
|
+
return prerendered.json;
|
|
1127
|
+
}
|
|
995
1128
|
return JSON.stringify(body);
|
|
996
1129
|
}
|
|
997
1130
|
throw new Error(`Unsupported body format "${typeof body}"`);
|
|
@@ -1043,6 +1176,31 @@ var HttpResponse = class {
|
|
|
1043
1176
|
}
|
|
1044
1177
|
this._hasCookies = false;
|
|
1045
1178
|
}
|
|
1179
|
+
/**
|
|
1180
|
+
* For a body registered with `prerenderJson(obj, { etag: true })`: sets the `ETag` header on a
|
|
1181
|
+
* GET/HEAD 2xx response (unless one was set explicitly) and turns a `200` whose `If-None-Match`
|
|
1182
|
+
* matches into a bodiless `304`. Returns `true` when the response became `304`.
|
|
1183
|
+
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
1184
|
+
*/
|
|
1185
|
+
applyPrerenderedEtag() {
|
|
1186
|
+
const etag = this._prerenderedEtag;
|
|
1187
|
+
if (!etag) return false;
|
|
1188
|
+
this._prerenderedEtag = void 0;
|
|
1189
|
+
const method = this._req.method;
|
|
1190
|
+
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.hasHeaderIgnoreCase("etag")) return false;
|
|
1191
|
+
this._headers.etag = etag;
|
|
1192
|
+
if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
|
|
1193
|
+
this._status = EHttpStatusCode.NotModified;
|
|
1194
|
+
for (const key in this._headers) {
|
|
1195
|
+
const lower = key.toLowerCase();
|
|
1196
|
+
if (lower === "content-type" || lower === "content-length") delete this._headers[key];
|
|
1197
|
+
}
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
hasHeaderIgnoreCase(name) {
|
|
1201
|
+
for (const key in this._headers) if (key.toLowerCase() === name) return true;
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1046
1204
|
autoStatus(hasBody) {
|
|
1047
1205
|
if (this._status) return;
|
|
1048
1206
|
if (!hasBody) {
|
|
@@ -1090,6 +1248,10 @@ var HttpResponse = class {
|
|
|
1090
1248
|
sendRegular(method) {
|
|
1091
1249
|
const renderedBody = this.renderBody();
|
|
1092
1250
|
this.autoStatus(!!renderedBody);
|
|
1251
|
+
if (this.applyPrerenderedEtag()) {
|
|
1252
|
+
this._res.writeHead(this._status, this._headers).end();
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1093
1255
|
const contentLength = typeof renderedBody === "string" ? Buffer.byteLength(renderedBody) : renderedBody.byteLength;
|
|
1094
1256
|
this._headers["content-length"] = contentLength.toString();
|
|
1095
1257
|
this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : renderedBody);
|
|
@@ -1468,7 +1630,7 @@ function escapeHtml(value) {
|
|
|
1468
1630
|
//#endregion
|
|
1469
1631
|
//#region packages/event-http/src/response/wooks-http-response.ts
|
|
1470
1632
|
let framework = {
|
|
1471
|
-
version: "0.7.
|
|
1633
|
+
version: "0.7.27",
|
|
1472
1634
|
poweredBy: "wooksjs",
|
|
1473
1635
|
link: "https://wooks.moost.org/",
|
|
1474
1636
|
image: "https://wooks.moost.org/wooks-full-logo.svg"
|
|
@@ -1492,7 +1654,8 @@ var WooksHttpResponse = class extends HttpResponse {
|
|
|
1492
1654
|
}
|
|
1493
1655
|
renderError(data, ctx) {
|
|
1494
1656
|
this._status = data.statusCode || 500;
|
|
1495
|
-
const
|
|
1657
|
+
const accept = ctx.get(httpKind.keys.req).headers.accept;
|
|
1658
|
+
const has = (type) => acceptHeaderHas(accept, type);
|
|
1496
1659
|
if (has("json")) {
|
|
1497
1660
|
this._headers["content-type"] = "application/json";
|
|
1498
1661
|
this._body = JSON.stringify(data);
|
|
@@ -1788,33 +1951,19 @@ var WooksHttp = class extends WooksAdapterBase {
|
|
|
1788
1951
|
}
|
|
1789
1952
|
}
|
|
1790
1953
|
async processAsyncResult(promise, handlers, startIndex, ctx, response) {
|
|
1791
|
-
try {
|
|
1792
|
-
const result = await promise;
|
|
1793
|
-
|
|
1954
|
+
for (let i = startIndex; i < handlers.length; i++) try {
|
|
1955
|
+
const result = await (i === startIndex ? promise : handlers[i]());
|
|
1956
|
+
const sent = this.respond(result, response, ctx);
|
|
1957
|
+
if (sent) await sent;
|
|
1794
1958
|
return result;
|
|
1795
1959
|
} catch (error) {
|
|
1796
|
-
const isLastHandler = startIndex === handlers.length - 1;
|
|
1797
1960
|
if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
|
|
1798
|
-
if (
|
|
1799
|
-
|
|
1961
|
+
if (i === handlers.length - 1) {
|
|
1962
|
+
const sent = this.respond(error, response, ctx);
|
|
1963
|
+
if (sent) await sent;
|
|
1800
1964
|
return error;
|
|
1801
1965
|
}
|
|
1802
1966
|
}
|
|
1803
|
-
for (let i = startIndex + 1; i < handlers.length; i++) {
|
|
1804
|
-
const handler = handlers[i];
|
|
1805
|
-
const isLastHandler = i === handlers.length - 1;
|
|
1806
|
-
try {
|
|
1807
|
-
const result = await handler();
|
|
1808
|
-
await this.respond(result, response, ctx);
|
|
1809
|
-
return result;
|
|
1810
|
-
} catch (error) {
|
|
1811
|
-
if (!(error instanceof HttpError)) this.logger.error(`Uncaught route handler exception: ${ctx.get(httpKind.keys.req)?.url || ""}`, error);
|
|
1812
|
-
if (isLastHandler) {
|
|
1813
|
-
await this.respond(error, response, ctx);
|
|
1814
|
-
return error;
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
1818
1967
|
}
|
|
1819
1968
|
/**
|
|
1820
1969
|
* Programmatic route invocation using the Web Standard fetch API.
|
|
@@ -2087,4 +2236,4 @@ function prepareTestHttpContext(options) {
|
|
|
2087
2236
|
}
|
|
2088
2237
|
|
|
2089
2238
|
//#endregion
|
|
2090
|
-
export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useLogger, useRequest, useResponse, useRouteParams, useUrlParams };
|
|
2239
|
+
export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useLogger, useRequest, useResponse, useRouteParams, useUrlParams };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wooksjs/event-http",
|
|
3
|
-
"version": "0.7.
|
|
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.
|
|
46
|
-
"wooks": "^0.7.
|
|
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.
|
|
51
|
-
"
|
|
52
|
-
"
|
|
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"
|