fulmine.js 5.5.1 → 5.6.0
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/COMPRESSION_LICENSE +25 -0
- package/NOTICE +9 -0
- package/README.md +29 -18
- package/package.json +4 -2
- package/src/application.js +6 -21
- package/src/cli.js +57 -15
- package/src/compression.js +400 -0
- package/src/index.js +4 -0
- package/src/middlewares.js +82 -1
- package/src/options.d.ts +6 -0
- package/src/request.js +0 -4
- package/src/response.js +30 -36
- package/src/router.js +26 -3
- package/src/types.d.ts +19 -0
- package/src/utils.js +116 -0
package/src/response.js
CHANGED
|
@@ -228,9 +228,10 @@ module.exports = class Response extends LazyWritable {
|
|
|
228
228
|
* measured on uWS alone, 500 writes of 66 bytes take 13ms against 0.06ms for 100 of them.
|
|
229
229
|
* Handing it the same bytes in blocks costs 0.4ms. Nothing here changes what goes on the wire,
|
|
230
230
|
* only how many calls it takes to put it there.
|
|
231
|
-
*
|
|
231
|
+
* Null until the first chunked write, since a res.send never queues anything.
|
|
232
|
+
* @type {Buffer[]|null}
|
|
232
233
|
*/
|
|
233
|
-
#queued =
|
|
234
|
+
#queued = null;
|
|
234
235
|
|
|
235
236
|
/** How many bytes {@link #queued} holds, kept alongside so the flush does not add them up. */
|
|
236
237
|
#queuedBytes = 0;
|
|
@@ -437,12 +438,12 @@ module.exports = class Response extends LazyWritable {
|
|
|
437
438
|
* @param {((err?: Error|null) => void)|null} callback the stream's, when there is one waiting
|
|
438
439
|
*/
|
|
439
440
|
#flushQueued(callback) {
|
|
440
|
-
if (this.#queuedBytes === 0) {
|
|
441
|
+
if (this.#queued === null || this.#queuedBytes === 0) {
|
|
441
442
|
if (callback) callback(null);
|
|
442
443
|
return;
|
|
443
444
|
}
|
|
444
445
|
const body = this.#queued.length === 1 ? this.#queued[0] : Buffer.concat(this.#queued, this.#queuedBytes);
|
|
445
|
-
this.#queued =
|
|
446
|
+
this.#queued = null;
|
|
446
447
|
this.#queuedBytes = 0;
|
|
447
448
|
|
|
448
449
|
const ok = this._res.write(body);
|
|
@@ -467,13 +468,16 @@ module.exports = class Response extends LazyWritable {
|
|
|
467
468
|
}
|
|
468
469
|
|
|
469
470
|
/**
|
|
470
|
-
* The booked flush.
|
|
471
|
+
* The booked flush. Static, so a response that never writes in pieces allocates nothing for it:
|
|
472
|
+
* nextTick forwards the receiver as an argument.
|
|
473
|
+
*
|
|
474
|
+
* @param {any} res
|
|
471
475
|
*/
|
|
472
|
-
#flushOnTick
|
|
473
|
-
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
}
|
|
476
|
+
static #flushOnTick(res) {
|
|
477
|
+
res.#flushBooked = false;
|
|
478
|
+
if (res.aborted || res.finished || res.#queuedBytes === 0) return;
|
|
479
|
+
res._res.cork(() => res.#flushQueued(null));
|
|
480
|
+
}
|
|
477
481
|
|
|
478
482
|
/**
|
|
479
483
|
* Writable's sink. Sends the headers if they have not gone yet, then hands the chunk to uWS,
|
|
@@ -520,7 +524,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
520
524
|
// turn or once it is big enough to be worth a call. A stream that writes once per
|
|
521
525
|
// turn, an SSE feed for instance, still leaves on its own turn: the queue only ever
|
|
522
526
|
// gathers what was written without yielding in between.
|
|
523
|
-
this.#queued.push(/** @type {Buffer} */ (chunk));
|
|
527
|
+
(this.#queued ??= []).push(/** @type {Buffer} */ (chunk));
|
|
524
528
|
this.#queuedBytes += /** @type {Buffer} */ (chunk).byteLength;
|
|
525
529
|
// a chunk that is already big enough to be worth its own call leaves with whatever
|
|
526
530
|
// was waiting in front of it, rather than paying for a copy it does not need
|
|
@@ -529,7 +533,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
529
533
|
} else {
|
|
530
534
|
if (!this.#flushBooked) {
|
|
531
535
|
this.#flushBooked = true;
|
|
532
|
-
process.nextTick(
|
|
536
|
+
process.nextTick(Response.#flushOnTick, this);
|
|
533
537
|
}
|
|
534
538
|
this.writingChunk = false;
|
|
535
539
|
callback(null);
|
|
@@ -786,7 +790,10 @@ module.exports = class Response extends LazyWritable {
|
|
|
786
790
|
// remembered rather than measured: only a caller that asks for content-length pays
|
|
787
791
|
// for it, and uWS is measuring the same bytes for the wire anyway
|
|
788
792
|
this._sentBody = data ?? "";
|
|
789
|
-
|
|
793
|
+
// and null is sent as the empty body it means. uWS answers end(null) with a
|
|
794
|
+
// response the client never sees the end of, where node and express send an empty
|
|
795
|
+
// 200: res.end(null) is what the compression module's own test suite does
|
|
796
|
+
this._res.end(data ?? "");
|
|
790
797
|
}
|
|
791
798
|
}
|
|
792
799
|
|
|
@@ -1329,11 +1336,16 @@ module.exports = class Response extends LazyWritable {
|
|
|
1329
1336
|
}
|
|
1330
1337
|
|
|
1331
1338
|
/**
|
|
1332
|
-
*
|
|
1339
|
+
* Hands the status line and the headers over now, without waiting for a body, which is node's
|
|
1333
1340
|
* flushHeaders(). Callers use it to let the client start on the head while the body is still
|
|
1334
1341
|
* being produced, and one of them is `@angular/ssr`'s writeResponseToNodeResponse, which calls
|
|
1335
1342
|
* it before streaming a rendered page.
|
|
1336
1343
|
*
|
|
1344
|
+
* **The head does not reach the wire here.** uWS holds it until the first body chunk, so a
|
|
1345
|
+
* client sees nothing until then, where express answers at once. `beginWrite` is uWS's API for
|
|
1346
|
+
* this and is unusable: it emits a stray CRLF before the first chunk size, which node's parser
|
|
1347
|
+
* rejects as HPE_INVALID_CHUNK_SIZE. Checked against v20.69.0, the latest release.
|
|
1348
|
+
*
|
|
1337
1349
|
* A second call does nothing, as node's does. Nothing is written for a response already
|
|
1338
1350
|
* finished or aborted: uWS has let go of it by then.
|
|
1339
1351
|
*
|
|
@@ -1425,10 +1437,6 @@ module.exports = class Response extends LazyWritable {
|
|
|
1425
1437
|
* @param {Record<string, string>|[string, string][]} [headers]
|
|
1426
1438
|
* @returns {void}
|
|
1427
1439
|
*/
|
|
1428
|
-
|
|
1429
|
-
/**
|
|
1430
|
-
*
|
|
1431
|
-
*/
|
|
1432
1440
|
addTrailers(headers) {}
|
|
1433
1441
|
|
|
1434
1442
|
/**
|
|
@@ -1440,10 +1448,6 @@ module.exports = class Response extends LazyWritable {
|
|
|
1440
1448
|
* @param {() => void} [callback]
|
|
1441
1449
|
* @returns {this}
|
|
1442
1450
|
*/
|
|
1443
|
-
|
|
1444
|
-
/**
|
|
1445
|
-
*
|
|
1446
|
-
*/
|
|
1447
1451
|
setTimeout(msecs, callback) {
|
|
1448
1452
|
if (typeof callback === "function") {
|
|
1449
1453
|
this.once("timeout", callback);
|
|
@@ -1458,20 +1462,12 @@ module.exports = class Response extends LazyWritable {
|
|
|
1458
1462
|
* @param {any} [socket]
|
|
1459
1463
|
* @returns {void}
|
|
1460
1464
|
*/
|
|
1461
|
-
|
|
1462
|
-
/**
|
|
1463
|
-
*
|
|
1464
|
-
*/
|
|
1465
1465
|
assignSocket(socket) {}
|
|
1466
1466
|
|
|
1467
1467
|
/**
|
|
1468
1468
|
* @param {any} [socket]
|
|
1469
1469
|
* @returns {void}
|
|
1470
1470
|
*/
|
|
1471
|
-
|
|
1472
|
-
/**
|
|
1473
|
-
*
|
|
1474
|
-
*/
|
|
1475
1471
|
detachSocket(socket) {}
|
|
1476
1472
|
|
|
1477
1473
|
/**
|
|
@@ -2005,12 +2001,6 @@ module.exports = class Response extends LazyWritable {
|
|
|
2005
2001
|
return this.set("content-type", ct);
|
|
2006
2002
|
}
|
|
2007
2003
|
|
|
2008
|
-
/**
|
|
2009
|
-
* express carries both names for the same method, and middleware reaches for either.
|
|
2010
|
-
* @type {(type: string) => any}
|
|
2011
|
-
*/
|
|
2012
|
-
contentType = this.type;
|
|
2013
|
-
|
|
2014
2004
|
/**
|
|
2015
2005
|
* Adds a field to Vary, without repeating one already there.
|
|
2016
2006
|
* @param {string|string[]} field
|
|
@@ -2040,3 +2030,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
2040
2030
|
return this.finished;
|
|
2041
2031
|
}
|
|
2042
2032
|
};
|
|
2033
|
+
|
|
2034
|
+
// res.contentType is res.type under express's other name. On the prototype rather than an instance
|
|
2035
|
+
// field, which wrote one own property per response in the constructor.
|
|
2036
|
+
/** @type {any} */ (module.exports.prototype).contentType = module.exports.prototype.type;
|
package/src/router.js
CHANGED
|
@@ -446,7 +446,7 @@ class Walk {
|
|
|
446
446
|
return this.step(undefined);
|
|
447
447
|
}
|
|
448
448
|
if (kind === CALLBACK_ROUTER) {
|
|
449
|
-
if (callback.
|
|
449
|
+
if (callback._isApplication) {
|
|
450
450
|
rememberApp(this, route, req);
|
|
451
451
|
useApp(req, callback);
|
|
452
452
|
}
|
|
@@ -616,10 +616,15 @@ function nativeFail(err) {
|
|
|
616
616
|
* @returns {number}
|
|
617
617
|
*/
|
|
618
618
|
function mountPrefixLength(route, req) {
|
|
619
|
-
|
|
619
|
+
// a use with no path is EMPTY_REGEX, which matches "" at 0 whatever the path is. Answered
|
|
620
|
+
// without the exec, since this runs per hop and most middleware is pathless
|
|
621
|
+
if (route.pattern === EMPTY_REGEX) {
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
620
624
|
if (typeof route.pattern === "string") {
|
|
621
625
|
return route.pattern.length;
|
|
622
626
|
}
|
|
627
|
+
const path = req._opPath;
|
|
623
628
|
const matched = route.pattern.exec(path === "" ? "/" : path);
|
|
624
629
|
return matched ? Math.min(matched[0].length, path.length) : 0;
|
|
625
630
|
}
|
|
@@ -1057,7 +1062,7 @@ function guardsInside(router, mount, pathPrefix, chain, inherited) {
|
|
|
1057
1062
|
* @param {any} req
|
|
1058
1063
|
*/
|
|
1059
1064
|
function rememberApp(walk, route, req) {
|
|
1060
|
-
if (walk.router._isApplication && route.callbacks[0]?.
|
|
1065
|
+
if (walk.router._isApplication && route.callbacks[0]?._isApplication) {
|
|
1061
1066
|
(req._appStack ??= []).push(route, req.app);
|
|
1062
1067
|
}
|
|
1063
1068
|
}
|
|
@@ -2536,6 +2541,24 @@ module.exports = class Router extends EventEmitter {
|
|
|
2536
2541
|
});
|
|
2537
2542
|
}
|
|
2538
2543
|
|
|
2544
|
+
/**
|
|
2545
|
+
* The same walk without the promise pair, for a uWS handler that never awaited it. nativeDone
|
|
2546
|
+
* and nativeFail defer their epilogues to the microtask the await used to resume on, so the
|
|
2547
|
+
* visible order holds.
|
|
2548
|
+
*
|
|
2549
|
+
* @param {any} req
|
|
2550
|
+
* @param {any} res
|
|
2551
|
+
*/
|
|
2552
|
+
_routeRequestDirect(req, res) {
|
|
2553
|
+
const walk = new Walk(this, req, res, this._routes, false, undefined, nativeDone, nativeFail);
|
|
2554
|
+
try {
|
|
2555
|
+
walk.dispatch(0);
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
// what a throw inside a promise executor did: reject, once
|
|
2558
|
+
nativeFail.call(walk, err);
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2539
2562
|
/**
|
|
2540
2563
|
* Mounts middleware, or a whole router, at a path. The path is optional, and a mount matches
|
|
2541
2564
|
* everything under it, which is what separates it from all(). Mounting a Router sets its
|
package/src/types.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ limitations under the License.
|
|
|
17
17
|
declare module "fulmine.js" {
|
|
18
18
|
import e from "express";
|
|
19
19
|
import uWS from "uWebSockets.js";
|
|
20
|
+
import { ZlibOptions, BrotliOptions } from "zlib";
|
|
20
21
|
|
|
21
22
|
type Settings = {
|
|
22
23
|
uwsOptions?: uWS.AppOptions;
|
|
@@ -37,6 +38,24 @@ declare module "fulmine.js" {
|
|
|
37
38
|
export import static = e.static;
|
|
38
39
|
// export import query = e.query;
|
|
39
40
|
|
|
41
|
+
// express has no compression middleware, so there is nothing to re-export: these are the
|
|
42
|
+
// compression module's options, which this one takes as they are
|
|
43
|
+
interface CompressionOptions extends ZlibOptions {
|
|
44
|
+
/** The smallest body worth compressing, in bytes or as "1kb". Default 1024. */
|
|
45
|
+
threshold?: number | string;
|
|
46
|
+
/** Whether this response should be compressed at all. */
|
|
47
|
+
filter?: (req: e.Request, res: e.Response) => boolean;
|
|
48
|
+
/** What to use when the request carries no Accept-Encoding. Default "identity". */
|
|
49
|
+
enforceEncoding?: string;
|
|
50
|
+
/** Brotli options. The default quality is 4. */
|
|
51
|
+
brotli?: BrotliOptions;
|
|
52
|
+
}
|
|
53
|
+
export function compression(options?: CompressionOptions): e.RequestHandler;
|
|
54
|
+
export namespace compression {
|
|
55
|
+
/** The default filter: any compressible content type. */
|
|
56
|
+
function filter(req: e.Request, res: e.Response): boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
export import urlencoded = e.urlencoded;
|
|
41
60
|
|
|
42
61
|
export import RouterOptions = e.RouterOptions;
|
package/src/utils.js
CHANGED
|
@@ -788,6 +788,113 @@ function stringify(value, replacer, spaces, escape) {
|
|
|
788
788
|
return json;
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
+
// What negotiateEncoding may answer with, since a caller can only offer what it can produce
|
|
792
|
+
const ENCODING_BR = 1;
|
|
793
|
+
const ENCODING_GZIP = 2;
|
|
794
|
+
const ENCODING_DEFLATE = 4;
|
|
795
|
+
const ENCODING_ANY = ENCODING_BR | ENCODING_GZIP | ENCODING_DEFLATE;
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The encoding to answer with, read straight off Accept-Encoding rather than through negotiator:
|
|
799
|
+
* the header is a short list of names with an optional q, and building a Negotiator per response
|
|
800
|
+
* to read it costs more than the scan does.
|
|
801
|
+
*
|
|
802
|
+
* The tie-break is negotiator's, for the list the compression module hands it: brotli first, then
|
|
803
|
+
* gzip, then deflate, and identity last.
|
|
804
|
+
*
|
|
805
|
+
* Only the encodings named in `allowed` are on offer, since the caller may not be able to
|
|
806
|
+
* produce all three: express.static offers the two it can have lying on disk. An uncompressed
|
|
807
|
+
* answer is always on offer, and is what an empty header ends up choosing.
|
|
808
|
+
*
|
|
809
|
+
* @param {string} accept the header, or "" when the request carried none
|
|
810
|
+
* @param {number} allowed ENCODING_BR, ENCODING_GZIP and ENCODING_DEFLATE, or'd together
|
|
811
|
+
* @returns {string} "br", "gzip", "deflate", "identity", or "" when nothing is acceptable
|
|
812
|
+
*/
|
|
813
|
+
function negotiateEncoding(accept, allowed) {
|
|
814
|
+
// -1 while a name has not appeared: q=0 is a refusal and has to be told apart from silence
|
|
815
|
+
let br = -1;
|
|
816
|
+
let gzip = -1;
|
|
817
|
+
let deflate = -1;
|
|
818
|
+
let identity = -1;
|
|
819
|
+
let star = -1;
|
|
820
|
+
// the lowest q anything was named with, which is what an unnamed identity is worth, see below
|
|
821
|
+
let minQuality = 1;
|
|
822
|
+
let index = 0;
|
|
823
|
+
while (index < accept.length) {
|
|
824
|
+
let end = accept.indexOf(",", index);
|
|
825
|
+
if (end === -1) {
|
|
826
|
+
end = accept.length;
|
|
827
|
+
}
|
|
828
|
+
let semi = accept.indexOf(";", index);
|
|
829
|
+
if (semi === -1 || semi > end) {
|
|
830
|
+
semi = end;
|
|
831
|
+
}
|
|
832
|
+
const name = accept.slice(index, semi).trim().toLowerCase();
|
|
833
|
+
let q = 1;
|
|
834
|
+
if (semi < end) {
|
|
835
|
+
const params = accept.slice(semi + 1, end);
|
|
836
|
+
const at = params.indexOf("q=");
|
|
837
|
+
if (at !== -1) {
|
|
838
|
+
const parsed = parseFloat(params.slice(at + 2));
|
|
839
|
+
// a q nobody can read is a refusal, which is how negotiator reads it too
|
|
840
|
+
q = parsed === parsed ? parsed : 0;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (q < minQuality) {
|
|
844
|
+
minQuality = q;
|
|
845
|
+
}
|
|
846
|
+
switch (name) {
|
|
847
|
+
case "br":
|
|
848
|
+
br = q;
|
|
849
|
+
break;
|
|
850
|
+
case "gzip":
|
|
851
|
+
gzip = q;
|
|
852
|
+
break;
|
|
853
|
+
case "deflate":
|
|
854
|
+
deflate = q;
|
|
855
|
+
break;
|
|
856
|
+
case "identity":
|
|
857
|
+
identity = q;
|
|
858
|
+
break;
|
|
859
|
+
case "*":
|
|
860
|
+
star = q;
|
|
861
|
+
break;
|
|
862
|
+
}
|
|
863
|
+
index = end + 1;
|
|
864
|
+
}
|
|
865
|
+
if (br < 0) br = star;
|
|
866
|
+
if (gzip < 0) gzip = star;
|
|
867
|
+
if (deflate < 0) deflate = star;
|
|
868
|
+
// An uncompressed answer that the request did not name is worth the lowest q it named
|
|
869
|
+
// anything with, which is negotiator's rule and not the obvious one: "br;q=0.5, gzip;q=0.9"
|
|
870
|
+
// means gzip, because identity comes in at 0.5 rather than at 1 and does not win the list.
|
|
871
|
+
// A "*" names identity as much as it names anything else, so its q is identity's.
|
|
872
|
+
if (identity < 0) identity = star < 0 ? minQuality : star;
|
|
873
|
+
|
|
874
|
+
if (!(allowed & ENCODING_BR)) br = -1;
|
|
875
|
+
if (!(allowed & ENCODING_GZIP)) gzip = -1;
|
|
876
|
+
if (!(allowed & ENCODING_DEFLATE)) deflate = -1;
|
|
877
|
+
|
|
878
|
+
let best = "";
|
|
879
|
+
let bestQ = 0;
|
|
880
|
+
if (br > bestQ) {
|
|
881
|
+
best = "br";
|
|
882
|
+
bestQ = br;
|
|
883
|
+
}
|
|
884
|
+
if (gzip > bestQ) {
|
|
885
|
+
best = "gzip";
|
|
886
|
+
bestQ = gzip;
|
|
887
|
+
}
|
|
888
|
+
if (deflate > bestQ) {
|
|
889
|
+
best = "deflate";
|
|
890
|
+
bestQ = deflate;
|
|
891
|
+
}
|
|
892
|
+
if (identity > bestQ) {
|
|
893
|
+
best = "identity";
|
|
894
|
+
}
|
|
895
|
+
return best;
|
|
896
|
+
}
|
|
897
|
+
|
|
791
898
|
const defaultSettings = {
|
|
792
899
|
"jsonp callback name": "callback",
|
|
793
900
|
env: () => process.env.NODE_ENV ?? "development",
|
|
@@ -1102,6 +1209,11 @@ function createETagGenerator(options) {
|
|
|
1102
1209
|
if (body instanceof Stats) {
|
|
1103
1210
|
return statTag(body, options.weak);
|
|
1104
1211
|
}
|
|
1212
|
+
// crypto.hash reads a string as the utf8 bytes Buffer.from would have produced, so the tag
|
|
1213
|
+
// is the same one without copying the whole body first
|
|
1214
|
+
if (typeof body === "string" && (encoding === undefined || encoding === "utf8" || encoding === "utf-8")) {
|
|
1215
|
+
return entityTag(body, options.weak);
|
|
1216
|
+
}
|
|
1105
1217
|
const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body;
|
|
1106
1218
|
return entityTag(buf, options.weak);
|
|
1107
1219
|
};
|
|
@@ -1300,6 +1412,10 @@ module.exports = {
|
|
|
1300
1412
|
entityTag,
|
|
1301
1413
|
statTag,
|
|
1302
1414
|
contentTypeFor,
|
|
1415
|
+
negotiateEncoding,
|
|
1416
|
+
ENCODING_BR,
|
|
1417
|
+
ENCODING_GZIP,
|
|
1418
|
+
ENCODING_ANY,
|
|
1303
1419
|
memoizeByString,
|
|
1304
1420
|
isRangeFresh,
|
|
1305
1421
|
findIndexStartingFrom,
|