fulmine.js 5.21.2 → 5.21.4
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/README.md +3 -3
- package/package.json +5 -1
- package/src/adopt.js +4 -1
- package/src/application.js +11 -2
- package/src/cli.js +1 -0
- package/src/compression.js +40 -3
- package/src/declarative.js +66 -25
- package/src/lazy-readable.js +15 -15
- package/src/lazy-writable.js +15 -15
- package/src/middlewares.js +24 -11
- package/src/nest.js +2 -0
- package/src/optimizer.js +65 -7
- package/src/options.d.ts +1 -1
- package/src/request.js +22 -16
- package/src/response-utils.js +41 -4
- package/src/response.js +102 -62
- package/src/route.js +5 -3
- package/src/router-utils.js +40 -5
- package/src/router.js +23 -9
- package/src/server-shape.js +3 -3
- package/src/server-timing.js +21 -0
- package/src/testing.js +2 -1
- package/src/usage.js +40 -3
- package/src/utils.js +4 -1
- package/src/verify.js +2 -0
- package/src/view.js +1 -0
- package/src/websocket.js +2 -1
- package/src/work.js +1 -0
package/src/response.js
CHANGED
|
@@ -49,39 +49,6 @@ const { isAbsolute } = require("path");
|
|
|
49
49
|
const fs = require("fs");
|
|
50
50
|
const Path = require("path");
|
|
51
51
|
const statuses = require("statuses");
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The TypeError node throws for a chunk that is not a string, a Buffer or a Uint8Array, worded as
|
|
55
|
-
* node words it.
|
|
56
|
-
*
|
|
57
|
-
* @param {unknown} chunk what end() was handed, known here not to be a string or a Uint8Array
|
|
58
|
-
* @returns {NodeJS.ErrnoException}
|
|
59
|
-
*/
|
|
60
|
-
function invalidChunkError(chunk) {
|
|
61
|
-
let received;
|
|
62
|
-
if (chunk === null) {
|
|
63
|
-
received = "null";
|
|
64
|
-
} else if (typeof chunk === "object" || typeof chunk === "function") {
|
|
65
|
-
const name = /** @type {any} */ (chunk).constructor?.name;
|
|
66
|
-
received = name ? `an instance of ${name}` : "an instance of Object";
|
|
67
|
-
} else if (typeof chunk === "bigint") {
|
|
68
|
-
received = `type bigint (${chunk}n)`;
|
|
69
|
-
} else if (typeof chunk === "symbol") {
|
|
70
|
-
received = `type symbol (${String(chunk)})`;
|
|
71
|
-
} else {
|
|
72
|
-
received = `type ${typeof chunk} (${chunk})`;
|
|
73
|
-
}
|
|
74
|
-
/** @type {NodeJS.ErrnoException} */
|
|
75
|
-
const err = new TypeError(
|
|
76
|
-
`The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received ${received}`
|
|
77
|
-
);
|
|
78
|
-
err.code = "ERR_INVALID_ARG_TYPE";
|
|
79
|
-
// as node: the bracketed name goes into the stack's first line, then err.name reads TypeError
|
|
80
|
-
err.name = "TypeError [ERR_INVALID_ARG_TYPE]";
|
|
81
|
-
void err.stack;
|
|
82
|
-
delete (/** @type {Record<string, unknown>} */ (/** @type {unknown} */ (err)).name);
|
|
83
|
-
return err;
|
|
84
|
-
}
|
|
85
52
|
const { sign } = require("cookie-signature");
|
|
86
53
|
const ms = require("ms");
|
|
87
54
|
const Socket = require("./socket.js");
|
|
@@ -92,7 +59,8 @@ const {
|
|
|
92
59
|
VALIDATED_HEADER_NAMES,
|
|
93
60
|
HEADER_NAME_BUF,
|
|
94
61
|
HEADER_VALUE_BUF,
|
|
95
|
-
statusLine
|
|
62
|
+
statusLine,
|
|
63
|
+
invalidChunkError
|
|
96
64
|
} = require("./response-utils.js");
|
|
97
65
|
|
|
98
66
|
// How much a chunked response may gather before it goes to uWS: 33 KB in 500 pieces measured
|
|
@@ -115,6 +83,9 @@ const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
|
|
|
115
83
|
// what send takes as a range request, checked on the header's text before parsing
|
|
116
84
|
const BYTES_RANGE = /^ *bytes=/;
|
|
117
85
|
|
|
86
|
+
// node's test for a Transfer-Encoding that means chunked framing
|
|
87
|
+
const CHUNKED_VALUE = /(?:^|\W)chunked(?:$|\W)/i;
|
|
88
|
+
|
|
118
89
|
module.exports = class Response extends LazyWritable {
|
|
119
90
|
/** @type {Socket|null} */
|
|
120
91
|
#socket = null;
|
|
@@ -125,6 +96,15 @@ module.exports = class Response extends LazyWritable {
|
|
|
125
96
|
/** @type {((err?: Error|null) => void)|null} */
|
|
126
97
|
#pendingCallback = null;
|
|
127
98
|
|
|
99
|
+
/**
|
|
100
|
+
* An end() that arrived while uWS still held a chunk, run once that chunk is through. Not
|
|
101
|
+
* waited for on 'drain': node emits it only after a write() that said false, and a piece
|
|
102
|
+
* under the high-water mark never says it, so a stream ending on a small piece a slow client
|
|
103
|
+
* had not taken yet was never closed (compression-file under load, 2026-09-21).
|
|
104
|
+
* @type {(() => void)|null}
|
|
105
|
+
*/
|
|
106
|
+
#deferredEnd = null;
|
|
107
|
+
|
|
128
108
|
/**
|
|
129
109
|
* Chunks written but not yet handed to uWS: a write costs uWS everything buffered behind it, so
|
|
130
110
|
* many small pieces cost quadratically, 500 writes of 66 bytes measured 13ms against 0.4ms in
|
|
@@ -142,6 +122,13 @@ module.exports = class Response extends LazyWritable {
|
|
|
142
122
|
/** Whether the status line and the headers have reached uWS, which only a body write does. */
|
|
143
123
|
#headOut = false;
|
|
144
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Whether the application set Transfer-Encoding: chunked itself. The body then goes out
|
|
127
|
+
* through uWS's write(), which frames it and writes the header, and no Content-Length is
|
|
128
|
+
* added beside it, as express 5.3 and node do. See writeHeaders and _finish.
|
|
129
|
+
*/
|
|
130
|
+
#userChunked = false;
|
|
131
|
+
|
|
145
132
|
/**
|
|
146
133
|
* The status as writeHead settled it, the one the wire gets: a status set later never reaches
|
|
147
134
|
* the client, as in node.
|
|
@@ -323,6 +310,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
323
310
|
* A finished or aborted response only tears the stream down, touching an aborted uWS response
|
|
324
311
|
* is a use after free. writableEnded reads true after this, node keeps it false until end().
|
|
325
312
|
*
|
|
313
|
+
* @override
|
|
326
314
|
* @param {Error} [error] whatever the caller is destroying the response with
|
|
327
315
|
* @returns {this}
|
|
328
316
|
*/
|
|
@@ -372,8 +360,9 @@ module.exports = class Response extends LazyWritable {
|
|
|
372
360
|
get [kOutHeaders]() {
|
|
373
361
|
if (!this.#outHeaders) {
|
|
374
362
|
this.#outHeaders = new Proxy(this.headers, {
|
|
363
|
+
// node keys the slot by lowercased name, and stores [name, value]
|
|
375
364
|
set: (obj, prop, value) => {
|
|
376
|
-
this.set(prop, value[1]);
|
|
365
|
+
this.set(/** @type {string} */ (prop), value[1]);
|
|
377
366
|
return true;
|
|
378
367
|
},
|
|
379
368
|
get: (obj, prop) => {
|
|
@@ -384,6 +373,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
384
373
|
return this.#outHeaders;
|
|
385
374
|
}
|
|
386
375
|
|
|
376
|
+
/** @param {Response["headers"]|null} value */
|
|
387
377
|
set [kOutHeaders](value) {
|
|
388
378
|
this.#outHeaders = value;
|
|
389
379
|
}
|
|
@@ -446,6 +436,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
446
436
|
this.#pendingCallback = null;
|
|
447
437
|
this.writingChunk = false;
|
|
448
438
|
if (cb) cb(null);
|
|
439
|
+
this.#afterPending();
|
|
449
440
|
return true;
|
|
450
441
|
});
|
|
451
442
|
} else {
|
|
@@ -454,6 +445,18 @@ module.exports = class Response extends LazyWritable {
|
|
|
454
445
|
}
|
|
455
446
|
}
|
|
456
447
|
|
|
448
|
+
/**
|
|
449
|
+
* After a held chunk went through and the stream's callback ran: the callback may have pushed
|
|
450
|
+
* the next buffered piece into uWS's hands, so the waiting end() runs only when nothing is.
|
|
451
|
+
*/
|
|
452
|
+
#afterPending() {
|
|
453
|
+
if (this.#deferredEnd !== null && !this.writingChunk) {
|
|
454
|
+
const end = this.#deferredEnd;
|
|
455
|
+
this.#deferredEnd = null;
|
|
456
|
+
end();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
457
460
|
/**
|
|
458
461
|
* The booked flush. Static, so a response that never writes in pieces allocates no closure.
|
|
459
462
|
*
|
|
@@ -469,6 +472,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
469
472
|
* Writable's sink: the head if not out yet, then the chunk to uWS through the queue for a
|
|
470
473
|
* chunked response or tryEnd with a Content-Length. Backpressure defers the callback.
|
|
471
474
|
*
|
|
475
|
+
* @override
|
|
472
476
|
* @param {any} chunk whatever a Writable was handed
|
|
473
477
|
* @param {BufferEncoding} encoding
|
|
474
478
|
* @param {(err?: Error|null) => void} callback
|
|
@@ -478,11 +482,13 @@ module.exports = class Response extends LazyWritable {
|
|
|
478
482
|
/** @type {NodeJS.ErrnoException} */
|
|
479
483
|
const err = new Error("Request aborted");
|
|
480
484
|
err.code = "ECONNABORTED";
|
|
481
|
-
|
|
485
|
+
this.destroy(err);
|
|
486
|
+
return;
|
|
482
487
|
}
|
|
483
488
|
if (this.finished) {
|
|
484
489
|
const err = new Error("Response already finished");
|
|
485
|
-
|
|
490
|
+
this.destroy(err);
|
|
491
|
+
return;
|
|
486
492
|
}
|
|
487
493
|
|
|
488
494
|
this.writingChunk = true;
|
|
@@ -544,6 +550,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
544
550
|
this.writingChunk = false;
|
|
545
551
|
handlerUsed = true;
|
|
546
552
|
callback(null);
|
|
553
|
+
this.#afterPending();
|
|
547
554
|
}
|
|
548
555
|
return ok;
|
|
549
556
|
});
|
|
@@ -619,6 +626,17 @@ module.exports = class Response extends LazyWritable {
|
|
|
619
626
|
this.totalSize = parseInt(value);
|
|
620
627
|
continue;
|
|
621
628
|
}
|
|
629
|
+
if (
|
|
630
|
+
header === "transfer-encoding" &&
|
|
631
|
+
typeof value === "string" &&
|
|
632
|
+
res._nodeRes === undefined &&
|
|
633
|
+
CHUNKED_VALUE.test(value)
|
|
634
|
+
) {
|
|
635
|
+
// not written: uWS writes its own on the write() path, two came out before. Node's
|
|
636
|
+
// own response, behind the shim, frames by the header itself
|
|
637
|
+
this.#userChunked = true;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
622
640
|
// the recurring names and values cross as cached Buffers, see HEADER_NAME_BUF
|
|
623
641
|
const name = HEADER_NAME_BUF[header] || header;
|
|
624
642
|
if (Array.isArray(value)) {
|
|
@@ -676,6 +694,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
676
694
|
}
|
|
677
695
|
|
|
678
696
|
/**
|
|
697
|
+
* @override
|
|
679
698
|
* @param {string|Buffer|Uint8Array|null|(() => void)} [data] the last body piece, or the callback in
|
|
680
699
|
* node's one-argument shape
|
|
681
700
|
* @param {BufferEncoding|(() => void)} [encoding] how a string body is encoded, or the callback in
|
|
@@ -705,13 +724,11 @@ module.exports = class Response extends LazyWritable {
|
|
|
705
724
|
}
|
|
706
725
|
// uWS takes a string as utf-8 only: res.end(data, "binary") is how old code sends an image
|
|
707
726
|
if (typeof data === "string" && encoding !== undefined && encoding !== "utf8" && encoding !== "utf-8") {
|
|
708
|
-
data = Buffer.from(data, encoding);
|
|
727
|
+
data = Buffer.from(data, /** @type {BufferEncoding} */ (encoding));
|
|
709
728
|
}
|
|
710
729
|
|
|
711
730
|
if (this.writingChunk) {
|
|
712
|
-
this
|
|
713
|
-
this.end(data, cb);
|
|
714
|
-
});
|
|
731
|
+
this.#deferredEnd = () => this.end(data, cb);
|
|
715
732
|
return this;
|
|
716
733
|
}
|
|
717
734
|
if (this.finished) {
|
|
@@ -722,10 +739,12 @@ module.exports = class Response extends LazyWritable {
|
|
|
722
739
|
this.writeHead(this.statusCode);
|
|
723
740
|
}
|
|
724
741
|
// uWS corks itself for the synchronous window of its route handler, see _corkNeeded
|
|
742
|
+
// by now a function was moved to cb, which the editor's config cannot see
|
|
743
|
+
const chunk = /** @type {string|Uint8Array|null|undefined} */ (data);
|
|
725
744
|
if (this._corkNeeded) {
|
|
726
|
-
this._res.cork(() => this._finish(
|
|
745
|
+
this._res.cork(() => this._finish(chunk, cb));
|
|
727
746
|
} else {
|
|
728
|
-
this._finish(
|
|
747
|
+
this._finish(chunk, cb);
|
|
729
748
|
}
|
|
730
749
|
return this;
|
|
731
750
|
}
|
|
@@ -759,9 +778,10 @@ module.exports = class Response extends LazyWritable {
|
|
|
759
778
|
this._res.endWithoutBody();
|
|
760
779
|
} else if (!data && contentLength) {
|
|
761
780
|
this._res.endWithoutBody(contentLength.toString(), closeConnection);
|
|
762
|
-
} else if (headWasAlreadyOut && this.chunkedTransfer) {
|
|
781
|
+
} else if ((headWasAlreadyOut && this.chunkedTransfer) || (this.#userChunked && this._hasBody && data)) {
|
|
763
782
|
// the queue first, then the last piece as a chunk: the head already went out without a
|
|
764
|
-
// length, and uWS's end() would append one
|
|
783
|
+
// length, or the application asked for chunked framing, and uWS's end() would append one.
|
|
784
|
+
// An empty body under that header takes end() below: uWS frames nothing without a write()
|
|
765
785
|
this.#flushQueued(null);
|
|
766
786
|
if (data) {
|
|
767
787
|
this._res.write(data);
|
|
@@ -770,9 +790,15 @@ module.exports = class Response extends LazyWritable {
|
|
|
770
790
|
this._res.endWithoutBody();
|
|
771
791
|
} else {
|
|
772
792
|
if (!this._hasBody) {
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
793
|
+
if (this.#userChunked) {
|
|
794
|
+
// a HEAD under the application's chunked framing carries no length, as node.
|
|
795
|
+
// No arguments: given a close flag alone, uWS writes a 2^63 length
|
|
796
|
+
this._res.endWithoutBody();
|
|
797
|
+
} else {
|
|
798
|
+
const length = Buffer.byteLength(data ?? "");
|
|
799
|
+
this.headers["content-length"] = String(length);
|
|
800
|
+
this._res.endWithoutBody(length, closeConnection);
|
|
801
|
+
}
|
|
776
802
|
} else {
|
|
777
803
|
this._sentBody = data ?? "";
|
|
778
804
|
// null is the empty body: uWS never ends a response given end(null)
|
|
@@ -795,7 +821,8 @@ module.exports = class Response extends LazyWritable {
|
|
|
795
821
|
* Sends the body, with a Content-Type when none was set and an ETag when the setting asks.
|
|
796
822
|
* A number is a value to serialise, never a status: that is `sendStatus()`.
|
|
797
823
|
*
|
|
798
|
-
* @param {string|number|boolean|object|
|
|
824
|
+
* @param {string|number|boolean|object|null} [body] a Buffer or a Uint8Array is sent as bytes,
|
|
825
|
+
* any other object is serialised
|
|
799
826
|
* @returns {this}
|
|
800
827
|
*/
|
|
801
828
|
send(body) {
|
|
@@ -812,6 +839,12 @@ module.exports = class Response extends LazyWritable {
|
|
|
812
839
|
delete this.headers["content-length"];
|
|
813
840
|
delete this.headers["transfer-encoding"];
|
|
814
841
|
}
|
|
842
|
+
} else if (this.req.fresh || this.statusCode === 204 || this.statusCode === 304) {
|
|
843
|
+
// express strips the content headers through removeHeader, which node refuses
|
|
844
|
+
// once the head is out; a fresh request is made a 304 first and lands here too
|
|
845
|
+
throw headersSentError("remove");
|
|
846
|
+
} else if (this.statusCode === 205) {
|
|
847
|
+
throw headersSentError("set");
|
|
815
848
|
}
|
|
816
849
|
return this.end("");
|
|
817
850
|
}
|
|
@@ -838,11 +871,12 @@ module.exports = class Response extends LazyWritable {
|
|
|
838
871
|
} else if (!isBuffer && typeof body !== "string") {
|
|
839
872
|
// a symbol, a bigint or a function: what node's byteLength or from() throws is the
|
|
840
873
|
// answer. A string never gets here, measuring it twice cost 157us per thousand requests
|
|
874
|
+
const unsendable = /** @type {any} */ (body);
|
|
841
875
|
const generateETag = !this.headers["etag"] && typeof this.app._hot().etagFn === "function";
|
|
842
|
-
if (!generateETag &&
|
|
843
|
-
Buffer.byteLength(
|
|
876
|
+
if (!generateETag && unsendable.length < 1000) {
|
|
877
|
+
Buffer.byteLength(unsendable, "utf8");
|
|
844
878
|
}
|
|
845
|
-
Buffer.from(
|
|
879
|
+
Buffer.from(unsendable, "utf8");
|
|
846
880
|
}
|
|
847
881
|
if (typeof body === "string" && !isBuffer) {
|
|
848
882
|
const contentType = this.headers["content-type"];
|
|
@@ -870,7 +904,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
870
904
|
!this.req.noEtag &&
|
|
871
905
|
(hot.etagMethods === null || hot.etagMethods.has(this.req.method))
|
|
872
906
|
) {
|
|
873
|
-
const etag = etagFn(body);
|
|
907
|
+
const etag = etagFn(/** @type {string|Buffer} */ (body));
|
|
874
908
|
// an application's own etag function may decline
|
|
875
909
|
if (etag) {
|
|
876
910
|
this.headers["etag"] = etag;
|
|
@@ -892,14 +926,15 @@ module.exports = class Response extends LazyWritable {
|
|
|
892
926
|
body = "";
|
|
893
927
|
}
|
|
894
928
|
// by req.method as express's send does, so a GET a middleware made a HEAD answers its
|
|
895
|
-
// length and no body; end() alone decides by the wire, see _hasBody
|
|
929
|
+
// length and no body; end() alone decides by the wire, see _hasBody. No length beside a
|
|
930
|
+
// Transfer-Encoding the application set, as express 5.3
|
|
896
931
|
if (this.req.method === "HEAD") {
|
|
897
|
-
if (this.statusCode !== 204 && this.statusCode !== 304) {
|
|
898
|
-
this.headers["content-length"] = String(Buffer.byteLength(body));
|
|
932
|
+
if (this.statusCode !== 204 && this.statusCode !== 304 && !this.headers["transfer-encoding"]) {
|
|
933
|
+
this.headers["content-length"] = String(Buffer.byteLength(/** @type {string|Buffer} */ (body)));
|
|
899
934
|
}
|
|
900
935
|
return this.end();
|
|
901
936
|
}
|
|
902
|
-
return this.end(body);
|
|
937
|
+
return this.end(/** @type {string|Buffer} */ (body));
|
|
903
938
|
}
|
|
904
939
|
|
|
905
940
|
/**
|
|
@@ -1166,6 +1201,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
1166
1201
|
});
|
|
1167
1202
|
} else {
|
|
1168
1203
|
// large files and ranges are piped
|
|
1204
|
+
/** @type {{highWaterMark: number, start?: number, end?: number}} */
|
|
1169
1205
|
const opts = {
|
|
1170
1206
|
highWaterMark: HIGH_WATERMARK
|
|
1171
1207
|
};
|
|
@@ -1229,6 +1265,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
1229
1265
|
}
|
|
1230
1266
|
|
|
1231
1267
|
// a header option of sendFile, as Express does: it only goes out once the stat succeeded
|
|
1268
|
+
/** @type {Record<string, string>} */
|
|
1232
1269
|
const headers = {
|
|
1233
1270
|
"Content-Disposition": contentDisposition(name || path)
|
|
1234
1271
|
};
|
|
@@ -1521,8 +1558,9 @@ module.exports = class Response extends LazyWritable {
|
|
|
1521
1558
|
*/
|
|
1522
1559
|
set(field, value) {
|
|
1523
1560
|
if (typeof field === "object") {
|
|
1524
|
-
|
|
1525
|
-
|
|
1561
|
+
const fields = /** @type {Record<string, string|string[]>} */ (field);
|
|
1562
|
+
for (const header in fields) {
|
|
1563
|
+
this.set(header, fields[header]);
|
|
1526
1564
|
}
|
|
1527
1565
|
} else {
|
|
1528
1566
|
const name = field.toLowerCase();
|
|
@@ -1864,8 +1902,10 @@ module.exports = class Response extends LazyWritable {
|
|
|
1864
1902
|
url = status;
|
|
1865
1903
|
status = 302;
|
|
1866
1904
|
}
|
|
1905
|
+
// read from the closures below too, where the checker does not see the shuffle above
|
|
1906
|
+
const code = /** @type {number} */ (status);
|
|
1867
1907
|
this.location(/** @type {string} */ (url));
|
|
1868
|
-
this.status(
|
|
1908
|
+
this.status(code);
|
|
1869
1909
|
|
|
1870
1910
|
const address = /** @type {string} */ (this.get("Location"));
|
|
1871
1911
|
let body;
|
|
@@ -1888,11 +1928,11 @@ module.exports = class Response extends LazyWritable {
|
|
|
1888
1928
|
this.format({
|
|
1889
1929
|
text: () => {
|
|
1890
1930
|
this.set("Content-Type", "text/plain; charset=utf-8");
|
|
1891
|
-
body = `${statuses.message[
|
|
1931
|
+
body = `${statuses.message[code]}. Redirecting to ${address}`;
|
|
1892
1932
|
},
|
|
1893
1933
|
html: () => {
|
|
1894
1934
|
this.set("Content-Type", "text/html; charset=utf-8");
|
|
1895
|
-
body = `<p>${statuses.message[
|
|
1935
|
+
body = `<p>${statuses.message[code]}. Redirecting to ${escapeHtml(address)}</p>`;
|
|
1896
1936
|
},
|
|
1897
1937
|
default: () => {
|
|
1898
1938
|
// no Content-Type, as Express leaves it when the client accepts neither
|
package/src/route.js
CHANGED
|
@@ -166,12 +166,14 @@ function checkRouteHandlers(method, handlers) {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// every verb, plus all(), registered the same way: flattened, checked, then pushed with the verb
|
|
169
|
-
// they answer. all() pushes handlers with no verb at all, which matches every request
|
|
169
|
+
// they answer. all() pushes handlers with no verb at all, which matches every request. Loose:
|
|
170
|
+
// the members are written here by name and not declared on the class
|
|
171
|
+
const prototype = /** @type {any} */ (Route.prototype);
|
|
170
172
|
for (const method of ["all", ...METHODS.map((verb) => verb.toLowerCase())]) {
|
|
171
|
-
if (method !== "all" && typeof
|
|
173
|
+
if (method !== "all" && typeof prototype[method] === "function") {
|
|
172
174
|
continue;
|
|
173
175
|
}
|
|
174
|
-
|
|
176
|
+
prototype[method] = function (/** @type {unknown[]} */ ...handlers) {
|
|
175
177
|
const flattened = handlers.flat(Infinity);
|
|
176
178
|
checkRouteHandlers(method, flattened);
|
|
177
179
|
// the check above threw on anything else
|
package/src/router-utils.js
CHANGED
|
@@ -53,7 +53,7 @@ const { METHODS } = require("http");
|
|
|
53
53
|
*/
|
|
54
54
|
/**
|
|
55
55
|
* An earlier registration a mount is guarded by, see guardsInside.
|
|
56
|
-
* @typedef {{path: string, use: boolean, method: string, all: boolean}} MountGuard
|
|
56
|
+
* @typedef {{path: string, use: boolean, method: string, all: boolean, params: boolean}} MountGuard
|
|
57
57
|
*/
|
|
58
58
|
/**
|
|
59
59
|
* A layer as express shapes it, for app.stack and router.stack.
|
|
@@ -118,8 +118,19 @@ function anyGuardHits(guards, path) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
// every method the declarative compiler can emit: a patched one disables compilation
|
|
121
|
+
/** @type {Record<string, string>} */
|
|
121
122
|
const resCodes = {},
|
|
122
|
-
resDecMethods =
|
|
123
|
+
resDecMethods = /** @type {const} */ ([
|
|
124
|
+
"set",
|
|
125
|
+
"setHeader",
|
|
126
|
+
"header",
|
|
127
|
+
"send",
|
|
128
|
+
"end",
|
|
129
|
+
"append",
|
|
130
|
+
"status",
|
|
131
|
+
"json",
|
|
132
|
+
"sendStatus"
|
|
133
|
+
]);
|
|
123
134
|
for (const method of resDecMethods) {
|
|
124
135
|
resCodes[method] = Response.prototype[method].toString();
|
|
125
136
|
}
|
|
@@ -143,7 +154,7 @@ function layerFor(route, callback) {
|
|
|
143
154
|
: callback.name || "<anonymous>",
|
|
144
155
|
params: undefined,
|
|
145
156
|
path: undefined,
|
|
146
|
-
keys: [],
|
|
157
|
+
keys: /** @type {never[]} */ ([]),
|
|
147
158
|
route: undefined
|
|
148
159
|
};
|
|
149
160
|
route._layers.set(callback, layer);
|
|
@@ -685,6 +696,22 @@ function shadowsLeaf(guard, leafPath, leaf) {
|
|
|
685
696
|
return pathsCanOverlap(guard.path.toLowerCase(), leafPath.toLowerCase(), guard.use);
|
|
686
697
|
}
|
|
687
698
|
|
|
699
|
+
/**
|
|
700
|
+
* Whether a HEAD of this leaf's path could enter a route of another verb written before the mount,
|
|
701
|
+
* for its param() callbacks: Express exempts HEAD from the method check. The GET leaf keeps its
|
|
702
|
+
* registration, only its HEAD twin goes to the generic walk. See headEnters in optimizer.js.
|
|
703
|
+
*
|
|
704
|
+
* @param {MountGuard} guard
|
|
705
|
+
* @param {string} leafPath the leaf's absolute path, parameters and all
|
|
706
|
+
* @returns {boolean}
|
|
707
|
+
*/
|
|
708
|
+
function headEntersGuard(guard, leafPath) {
|
|
709
|
+
if (guard.all || guard.use || !guard.params || guard.method === "GET" || guard.method === "HEAD") {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
return pathsCanOverlap(guard.path.toLowerCase(), leafPath.toLowerCase(), false);
|
|
713
|
+
}
|
|
714
|
+
|
|
688
715
|
/**
|
|
689
716
|
* The layers before a mount that answer some of what is inside it and not all of it, which neither
|
|
690
717
|
* the chain nor uWS's specificity can say. Carried down the walk and asked about every leaf, see
|
|
@@ -714,7 +741,14 @@ function guardsInside(router, mount, pathPrefix, chain, inherited) {
|
|
|
714
741
|
if (guards === inherited) {
|
|
715
742
|
guards = [...inherited];
|
|
716
743
|
}
|
|
717
|
-
guards.push({
|
|
744
|
+
guards.push({
|
|
745
|
+
path: pathPrefix + r.path,
|
|
746
|
+
use: r.use === true,
|
|
747
|
+
method: r.method,
|
|
748
|
+
all: r.all === true,
|
|
749
|
+
// whether its router has param() callbacks, which a HEAD runs on any matching verb
|
|
750
|
+
params: r.paramCallbacks.size > 0
|
|
751
|
+
});
|
|
718
752
|
}
|
|
719
753
|
return guards;
|
|
720
754
|
}
|
|
@@ -798,7 +832,7 @@ function callablePrototypeFor(classPrototype) {
|
|
|
798
832
|
return prototype;
|
|
799
833
|
}
|
|
800
834
|
prototype = Object.create(classPrototype);
|
|
801
|
-
for (const name of ["apply", "call", "toString"]) {
|
|
835
|
+
for (const name of /** @type {const} */ (["apply", "call", "toString"])) {
|
|
802
836
|
Object.defineProperty(prototype, name, {
|
|
803
837
|
value: Function.prototype[name],
|
|
804
838
|
writable: true,
|
|
@@ -870,6 +904,7 @@ module.exports = {
|
|
|
870
904
|
stepsOver,
|
|
871
905
|
couldAnswer,
|
|
872
906
|
shadowsLeaf,
|
|
907
|
+
headEntersGuard,
|
|
873
908
|
guardsInside,
|
|
874
909
|
rememberApp,
|
|
875
910
|
restoreApp,
|
package/src/router.js
CHANGED
|
@@ -74,6 +74,11 @@ const {
|
|
|
74
74
|
/** @typedef {import("./router-utils.js").WsRoute} WsRoute */
|
|
75
75
|
/** @typedef {import("uWebSockets.js").HttpRequest} UwsRequest */
|
|
76
76
|
/** @typedef {import("uWebSockets.js").HttpResponse} UwsResponse */
|
|
77
|
+
/**
|
|
78
|
+
* One app.param() call as _runParamCallbacks records it: the value its callbacks were given, the
|
|
79
|
+
* one they left in req.params, and what they ended with.
|
|
80
|
+
* @typedef {{error: unknown, match: unknown, value: unknown}} ParamCall
|
|
81
|
+
*/
|
|
77
82
|
|
|
78
83
|
// one number per app.route(), so the routes it creates know they belong together
|
|
79
84
|
let routeGroups = 0;
|
|
@@ -238,6 +243,11 @@ module.exports = class Router extends EventEmitter {
|
|
|
238
243
|
*/
|
|
239
244
|
_asCallable() {
|
|
240
245
|
const fn = /** @type {any} */ (
|
|
246
|
+
/**
|
|
247
|
+
* @param {any} req loose as handle() is
|
|
248
|
+
* @param {any} res
|
|
249
|
+
* @param {(err?: unknown) => void} [next]
|
|
250
|
+
*/
|
|
241
251
|
function (req, res, next) {
|
|
242
252
|
return fn.handle(req, res, next);
|
|
243
253
|
}
|
|
@@ -668,6 +678,8 @@ module.exports = class Router extends EventEmitter {
|
|
|
668
678
|
// a RegExp mount matches a piece of path known only per request
|
|
669
679
|
regexMount: method === "USE" && path instanceof RegExp,
|
|
670
680
|
userRegexp: path instanceof RegExp,
|
|
681
|
+
// a compiled pattern that cannot be compared as text, decided below
|
|
682
|
+
complex: false,
|
|
671
683
|
methods: methodMap,
|
|
672
684
|
stack,
|
|
673
685
|
// the route as a request sees it, a view with the written path when normalised
|
|
@@ -1052,6 +1064,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1052
1064
|
let name = "";
|
|
1053
1065
|
/** @type {unknown} */
|
|
1054
1066
|
let value;
|
|
1067
|
+
/** @type {ParamCall} */
|
|
1055
1068
|
let entry;
|
|
1056
1069
|
/** @type {Function[]} */
|
|
1057
1070
|
let fns = [];
|
|
@@ -1074,10 +1087,10 @@ module.exports = class Router extends EventEmitter {
|
|
|
1074
1087
|
}
|
|
1075
1088
|
name = names[index++];
|
|
1076
1089
|
value = req.params[name];
|
|
1077
|
-
|
|
1078
|
-
if (
|
|
1079
|
-
req.params[name] =
|
|
1080
|
-
return nextParam(
|
|
1090
|
+
const earlier = called.get(name);
|
|
1091
|
+
if (earlier && (earlier.match === value || (earlier.error && earlier.error !== "route"))) {
|
|
1092
|
+
req.params[name] = earlier.value;
|
|
1093
|
+
return nextParam(earlier.error);
|
|
1081
1094
|
}
|
|
1082
1095
|
entry = { error: null, match: value, value };
|
|
1083
1096
|
called.set(name, entry);
|
|
@@ -1298,7 +1311,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1298
1311
|
* @param {Record<string, any>} [carried] the headers the error asked for, written last
|
|
1299
1312
|
*/
|
|
1300
1313
|
_sendErrorPage(request, response, err, checkEnv = false, carried = undefined) {
|
|
1301
|
-
|
|
1314
|
+
const page = this._generateErrorPage(err, response.statusCode, checkEnv);
|
|
1302
1315
|
request.noEtag = true;
|
|
1303
1316
|
// a header that cannot be written may be what brought the request here
|
|
1304
1317
|
response._dropUnwritableHeaders();
|
|
@@ -1312,7 +1325,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1312
1325
|
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
1313
1326
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
1314
1327
|
response.setHeader("Content-Security-Policy", "default-src 'none'");
|
|
1315
|
-
response.send(
|
|
1328
|
+
response.send(page);
|
|
1316
1329
|
}
|
|
1317
1330
|
|
|
1318
1331
|
/**
|
|
@@ -1368,10 +1381,11 @@ module.exports = class Router extends EventEmitter {
|
|
|
1368
1381
|
useRouterClass(module.exports);
|
|
1369
1382
|
|
|
1370
1383
|
// the verb methods on the prototype: as own arrows they closed over the instance they were built
|
|
1371
|
-
// on, and express.Router().post(...) answered with the object the callable was copied from
|
|
1372
|
-
|
|
1384
|
+
// on, and express.Router().post(...) answered with the object the callable was copied from.
|
|
1385
|
+
// Loose: the members are written here by name and not declared on the class
|
|
1386
|
+
const prototype = /** @type {any} */ (module.exports.prototype);
|
|
1373
1387
|
for (const method of methods) {
|
|
1374
|
-
|
|
1388
|
+
prototype[method] = function (
|
|
1375
1389
|
/** @type {string|RegExp|(string|RegExp)[]} */ path,
|
|
1376
1390
|
/** @type {unknown[]} */ ...callbacks
|
|
1377
1391
|
) {
|
package/src/server-shape.js
CHANGED
|
@@ -32,7 +32,7 @@ const kIsApplication = Symbol.for("fulmine.application");
|
|
|
32
32
|
* Teaches `instanceof` that an application is a server, once per class. The original answer is
|
|
33
33
|
* asked first and never overruled.
|
|
34
34
|
*
|
|
35
|
-
* @param {Function} klass http.Server or net.Server
|
|
35
|
+
* @param {Function & {[kIsApplication]?: boolean}} klass http.Server or net.Server
|
|
36
36
|
*/
|
|
37
37
|
function acceptApplications(klass) {
|
|
38
38
|
const previous = klass[Symbol.hasInstance];
|
|
@@ -48,7 +48,7 @@ function acceptApplications(klass) {
|
|
|
48
48
|
}
|
|
49
49
|
// an application is a function and a property read works on one. The guard is for the
|
|
50
50
|
// primitives and nulls that reach any instanceof
|
|
51
|
-
return value != null && value[kIsApplication] === true;
|
|
51
|
+
return value != null && /** @type {{[kIsApplication]?: unknown}} */ (value)[kIsApplication] === true;
|
|
52
52
|
},
|
|
53
53
|
configurable: true,
|
|
54
54
|
writable: true
|
|
@@ -104,7 +104,7 @@ function addServerMembers(prototype) {
|
|
|
104
104
|
* Registers the callback like node's does and remembers the value, which is all a caller can
|
|
105
105
|
* observe. The timeout belongs to uWS and is set through uwsOptions.idleTimeout.
|
|
106
106
|
*
|
|
107
|
-
* @this {Application & {timeout?: number}}
|
|
107
|
+
* @this {Application & {timeout?: number|undefined}}
|
|
108
108
|
* @param {number} [msecs]
|
|
109
109
|
* @param {() => void} [callback]
|
|
110
110
|
* @returns {Application}
|
package/src/server-timing.js
CHANGED
|
@@ -25,6 +25,12 @@ const { work, names } = require("./work.js");
|
|
|
25
25
|
const { applyWriteHead } = require("./utils.js");
|
|
26
26
|
|
|
27
27
|
/** @typedef {import("./response.js")} Response */
|
|
28
|
+
/**
|
|
29
|
+
* What the hooks below take: writeHead's arguments in node's two shapes, see applyWriteHead, and
|
|
30
|
+
* the callback write() and end() may carry in place of the encoding.
|
|
31
|
+
* @typedef {Parameters<Response["writeHead"]>} WriteHeadArgs
|
|
32
|
+
* @typedef {(err?: Error|null) => void} WriteCallback
|
|
33
|
+
*/
|
|
28
34
|
|
|
29
35
|
/**
|
|
30
36
|
* A duration in milliseconds, as Server-Timing writes them: two decimals.
|
|
@@ -167,15 +173,30 @@ function serverTiming(options) {
|
|
|
167
173
|
const _writeHead = res.writeHead;
|
|
168
174
|
// writeHead settles the head, so a handler that calls it is stamped there, with the headers
|
|
169
175
|
// it carries applied first, as on-headers orders it
|
|
176
|
+
/**
|
|
177
|
+
* @param {WriteHeadArgs[0]} statusCode
|
|
178
|
+
* @param {WriteHeadArgs[1]} [statusMessage]
|
|
179
|
+
* @param {WriteHeadArgs[2]} [headers]
|
|
180
|
+
*/
|
|
170
181
|
res.writeHead = function writeHead(statusCode, statusMessage, headers) {
|
|
171
182
|
const reason = applyWriteHead(this, statusMessage, headers);
|
|
172
183
|
stamp();
|
|
173
184
|
return _writeHead.call(this, statusCode, reason);
|
|
174
185
|
};
|
|
186
|
+
/**
|
|
187
|
+
* @param {any} chunk
|
|
188
|
+
* @param {BufferEncoding|WriteCallback} [encoding]
|
|
189
|
+
* @param {WriteCallback} [callback]
|
|
190
|
+
*/
|
|
175
191
|
res.write = function write(chunk, encoding, callback) {
|
|
176
192
|
stamp();
|
|
177
193
|
return _write.call(this, chunk, encoding, callback);
|
|
178
194
|
};
|
|
195
|
+
/**
|
|
196
|
+
* @param {any} [chunk]
|
|
197
|
+
* @param {BufferEncoding|WriteCallback} [encoding]
|
|
198
|
+
* @param {WriteCallback} [callback]
|
|
199
|
+
*/
|
|
179
200
|
res.end = function end(chunk, encoding, callback) {
|
|
180
201
|
stamp();
|
|
181
202
|
return _end.call(this, chunk, encoding, callback);
|
package/src/testing.js
CHANGED
|
@@ -242,7 +242,8 @@ function expectLazy(req, res, options) {
|
|
|
242
242
|
const done = work(req, res);
|
|
243
243
|
const unwanted = { ...done };
|
|
244
244
|
for (const field of allowed) {
|
|
245
|
-
|
|
245
|
+
// checked against LAZY above
|
|
246
|
+
unwanted[/** @type {keyof import("./work.js").Work} */ (field)] = false;
|
|
246
247
|
}
|
|
247
248
|
const listed = workNames(unwanted);
|
|
248
249
|
if (listed.length === 0) {
|