@wooksjs/event-http 0.7.27 → 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 +250 -24
- package/dist/index.d.ts +113 -6
- package/dist/index.mjs +250 -26
- package/package.json +5 -5
package/dist/index.cjs
CHANGED
|
@@ -747,6 +747,120 @@ function useHttpContext(ctx) {
|
|
|
747
747
|
return ctx ?? (0, _wooksjs_event_core.current)();
|
|
748
748
|
}
|
|
749
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
|
+
|
|
750
864
|
//#endregion
|
|
751
865
|
//#region packages/event-http/src/utils/time.ts
|
|
752
866
|
function convertTime(time, unit = "ms") {
|
|
@@ -916,8 +1030,10 @@ var HttpResponse = class {
|
|
|
916
1030
|
* @param _req - The underlying Node.js `IncomingMessage`.
|
|
917
1031
|
* @param _logger - Logger instance for error reporting.
|
|
918
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).
|
|
919
1035
|
*/
|
|
920
|
-
constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
|
|
1036
|
+
constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
|
|
921
1037
|
this._res = _res;
|
|
922
1038
|
this._req = _req;
|
|
923
1039
|
this._logger = _logger;
|
|
@@ -927,6 +1043,7 @@ var HttpResponse = class {
|
|
|
927
1043
|
this._headers = {};
|
|
928
1044
|
this._hasCookies = false;
|
|
929
1045
|
this._responded = false;
|
|
1046
|
+
this._compression = compression;
|
|
930
1047
|
if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
|
|
931
1048
|
}
|
|
932
1049
|
/** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
|
|
@@ -1059,6 +1176,24 @@ var HttpResponse = class {
|
|
|
1059
1176
|
return this;
|
|
1060
1177
|
}
|
|
1061
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
|
+
/**
|
|
1062
1197
|
* Returns the underlying Node.js `ServerResponse`.
|
|
1063
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.
|
|
1064
1199
|
*/
|
|
@@ -1096,15 +1231,13 @@ var HttpResponse = class {
|
|
|
1096
1231
|
});
|
|
1097
1232
|
}
|
|
1098
1233
|
const rendered = this.renderBody();
|
|
1234
|
+
const prerendered = this.takePrerendered();
|
|
1099
1235
|
this.autoStatus(!!rendered);
|
|
1100
|
-
if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
|
|
1236
|
+
if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
|
|
1101
1237
|
status: this._status,
|
|
1102
1238
|
headers: this._buildWebHeaders()
|
|
1103
1239
|
});
|
|
1104
|
-
if (rendered)
|
|
1105
|
-
const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
|
|
1106
|
-
this._headers["content-length"] = contentLength.toString();
|
|
1107
|
-
}
|
|
1240
|
+
if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
|
|
1108
1241
|
const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
|
|
1109
1242
|
const webResponse = new globalThis.Response(webBody, {
|
|
1110
1243
|
status: this._status,
|
|
@@ -1151,7 +1284,7 @@ var HttpResponse = class {
|
|
|
1151
1284
|
if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
|
|
1152
1285
|
const prerendered = getPrerenderedJson(body);
|
|
1153
1286
|
if (prerendered) {
|
|
1154
|
-
this.
|
|
1287
|
+
this._prerendered = prerendered;
|
|
1155
1288
|
return prerendered.json;
|
|
1156
1289
|
}
|
|
1157
1290
|
return JSON.stringify(body);
|
|
@@ -1174,6 +1307,7 @@ var HttpResponse = class {
|
|
|
1174
1307
|
*
|
|
1175
1308
|
* Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
|
|
1176
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.
|
|
1177
1311
|
*
|
|
1178
1312
|
* @throws Error if the response was already sent.
|
|
1179
1313
|
*/
|
|
@@ -1193,7 +1327,7 @@ var HttpResponse = class {
|
|
|
1193
1327
|
const method = this._req.method;
|
|
1194
1328
|
if (body instanceof stream.Readable) return this.sendStream(body, method);
|
|
1195
1329
|
if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
|
|
1196
|
-
this.sendRegular(method);
|
|
1330
|
+
return this.sendRegular(method);
|
|
1197
1331
|
}
|
|
1198
1332
|
finalizeCookies() {
|
|
1199
1333
|
if (!this._hasCookies) return;
|
|
@@ -1211,12 +1345,9 @@ var HttpResponse = class {
|
|
|
1211
1345
|
* matches into a bodiless `304`. Returns `true` when the response became `304`.
|
|
1212
1346
|
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
1213
1347
|
*/
|
|
1214
|
-
applyPrerenderedEtag() {
|
|
1215
|
-
const etag = this._prerenderedEtag;
|
|
1216
|
-
if (!etag) return false;
|
|
1217
|
-
this._prerenderedEtag = void 0;
|
|
1348
|
+
applyPrerenderedEtag(etag) {
|
|
1218
1349
|
const method = this._req.method;
|
|
1219
|
-
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.
|
|
1350
|
+
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
|
|
1220
1351
|
this._headers.etag = etag;
|
|
1221
1352
|
if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
|
|
1222
1353
|
this._status = EHttpStatusCode.NotModified;
|
|
@@ -1226,9 +1357,16 @@ var HttpResponse = class {
|
|
|
1226
1357
|
}
|
|
1227
1358
|
return true;
|
|
1228
1359
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
return
|
|
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;
|
|
1232
1370
|
}
|
|
1233
1371
|
autoStatus(hasBody) {
|
|
1234
1372
|
if (this._status) return;
|
|
@@ -1275,17 +1413,101 @@ var HttpResponse = class {
|
|
|
1275
1413
|
return this.pipeToResponse(stream.Readable.fromWeb(fetchResponse.body), "Error streaming fetch response body");
|
|
1276
1414
|
}
|
|
1277
1415
|
sendRegular(method) {
|
|
1278
|
-
const
|
|
1279
|
-
this.
|
|
1280
|
-
|
|
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)) {
|
|
1281
1423
|
this._res.writeHead(this._status, this._headers).end();
|
|
1282
1424
|
return;
|
|
1283
1425
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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);
|
|
1287
1504
|
}
|
|
1288
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
|
+
}
|
|
1289
1511
|
/** Converts a Record of headers to a Web Standard `Headers` object. */
|
|
1290
1512
|
function recordToWebHeaders(record) {
|
|
1291
1513
|
const headers = new Headers();
|
|
@@ -1659,7 +1881,7 @@ function escapeHtml(value) {
|
|
|
1659
1881
|
//#endregion
|
|
1660
1882
|
//#region packages/event-http/src/response/wooks-http-response.ts
|
|
1661
1883
|
let framework = {
|
|
1662
|
-
version: "0.7.
|
|
1884
|
+
version: "0.7.28",
|
|
1663
1885
|
poweredBy: "wooksjs",
|
|
1664
1886
|
link: "https://wooks.moost.org/",
|
|
1665
1887
|
image: "https://wooks.moost.org/wooks-full-logo.svg"
|
|
@@ -1755,6 +1977,7 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
|
|
|
1755
1977
|
this.logger = opts?.logger || this.getLogger(`[96m[wooks-http]`);
|
|
1756
1978
|
this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
|
|
1757
1979
|
this.eventContextOptions = this.getEventContextOptions();
|
|
1980
|
+
this.compression = resolveCompression(opts?.compression);
|
|
1758
1981
|
}
|
|
1759
1982
|
/** Registers a handler for all HTTP methods on the given path. */
|
|
1760
1983
|
all(path, handler) {
|
|
@@ -1877,8 +2100,9 @@ var WooksHttp = class extends wooks.WooksAdapterBase {
|
|
|
1877
2100
|
const RequestLimits = this.opts?.requestLimits;
|
|
1878
2101
|
const notFoundHandler = this.opts?.onNotFound;
|
|
1879
2102
|
const defaultHeaders = this.opts?.defaultHeaders;
|
|
2103
|
+
const compression = this.compression;
|
|
1880
2104
|
return (req, res) => {
|
|
1881
|
-
const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
|
|
2105
|
+
const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
|
|
1882
2106
|
const method = req.method || "";
|
|
1883
2107
|
const url = req.url || "";
|
|
1884
2108
|
createHttpContext(ctxOptions, {
|
|
@@ -2277,6 +2501,8 @@ exports.createHttpApp = createHttpApp;
|
|
|
2277
2501
|
exports.createHttpContext = createHttpContext;
|
|
2278
2502
|
exports.httpKind = httpKind;
|
|
2279
2503
|
exports.httpStatusCodes = httpStatusCodes;
|
|
2504
|
+
exports.isCompressibleType = isCompressibleType;
|
|
2505
|
+
exports.negotiateEncoding = negotiateEncoding;
|
|
2280
2506
|
exports.prepareTestHttpContext = prepareTestHttpContext;
|
|
2281
2507
|
exports.prerenderJson = prerenderJson;
|
|
2282
2508
|
exports.rawBodySlot = rawBodySlot;
|
package/dist/index.d.ts
CHANGED
|
@@ -347,6 +347,60 @@ interface TCacheControl {
|
|
|
347
347
|
/** Renders a `TCacheControl` object into a `Cache-Control` header string. */
|
|
348
348
|
declare function renderCacheControl(data: TCacheControl): string;
|
|
349
349
|
|
|
350
|
+
/** Content codings supported by response compression, in the order they are listed by default. */
|
|
351
|
+
type THttpCompressionEncoding = 'br' | 'gzip';
|
|
352
|
+
/**
|
|
353
|
+
* Response compression settings — the `compression` option of `createHttpApp()` and the
|
|
354
|
+
* argument of `HttpResponse.setCompression()`.
|
|
355
|
+
*/
|
|
356
|
+
interface THttpCompressionOptions {
|
|
357
|
+
/** Minimum body size in bytes to compress. Smaller bodies are sent as is. @default 1024 */
|
|
358
|
+
threshold?: number;
|
|
359
|
+
/**
|
|
360
|
+
* Codings the server may use, in preference order. The client's `Accept-Encoding` q-values
|
|
361
|
+
* decide first; this order breaks ties. @default ['br', 'gzip']
|
|
362
|
+
*/
|
|
363
|
+
encodings?: THttpCompressionEncoding[];
|
|
364
|
+
/** Brotli quality (0–11). Higher is smaller and much slower — keep 4–5 for dynamic bodies. @default 4 */
|
|
365
|
+
brotliQuality?: number;
|
|
366
|
+
/** Gzip level (1–9). @default 6 */
|
|
367
|
+
gzipLevel?: number;
|
|
368
|
+
/**
|
|
369
|
+
* Decides whether a response with this content type may be compressed.
|
|
370
|
+
* Replaces the default check — call `isCompressibleType(contentType)` inside to extend it.
|
|
371
|
+
* @default isCompressibleType
|
|
372
|
+
*/
|
|
373
|
+
filter?: (contentType: string, response: HttpResponse) => boolean;
|
|
374
|
+
}
|
|
375
|
+
/** Fully resolved compression settings (what `HttpResponse.compression` returns). */
|
|
376
|
+
interface TResolvedHttpCompression {
|
|
377
|
+
threshold: number;
|
|
378
|
+
encodings: THttpCompressionEncoding[];
|
|
379
|
+
brotliQuality: number;
|
|
380
|
+
gzipLevel: number;
|
|
381
|
+
filter: (contentType: string, response: HttpResponse) => boolean;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Default compression filter: `text/*` (except `text/event-stream`), JSON and `+json`, XML and
|
|
385
|
+
* `+xml`, JavaScript, SVG, NDJSON, WASM and form-urlencoded content types.
|
|
386
|
+
*/
|
|
387
|
+
declare function isCompressibleType(contentType: string): boolean;
|
|
388
|
+
/**
|
|
389
|
+
* Picks the content coding for a response from the request's `Accept-Encoding` header.
|
|
390
|
+
*
|
|
391
|
+
* The coding with the highest client q-value wins; `supported` order breaks ties. `q=0`
|
|
392
|
+
* excludes a coding, `*` matches every coding not listed explicitly. Returns `undefined` when
|
|
393
|
+
* the header is missing or none of `supported` is acceptable — send the body uncompressed then.
|
|
394
|
+
*
|
|
395
|
+
* @example
|
|
396
|
+
* ```ts
|
|
397
|
+
* negotiateEncoding('gzip, deflate, br', ['br', 'gzip']) // 'br'
|
|
398
|
+
* negotiateEncoding('br;q=0.5, gzip', ['br', 'gzip']) // 'gzip'
|
|
399
|
+
* negotiateEncoding('*;q=0, identity', ['br', 'gzip']) // undefined
|
|
400
|
+
* ```
|
|
401
|
+
*/
|
|
402
|
+
declare function negotiateEncoding<T extends string>(acceptEncoding: string | string[] | undefined, supported: readonly T[]): T | undefined;
|
|
403
|
+
|
|
350
404
|
/**
|
|
351
405
|
* Manages response status, headers, cookies, cache control, and body for an HTTP request.
|
|
352
406
|
*
|
|
@@ -370,8 +424,10 @@ declare class HttpResponse {
|
|
|
370
424
|
* @param _req - The underlying Node.js `IncomingMessage`.
|
|
371
425
|
* @param _logger - Logger instance for error reporting.
|
|
372
426
|
* @param defaultHeaders - Optional headers to pre-populate on this response (e.g. from `securityHeaders()`).
|
|
427
|
+
* @param _captureMode - Finalize state on `send()` without writing to `_res` (programmatic fetch).
|
|
428
|
+
* @param compression - App-level response compression settings (`undefined` = off).
|
|
373
429
|
*/
|
|
374
|
-
constructor(_res: ServerResponse, _req: IncomingMessage, _logger: Logger, defaultHeaders?: Record<string, string | string[]>, _captureMode?: boolean);
|
|
430
|
+
constructor(_res: ServerResponse, _req: IncomingMessage, _logger: Logger, defaultHeaders?: Record<string, string | string[]>, _captureMode?: boolean, compression?: TResolvedHttpCompression);
|
|
375
431
|
protected _status: EHttpStatusCode;
|
|
376
432
|
protected _body: unknown;
|
|
377
433
|
protected _headers: Record<string, string | string[]>;
|
|
@@ -381,8 +437,10 @@ declare class HttpResponse {
|
|
|
381
437
|
protected _rawCookies?: string[];
|
|
382
438
|
protected _hasCookies: boolean;
|
|
383
439
|
protected _responded: boolean;
|
|
384
|
-
/**
|
|
385
|
-
private
|
|
440
|
+
/** Registry entry of the prerendered body picked by the last `renderBody()` (see `prerenderJson`). */
|
|
441
|
+
private _prerendered?;
|
|
442
|
+
/** Effective compression settings for this response (`undefined` = off). */
|
|
443
|
+
protected _compression: TResolvedHttpCompression | undefined;
|
|
386
444
|
/** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
|
|
387
445
|
get status(): EHttpStatusCode;
|
|
388
446
|
set status(value: EHttpStatusCode);
|
|
@@ -437,6 +495,19 @@ declare class HttpResponse {
|
|
|
437
495
|
setExpires(value: Date | string | number): this;
|
|
438
496
|
/** Sets or clears the `Pragma: no-cache` header (chainable). */
|
|
439
497
|
setPragmaNoCache(value?: boolean): this;
|
|
498
|
+
/**
|
|
499
|
+
* Overrides response compression for this response (chainable).
|
|
500
|
+
*
|
|
501
|
+
* - `false` — never compress this response (e.g. a body that mixes secrets with reflected input).
|
|
502
|
+
* - `true` — compress with the app settings, or the defaults when the app has compression off.
|
|
503
|
+
* - an options object — compress with these settings over the app settings (or the defaults).
|
|
504
|
+
*
|
|
505
|
+
* Only regular bodies (strings, numbers, booleans, objects, `Uint8Array`) are compressed; streams
|
|
506
|
+
* and fetch `Response` bodies are sent as is.
|
|
507
|
+
*/
|
|
508
|
+
setCompression(value: boolean | THttpCompressionOptions): this;
|
|
509
|
+
/** Effective compression settings for this response, or `false` when compression is off. */
|
|
510
|
+
get compression(): Readonly<TResolvedHttpCompression> | false;
|
|
440
511
|
/**
|
|
441
512
|
* Returns the underlying Node.js `ServerResponse`.
|
|
442
513
|
* @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.
|
|
@@ -467,6 +538,7 @@ declare class HttpResponse {
|
|
|
467
538
|
*
|
|
468
539
|
* Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
|
|
469
540
|
* then writes the body. Supports `Readable` streams, `fetch` `Response` objects, and regular values.
|
|
541
|
+
* Returns a Promise (which never rejects) for streamed bodies and compressed regular bodies.
|
|
470
542
|
*
|
|
471
543
|
* @throws Error if the response was already sent.
|
|
472
544
|
*/
|
|
@@ -479,7 +551,10 @@ declare class HttpResponse {
|
|
|
479
551
|
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
480
552
|
*/
|
|
481
553
|
private applyPrerenderedEtag;
|
|
482
|
-
|
|
554
|
+
/** Returns the stored key of header `name` (lower-case) in whatever casing it was set with. */
|
|
555
|
+
private headerKeyIgnoreCase;
|
|
556
|
+
/** Returns and clears the prerender entry picked by the last `renderBody()`. */
|
|
557
|
+
private takePrerendered;
|
|
483
558
|
private autoStatus;
|
|
484
559
|
private sendStream;
|
|
485
560
|
/**
|
|
@@ -493,6 +568,25 @@ declare class HttpResponse {
|
|
|
493
568
|
private pipeToResponse;
|
|
494
569
|
private sendFetchResponse;
|
|
495
570
|
private sendRegular;
|
|
571
|
+
/**
|
|
572
|
+
* Whether a rendered regular body of `size` bytes may be compressed — independent of the
|
|
573
|
+
* request's `Accept-Encoding` (and of HEAD, which is answered uncompressed).
|
|
574
|
+
*/
|
|
575
|
+
private isCompressible;
|
|
576
|
+
/** Adds `token` to the `Vary` header (case-insensitive merge, keeps existing entries). */
|
|
577
|
+
private appendVary;
|
|
578
|
+
/**
|
|
579
|
+
* Sends the body compressed with `encoding`. Prerendered bodies are compressed once per coding
|
|
580
|
+
* and level and the bytes reused (sent synchronously once ready). A compressor failure is
|
|
581
|
+
* logged and the body is sent uncompressed — headers are not written yet, so that is safe.
|
|
582
|
+
* Never rejects: a sync handler's `send()` is not awaited.
|
|
583
|
+
*/
|
|
584
|
+
private sendCompressed;
|
|
585
|
+
/**
|
|
586
|
+
* Writes the body picked by `sendCompressed()` — the `encoding`-compressed bytes, or the identity
|
|
587
|
+
* body after a compressor failure — unless the client went away meanwhile.
|
|
588
|
+
*/
|
|
589
|
+
private writeDeferred;
|
|
496
590
|
}
|
|
497
591
|
/** Converts a Record of headers to a Web Standard `Headers` object. */
|
|
498
592
|
declare function recordToWebHeaders(record: Record<string, string | string[]>): Headers;
|
|
@@ -595,6 +689,18 @@ interface TWooksHttpOptions {
|
|
|
595
689
|
* @default DEFAULT_FORWARD_HEADERS — ['authorization', 'cookie', 'accept-language', 'x-forwarded-for', 'x-request-id']
|
|
596
690
|
*/
|
|
597
691
|
forwardHeaders?: string[] | false;
|
|
692
|
+
/**
|
|
693
|
+
* Compresses regular response bodies (JSON, text, HTML, …) with brotli or gzip, negotiated
|
|
694
|
+
* from the request's `Accept-Encoding`. Off by default. `true` uses the defaults
|
|
695
|
+
* (`threshold: 1024`, `encodings: ['br', 'gzip']`, `brotliQuality: 4`, `gzipLevel: 6`);
|
|
696
|
+
* an object overrides them. Streams, fetch `Response` bodies and programmatic `fetch()`
|
|
697
|
+
* responses are never compressed. Opt a single response out with
|
|
698
|
+
* `useResponse().setCompression(false)`.
|
|
699
|
+
*
|
|
700
|
+
* Do not enable it for responses that mix secrets with attacker-controlled input (BREACH).
|
|
701
|
+
* @default false
|
|
702
|
+
*/
|
|
703
|
+
compression?: boolean | THttpCompressionOptions;
|
|
598
704
|
}
|
|
599
705
|
/** HTTP adapter for Wooks that provides route registration, server lifecycle, and request handling. */
|
|
600
706
|
declare class WooksHttp extends WooksAdapterBase {
|
|
@@ -602,6 +708,7 @@ declare class WooksHttp extends WooksAdapterBase {
|
|
|
602
708
|
protected logger: TConsoleBase;
|
|
603
709
|
protected ResponseClass: typeof WooksHttpResponse;
|
|
604
710
|
protected eventContextOptions: EventContextOptions;
|
|
711
|
+
protected compression: TResolvedHttpCompression | undefined;
|
|
605
712
|
constructor(opts?: TWooksHttpOptions | undefined, wooks?: Wooks | WooksAdapterBase);
|
|
606
713
|
/** Registers a handler for all HTTP methods on the given path. */
|
|
607
714
|
all<ResType = unknown, ParamsType = Record<string, string | string[]>>(path: string, handler: TWooksHandler<ResType>): wooks.TProstoRouterPathHandle<ParamsType>;
|
|
@@ -848,5 +955,5 @@ interface TTestHttpContext {
|
|
|
848
955
|
*/
|
|
849
956
|
declare function prepareTestHttpContext(options: TTestHttpContext): <T>(cb: (...a: any[]) => T) => T;
|
|
850
957
|
|
|
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 };
|
|
958
|
+
export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, isCompressibleType, negotiateEncoding, prepareTestHttpContext, prerenderJson, rawBodySlot, recordToWebHeaders, renderCacheControl, securityHeaders, seedRawBody, useAccept, useAuthorization, useCookies, useHeaders, useHttpContext, useRequest, useResponse, useUrlParams };
|
|
959
|
+
export type { KnownAcceptType, KnownAuthType, SecurityHeadersOptions, TCacheControl, TCookieAttributes, TCookieAttributesInput, THttpCompressionEncoding, THttpCompressionOptions, THttpEventData, TPrerenderJsonOptions, TRequestLimits, TResolvedHttpCompression, TSetCookieData, TTestHttpContext, TWooksErrorBody, TWooksErrorBodyExt, TWooksHttpOptions };
|
package/dist/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { EventContext, cached, cachedBy, createEventContext, current, defineEven
|
|
|
2
2
|
import { Buffer as Buffer$1 } from "buffer";
|
|
3
3
|
import { Readable, pipeline } from "node:stream";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
-
import { createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate } from "node:zlib";
|
|
5
|
+
import { brotliCompress, constants, createBrotliCompress, createBrotliDecompress, createDeflate, createGunzip, createGzip, createInflate, gzip } from "node:zlib";
|
|
6
6
|
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";
|
|
@@ -718,6 +718,120 @@ function useHttpContext(ctx) {
|
|
|
718
718
|
return ctx ?? current();
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region packages/event-http/src/response/compression.ts
|
|
723
|
+
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;
|
|
724
|
+
/**
|
|
725
|
+
* Default compression filter: `text/*` (except `text/event-stream`), JSON and `+json`, XML and
|
|
726
|
+
* `+xml`, JavaScript, SVG, NDJSON, WASM and form-urlencoded content types.
|
|
727
|
+
*/
|
|
728
|
+
function isCompressibleType(contentType) {
|
|
729
|
+
return COMPRESSIBLE_TYPE.test(contentType);
|
|
730
|
+
}
|
|
731
|
+
const DEFAULTS = {
|
|
732
|
+
threshold: 1024,
|
|
733
|
+
encodings: ["br", "gzip"],
|
|
734
|
+
brotliQuality: 4,
|
|
735
|
+
gzipLevel: 6,
|
|
736
|
+
filter: isCompressibleType
|
|
737
|
+
};
|
|
738
|
+
/**
|
|
739
|
+
* @internal Resolves the `compression` option. `base` supplies the values `value` does not set
|
|
740
|
+
* (the app settings for a per-response override). Returns `undefined` when compression is off.
|
|
741
|
+
*/
|
|
742
|
+
function resolveCompression(value, base = DEFAULTS) {
|
|
743
|
+
if (!value) return;
|
|
744
|
+
if (value === true) return base;
|
|
745
|
+
return {
|
|
746
|
+
threshold: value.threshold ?? base.threshold,
|
|
747
|
+
encodings: value.encodings ?? base.encodings,
|
|
748
|
+
brotliQuality: value.brotliQuality ?? base.brotliQuality,
|
|
749
|
+
gzipLevel: value.gzipLevel ?? base.gzipLevel,
|
|
750
|
+
filter: value.filter ?? base.filter
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Picks the content coding for a response from the request's `Accept-Encoding` header.
|
|
755
|
+
*
|
|
756
|
+
* The coding with the highest client q-value wins; `supported` order breaks ties. `q=0`
|
|
757
|
+
* excludes a coding, `*` matches every coding not listed explicitly. Returns `undefined` when
|
|
758
|
+
* the header is missing or none of `supported` is acceptable — send the body uncompressed then.
|
|
759
|
+
*
|
|
760
|
+
* @example
|
|
761
|
+
* ```ts
|
|
762
|
+
* negotiateEncoding('gzip, deflate, br', ['br', 'gzip']) // 'br'
|
|
763
|
+
* negotiateEncoding('br;q=0.5, gzip', ['br', 'gzip']) // 'gzip'
|
|
764
|
+
* negotiateEncoding('*;q=0, identity', ['br', 'gzip']) // undefined
|
|
765
|
+
* ```
|
|
766
|
+
*/
|
|
767
|
+
function negotiateEncoding(acceptEncoding, supported) {
|
|
768
|
+
if (!acceptEncoding) return;
|
|
769
|
+
const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(",") : acceptEncoding;
|
|
770
|
+
const listed = /* @__PURE__ */ new Map();
|
|
771
|
+
let wildcard;
|
|
772
|
+
for (const part of header.split(",")) {
|
|
773
|
+
const semi = part.indexOf(";");
|
|
774
|
+
const name = (semi === -1 ? part : part.slice(0, semi)).trim().toLowerCase();
|
|
775
|
+
if (!name) continue;
|
|
776
|
+
const q = semi === -1 ? 1 : parseQ(part.slice(semi + 1));
|
|
777
|
+
if (name === "*") wildcard = q;
|
|
778
|
+
else listed.set(name, q);
|
|
779
|
+
}
|
|
780
|
+
let best;
|
|
781
|
+
let bestQ = 0;
|
|
782
|
+
for (const enc of supported) {
|
|
783
|
+
const q = listed.get(enc) ?? wildcard ?? 0;
|
|
784
|
+
if (q > bestQ) {
|
|
785
|
+
best = enc;
|
|
786
|
+
bestQ = q;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return best;
|
|
790
|
+
}
|
|
791
|
+
function parseQ(params) {
|
|
792
|
+
for (const param of params.split(";")) {
|
|
793
|
+
const eq = param.indexOf("=");
|
|
794
|
+
if (eq !== -1 && param.slice(0, eq).trim().toLowerCase() === "q") {
|
|
795
|
+
const q = Number(param.slice(eq + 1).trim());
|
|
796
|
+
return Number.isFinite(q) && q > 0 ? Math.min(q, 1) : 0;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return 1;
|
|
800
|
+
}
|
|
801
|
+
/** @internal Compresses a rendered body of `size` bytes with the given coding (async, libuv threadpool). */
|
|
802
|
+
function compressResponseBody(body, size, encoding, opts) {
|
|
803
|
+
return new Promise((resolve, reject) => {
|
|
804
|
+
const done = (error, result) => {
|
|
805
|
+
if (error) reject(error);
|
|
806
|
+
else resolve(result);
|
|
807
|
+
};
|
|
808
|
+
if (encoding === "br") brotliCompress(body, { params: {
|
|
809
|
+
[constants.BROTLI_PARAM_QUALITY]: opts.brotliQuality,
|
|
810
|
+
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_TEXT,
|
|
811
|
+
[constants.BROTLI_PARAM_SIZE_HINT]: size
|
|
812
|
+
} }, done);
|
|
813
|
+
else gzip(body, { level: opts.gzipLevel }, done);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
/** @internal Cache key for compressed bytes of a prerendered body (coding + level). */
|
|
817
|
+
function compressionCacheKey(encoding, opts) {
|
|
818
|
+
return encoding === "br" ? `br${opts.brotliQuality}` : `gzip${opts.gzipLevel}`;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* @internal Appends `token` to a `Vary` header value unless it (or `*`) is already listed.
|
|
822
|
+
* Returns `undefined` when nothing has to change.
|
|
823
|
+
*/
|
|
824
|
+
function mergeVary(existing, token) {
|
|
825
|
+
if (!existing || existing.length === 0) return token;
|
|
826
|
+
const value = Array.isArray(existing) ? existing.join(", ") : existing;
|
|
827
|
+
const lower = token.toLowerCase();
|
|
828
|
+
for (const part of value.split(",")) {
|
|
829
|
+
const name = part.trim().toLowerCase();
|
|
830
|
+
if (name === lower || name === "*") return;
|
|
831
|
+
}
|
|
832
|
+
return value.trim() ? `${value}, ${token}` : token;
|
|
833
|
+
}
|
|
834
|
+
|
|
721
835
|
//#endregion
|
|
722
836
|
//#region packages/event-http/src/utils/time.ts
|
|
723
837
|
function convertTime(time, unit = "ms") {
|
|
@@ -887,8 +1001,10 @@ var HttpResponse = class {
|
|
|
887
1001
|
* @param _req - The underlying Node.js `IncomingMessage`.
|
|
888
1002
|
* @param _logger - Logger instance for error reporting.
|
|
889
1003
|
* @param defaultHeaders - Optional headers to pre-populate on this response (e.g. from `securityHeaders()`).
|
|
1004
|
+
* @param _captureMode - Finalize state on `send()` without writing to `_res` (programmatic fetch).
|
|
1005
|
+
* @param compression - App-level response compression settings (`undefined` = off).
|
|
890
1006
|
*/
|
|
891
|
-
constructor(_res, _req, _logger, defaultHeaders, _captureMode = false) {
|
|
1007
|
+
constructor(_res, _req, _logger, defaultHeaders, _captureMode = false, compression) {
|
|
892
1008
|
this._res = _res;
|
|
893
1009
|
this._req = _req;
|
|
894
1010
|
this._logger = _logger;
|
|
@@ -898,6 +1014,7 @@ var HttpResponse = class {
|
|
|
898
1014
|
this._headers = {};
|
|
899
1015
|
this._hasCookies = false;
|
|
900
1016
|
this._responded = false;
|
|
1017
|
+
this._compression = compression;
|
|
901
1018
|
if (defaultHeaders) for (const key in defaultHeaders) this._headers[key] = defaultHeaders[key];
|
|
902
1019
|
}
|
|
903
1020
|
/** The HTTP status code. If not set, it is inferred automatically when `send()` is called. */
|
|
@@ -1030,6 +1147,24 @@ var HttpResponse = class {
|
|
|
1030
1147
|
return this;
|
|
1031
1148
|
}
|
|
1032
1149
|
/**
|
|
1150
|
+
* Overrides response compression for this response (chainable).
|
|
1151
|
+
*
|
|
1152
|
+
* - `false` — never compress this response (e.g. a body that mixes secrets with reflected input).
|
|
1153
|
+
* - `true` — compress with the app settings, or the defaults when the app has compression off.
|
|
1154
|
+
* - an options object — compress with these settings over the app settings (or the defaults).
|
|
1155
|
+
*
|
|
1156
|
+
* Only regular bodies (strings, numbers, booleans, objects, `Uint8Array`) are compressed; streams
|
|
1157
|
+
* and fetch `Response` bodies are sent as is.
|
|
1158
|
+
*/
|
|
1159
|
+
setCompression(value) {
|
|
1160
|
+
this._compression = value === true ? this._compression ?? resolveCompression(true) : resolveCompression(value, this._compression);
|
|
1161
|
+
return this;
|
|
1162
|
+
}
|
|
1163
|
+
/** Effective compression settings for this response, or `false` when compression is off. */
|
|
1164
|
+
get compression() {
|
|
1165
|
+
return this._compression ?? false;
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1033
1168
|
* Returns the underlying Node.js `ServerResponse`.
|
|
1034
1169
|
* @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.
|
|
1035
1170
|
*/
|
|
@@ -1067,15 +1202,13 @@ var HttpResponse = class {
|
|
|
1067
1202
|
});
|
|
1068
1203
|
}
|
|
1069
1204
|
const rendered = this.renderBody();
|
|
1205
|
+
const prerendered = this.takePrerendered();
|
|
1070
1206
|
this.autoStatus(!!rendered);
|
|
1071
|
-
if (this.applyPrerenderedEtag()) return new globalThis.Response(null, {
|
|
1207
|
+
if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) return new globalThis.Response(null, {
|
|
1072
1208
|
status: this._status,
|
|
1073
1209
|
headers: this._buildWebHeaders()
|
|
1074
1210
|
});
|
|
1075
|
-
if (rendered)
|
|
1076
|
-
const contentLength = typeof rendered === "string" ? Buffer.byteLength(rendered) : rendered.byteLength;
|
|
1077
|
-
this._headers["content-length"] = contentLength.toString();
|
|
1078
|
-
}
|
|
1211
|
+
if (rendered) this._headers["content-length"] = renderedSize(rendered, prerendered).toString();
|
|
1079
1212
|
const webBody = method === "HEAD" ? null : rendered instanceof Uint8Array ? rendered.buffer : rendered || null;
|
|
1080
1213
|
const webResponse = new globalThis.Response(webBody, {
|
|
1081
1214
|
status: this._status,
|
|
@@ -1122,7 +1255,7 @@ var HttpResponse = class {
|
|
|
1122
1255
|
if (!this._headers["content-type"]) this._headers["content-type"] = "application/json";
|
|
1123
1256
|
const prerendered = getPrerenderedJson(body);
|
|
1124
1257
|
if (prerendered) {
|
|
1125
|
-
this.
|
|
1258
|
+
this._prerendered = prerendered;
|
|
1126
1259
|
return prerendered.json;
|
|
1127
1260
|
}
|
|
1128
1261
|
return JSON.stringify(body);
|
|
@@ -1145,6 +1278,7 @@ var HttpResponse = class {
|
|
|
1145
1278
|
*
|
|
1146
1279
|
* Flushes all accumulated headers (including cookies) in a single `writeHead()` call,
|
|
1147
1280
|
* then writes the body. Supports `Readable` streams, `fetch` `Response` objects, and regular values.
|
|
1281
|
+
* Returns a Promise (which never rejects) for streamed bodies and compressed regular bodies.
|
|
1148
1282
|
*
|
|
1149
1283
|
* @throws Error if the response was already sent.
|
|
1150
1284
|
*/
|
|
@@ -1164,7 +1298,7 @@ var HttpResponse = class {
|
|
|
1164
1298
|
const method = this._req.method;
|
|
1165
1299
|
if (body instanceof Readable$1) return this.sendStream(body, method);
|
|
1166
1300
|
if (hasFetchResponse && body instanceof Response) return this.sendFetchResponse(body, method);
|
|
1167
|
-
this.sendRegular(method);
|
|
1301
|
+
return this.sendRegular(method);
|
|
1168
1302
|
}
|
|
1169
1303
|
finalizeCookies() {
|
|
1170
1304
|
if (!this._hasCookies) return;
|
|
@@ -1182,12 +1316,9 @@ var HttpResponse = class {
|
|
|
1182
1316
|
* matches into a bodiless `304`. Returns `true` when the response became `304`.
|
|
1183
1317
|
* Other methods get no ETag (a validator on e.g. a PUT response would describe the stored resource).
|
|
1184
1318
|
*/
|
|
1185
|
-
applyPrerenderedEtag() {
|
|
1186
|
-
const etag = this._prerenderedEtag;
|
|
1187
|
-
if (!etag) return false;
|
|
1188
|
-
this._prerenderedEtag = void 0;
|
|
1319
|
+
applyPrerenderedEtag(etag) {
|
|
1189
1320
|
const method = this._req.method;
|
|
1190
|
-
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.
|
|
1321
|
+
if (method !== "GET" && method !== "HEAD" || this._status < 200 || this._status > 299 || this.headerKeyIgnoreCase("etag") !== void 0) return false;
|
|
1191
1322
|
this._headers.etag = etag;
|
|
1192
1323
|
if (this._status !== EHttpStatusCode.OK || !ifNoneMatchHas(this._req.headers["if-none-match"], etag)) return false;
|
|
1193
1324
|
this._status = EHttpStatusCode.NotModified;
|
|
@@ -1197,9 +1328,16 @@ var HttpResponse = class {
|
|
|
1197
1328
|
}
|
|
1198
1329
|
return true;
|
|
1199
1330
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
return
|
|
1331
|
+
/** Returns the stored key of header `name` (lower-case) in whatever casing it was set with. */
|
|
1332
|
+
headerKeyIgnoreCase(name) {
|
|
1333
|
+
if (name in this._headers) return name;
|
|
1334
|
+
for (const key in this._headers) if (key.toLowerCase() === name) return key;
|
|
1335
|
+
}
|
|
1336
|
+
/** Returns and clears the prerender entry picked by the last `renderBody()`. */
|
|
1337
|
+
takePrerendered() {
|
|
1338
|
+
const prerendered = this._prerendered;
|
|
1339
|
+
this._prerendered = void 0;
|
|
1340
|
+
return prerendered;
|
|
1203
1341
|
}
|
|
1204
1342
|
autoStatus(hasBody) {
|
|
1205
1343
|
if (this._status) return;
|
|
@@ -1246,17 +1384,101 @@ var HttpResponse = class {
|
|
|
1246
1384
|
return this.pipeToResponse(Readable$1.fromWeb(fetchResponse.body), "Error streaming fetch response body");
|
|
1247
1385
|
}
|
|
1248
1386
|
sendRegular(method) {
|
|
1249
|
-
const
|
|
1250
|
-
this.
|
|
1251
|
-
|
|
1387
|
+
const body = this.renderBody();
|
|
1388
|
+
const prerendered = this.takePrerendered();
|
|
1389
|
+
const size = renderedSize(body, prerendered);
|
|
1390
|
+
this.autoStatus(!!body);
|
|
1391
|
+
const compression = this._compression && this.isCompressible(size, this._compression) ? this._compression : void 0;
|
|
1392
|
+
if (compression) this.appendVary("Accept-Encoding");
|
|
1393
|
+
if (prerendered?.etag && this.applyPrerenderedEtag(prerendered.etag)) {
|
|
1252
1394
|
this._res.writeHead(this._status, this._headers).end();
|
|
1253
1395
|
return;
|
|
1254
1396
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1397
|
+
if (compression && method !== "HEAD") {
|
|
1398
|
+
const encoding = negotiateEncoding(this._req.headers["accept-encoding"], compression.encodings);
|
|
1399
|
+
if (encoding) return this.sendCompressed(body, size, encoding, compression, prerendered);
|
|
1400
|
+
}
|
|
1401
|
+
this._headers["content-length"] = size.toString();
|
|
1402
|
+
this._res.writeHead(this._status, this._headers).end(method === "HEAD" ? "" : body);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Whether a rendered regular body of `size` bytes may be compressed — independent of the
|
|
1406
|
+
* request's `Accept-Encoding` (and of HEAD, which is answered uncompressed).
|
|
1407
|
+
*/
|
|
1408
|
+
isCompressible(size, opts) {
|
|
1409
|
+
const status = this._status;
|
|
1410
|
+
if (!size || size < opts.threshold || status < 200 || status === 204 || status === 206 || status === 304) return false;
|
|
1411
|
+
let contentType;
|
|
1412
|
+
for (const key in this._headers) {
|
|
1413
|
+
const lower = key.toLowerCase();
|
|
1414
|
+
if (lower === "content-type") contentType = this._headers[key];
|
|
1415
|
+
else if (lower === "content-encoding") return false;
|
|
1416
|
+
else if (lower === "cache-control" && /no-transform/i.test(String(this._headers[key]))) return false;
|
|
1417
|
+
}
|
|
1418
|
+
return typeof contentType === "string" && opts.filter(contentType, this);
|
|
1419
|
+
}
|
|
1420
|
+
/** Adds `token` to the `Vary` header (case-insensitive merge, keeps existing entries). */
|
|
1421
|
+
appendVary(token) {
|
|
1422
|
+
const key = this.headerKeyIgnoreCase("vary") ?? "vary";
|
|
1423
|
+
const merged = mergeVary(this._headers[key], token);
|
|
1424
|
+
if (merged !== void 0) this._headers[key] = merged;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Sends the body compressed with `encoding`. Prerendered bodies are compressed once per coding
|
|
1428
|
+
* and level and the bytes reused (sent synchronously once ready). A compressor failure is
|
|
1429
|
+
* logged and the body is sent uncompressed — headers are not written yet, so that is safe.
|
|
1430
|
+
* Never rejects: a sync handler's `send()` is not awaited.
|
|
1431
|
+
*/
|
|
1432
|
+
sendCompressed(body, size, encoding, opts, prerendered) {
|
|
1433
|
+
let pending;
|
|
1434
|
+
if (prerendered?.json === body) {
|
|
1435
|
+
const cache = prerendered.compressed ??= {};
|
|
1436
|
+
const cacheKey = compressionCacheKey(encoding, opts);
|
|
1437
|
+
let entry = cache[cacheKey];
|
|
1438
|
+
if (!entry) {
|
|
1439
|
+
const promise = compressResponseBody(body, size, encoding, opts);
|
|
1440
|
+
promise.then((bytes) => cache[cacheKey] = bytes, () => delete cache[cacheKey]);
|
|
1441
|
+
entry = cache[cacheKey] = promise;
|
|
1442
|
+
}
|
|
1443
|
+
pending = entry;
|
|
1444
|
+
} else pending = compressResponseBody(body, size, encoding, opts);
|
|
1445
|
+
if (Buffer.isBuffer(pending)) {
|
|
1446
|
+
this.writeDeferred(pending, pending.byteLength, encoding);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
return pending.then((bytes) => this.writeDeferred(bytes, bytes.byteLength, encoding), (error) => {
|
|
1450
|
+
this._logger.error("Response compression failed, sending uncompressed body", error);
|
|
1451
|
+
this.writeDeferred(body, size);
|
|
1452
|
+
}).catch((error) => {
|
|
1453
|
+
this._logger.error("Failed to send compressed response", error);
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Writes the body picked by `sendCompressed()` — the `encoding`-compressed bytes, or the identity
|
|
1458
|
+
* body after a compressor failure — unless the client went away meanwhile.
|
|
1459
|
+
*/
|
|
1460
|
+
writeDeferred(body, length, encoding) {
|
|
1461
|
+
if (this._res.destroyed) return;
|
|
1462
|
+
if (encoding) {
|
|
1463
|
+
this._headers["content-encoding"] = encoding;
|
|
1464
|
+
for (const key in this._headers) {
|
|
1465
|
+
const lower = key.toLowerCase();
|
|
1466
|
+
if (lower === "content-length" && key !== lower) delete this._headers[key];
|
|
1467
|
+
else if (lower === "etag") {
|
|
1468
|
+
const etag = this._headers[key];
|
|
1469
|
+
if (typeof etag === "string" && etag && !etag.startsWith("W/")) this._headers[key] = `W/${etag}`;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
this._headers["content-length"] = length.toString();
|
|
1474
|
+
this._res.writeHead(this._status, this._headers).end(body);
|
|
1258
1475
|
}
|
|
1259
1476
|
};
|
|
1477
|
+
/** UTF-8 size of a rendered body — computed once per registration for a prerendered body. */
|
|
1478
|
+
function renderedSize(body, prerendered) {
|
|
1479
|
+
if (typeof body !== "string") return body.byteLength;
|
|
1480
|
+
return prerendered?.json === body ? prerendered.size ??= Buffer.byteLength(body) : Buffer.byteLength(body);
|
|
1481
|
+
}
|
|
1260
1482
|
/** Converts a Record of headers to a Web Standard `Headers` object. */
|
|
1261
1483
|
function recordToWebHeaders(record) {
|
|
1262
1484
|
const headers = new Headers();
|
|
@@ -1630,7 +1852,7 @@ function escapeHtml(value) {
|
|
|
1630
1852
|
//#endregion
|
|
1631
1853
|
//#region packages/event-http/src/response/wooks-http-response.ts
|
|
1632
1854
|
let framework = {
|
|
1633
|
-
version: "0.7.
|
|
1855
|
+
version: "0.7.28",
|
|
1634
1856
|
poweredBy: "wooksjs",
|
|
1635
1857
|
link: "https://wooks.moost.org/",
|
|
1636
1858
|
image: "https://wooks.moost.org/wooks-full-logo.svg"
|
|
@@ -1726,6 +1948,7 @@ var WooksHttp = class extends WooksAdapterBase {
|
|
|
1726
1948
|
this.logger = opts?.logger || this.getLogger(`[96m[wooks-http]`);
|
|
1727
1949
|
this.ResponseClass = opts?.responseClass ?? WooksHttpResponse;
|
|
1728
1950
|
this.eventContextOptions = this.getEventContextOptions();
|
|
1951
|
+
this.compression = resolveCompression(opts?.compression);
|
|
1729
1952
|
}
|
|
1730
1953
|
/** Registers a handler for all HTTP methods on the given path. */
|
|
1731
1954
|
all(path, handler) {
|
|
@@ -1848,8 +2071,9 @@ var WooksHttp = class extends WooksAdapterBase {
|
|
|
1848
2071
|
const RequestLimits = this.opts?.requestLimits;
|
|
1849
2072
|
const notFoundHandler = this.opts?.onNotFound;
|
|
1850
2073
|
const defaultHeaders = this.opts?.defaultHeaders;
|
|
2074
|
+
const compression = this.compression;
|
|
1851
2075
|
return (req, res) => {
|
|
1852
|
-
const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders);
|
|
2076
|
+
const response = new this.ResponseClass(res, req, ctxOptions.logger, defaultHeaders, false, compression);
|
|
1853
2077
|
const method = req.method || "";
|
|
1854
2078
|
const url = req.url || "";
|
|
1855
2079
|
createHttpContext(ctxOptions, {
|
|
@@ -2236,4 +2460,4 @@ function prepareTestHttpContext(options) {
|
|
|
2236
2460
|
}
|
|
2237
2461
|
|
|
2238
2462
|
//#endregion
|
|
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 };
|
|
2463
|
+
export { DEFAULT_FORWARD_HEADERS, DEFAULT_LIMITS, EHttpStatusCode, HttpError, HttpResponse, WooksHttp, WooksHttpResponse, WooksURLSearchParams, createHttpApp, createHttpContext, httpKind, httpStatusCodes, isCompressibleType, negotiateEncoding, 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.28",
|
|
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.28",
|
|
46
|
+
"wooks": "^0.7.28"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@prostojs/logger": "^0.4.3",
|
|
50
50
|
"@prostojs/router": "^0.3.5",
|
|
51
|
-
"
|
|
52
|
-
"
|
|
51
|
+
"@wooksjs/event-core": "^0.7.28",
|
|
52
|
+
"wooks": "^0.7.28"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"build": "rolldown -c ../../rolldown.config.mjs"
|