fulmine.js 5.21.3 → 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/src/request.js CHANGED
@@ -43,6 +43,7 @@ const {
43
43
 
44
44
  // whose headers #collectHeader is filling: uWS's forEach is synchronous, so one callback serves
45
45
  // every request
46
+ /** @type {import("./request.js")|null} */
46
47
  let currentRequest = null;
47
48
 
48
49
  module.exports = class Request extends LazyReadable {
@@ -83,6 +84,12 @@ module.exports = class Request extends LazyReadable {
83
84
  /** @type {string[]|null} */
84
85
  #cachedSubdomains = null;
85
86
 
87
+ /** The socket stand-in `req.connection` once built, see the getter. @type {import("./socket.js")|null} */
88
+ #cachedConnection = null;
89
+
90
+ /** Behind `req.signal`, made on the first read. @type {AbortController|undefined} */
91
+ #abortController;
92
+
86
93
  /**
87
94
  * Copies one header out of uWS and notices what the constructor decides by. One function for
88
95
  * every request through currentRequest, an arrow per request cost a closure.
@@ -91,7 +98,8 @@ module.exports = class Request extends LazyReadable {
91
98
  * @param {string} value
92
99
  */
93
100
  static #collectHeader = (headerKey, value) => {
94
- const r = currentRequest;
101
+ // set by the two forEach callers, never null while uWS is calling back
102
+ const r = /** @type {Request} */ (currentRequest);
95
103
  r.#rawHeadersEntries.push(headerKey, value);
96
104
  // the response, built right after, must not answer keep-alive to a client that said close
97
105
  if (headerKey.length === 10 && headerKey === "connection" && saysClose(value)) {
@@ -321,7 +329,10 @@ module.exports = class Request extends LazyReadable {
321
329
  /** The verbs a path answers, for an OPTIONS; null on every other method. @type {Set<string>|null} */
322
330
  _matchedMethods = null;
323
331
 
324
- /** What each app.param() callback was called with, per router, made on first use. @type {Map<any, Map<any, any>>|null} */
332
+ /**
333
+ * What each app.param() callback was called with, per router, made on first use.
334
+ * @type {Map<Map<string, Function[]>, Map<string, import("./router.js").ParamCall>>|null}
335
+ */
325
336
  _paramCalled = null;
326
337
 
327
338
  /**
@@ -573,9 +584,6 @@ module.exports = class Request extends LazyReadable {
573
584
  return this.res?.finished || this.res?.aborted;
574
585
  }
575
586
 
576
- /** @type {AbortController|undefined} */
577
- #abortController;
578
-
579
587
  /**
580
588
  * node's `req.signal`, fired when the request is over: `@angular/ssr` reads it to give up a
581
589
  * render. Made on the first ask.
@@ -630,7 +638,10 @@ module.exports = class Request extends LazyReadable {
630
638
  return this;
631
639
  }
632
640
 
633
- /** Readable's pull: uWS pushes, so this only lifts the backpressure a full queue put on it. */
641
+ /**
642
+ * Readable's pull: uWS pushes, so this only lifts the backpressure a full queue put on it.
643
+ * @override
644
+ */
634
645
  _read() {
635
646
  // first, so a bodyless stream still ends for a consumer arriving after the response
636
647
  if (this.#emptyBody) {
@@ -1011,9 +1022,6 @@ module.exports = class Request extends LazyReadable {
1011
1022
  return ip;
1012
1023
  }
1013
1024
 
1014
- /** @type {import("./socket.js")|null} */
1015
- #cachedConnection = null;
1016
-
1017
1025
  /**
1018
1026
  * The socket stand-in, the same object as `res.socket`, kept here so it still answers once
1019
1027
  * the response is over and `res.socket` is null.
@@ -1214,8 +1222,8 @@ module.exports = class Request extends LazyReadable {
1214
1222
  *
1215
1223
  * @param {number} size length of the resource being served
1216
1224
  * @param {{combine?: boolean}} [options] combine adjacent and overlapping ranges
1217
- * @returns {Array|number|undefined} the ranges, -1 when unsatisfiable, -2 when malformed,
1218
- * or undefined when there is no Range header
1225
+ * @returns {import("range-parser").Ranges|-1|-2|undefined} the ranges, -1 when unsatisfiable,
1226
+ * -2 when malformed, or undefined when there is no Range header
1219
1227
  */
1220
1228
  range(size, options) {
1221
1229
  const range = this.headers["range"];
@@ -1302,10 +1310,8 @@ module.exports = class Request extends LazyReadable {
1302
1310
  return this.#rawHeadersEntries.slice();
1303
1311
  }
1304
1312
 
1305
- // the three below report work this request was made to do, for src/work.js
1306
-
1307
1313
  /**
1308
- * Whether the folded `req.headers` object was built.
1314
+ * Whether the folded `req.headers` object was built, which src/work.js reports.
1309
1315
  * @returns {boolean}
1310
1316
  */
1311
1317
  get _headersBuilt() {
@@ -1313,7 +1319,7 @@ module.exports = class Request extends LazyReadable {
1313
1319
  }
1314
1320
 
1315
1321
  /**
1316
- * Whether the query string was parsed at least once.
1322
+ * Whether the query string was parsed at least once, which src/work.js reports.
1317
1323
  * @returns {boolean}
1318
1324
  */
1319
1325
  get _queryParsed() {
@@ -1321,7 +1327,7 @@ module.exports = class Request extends LazyReadable {
1321
1327
  }
1322
1328
 
1323
1329
  /**
1324
- * Whether the socket stand-in `req.connection` was allocated.
1330
+ * Whether the socket stand-in `req.connection` was allocated, which src/work.js reports.
1325
1331
  * @returns {boolean}
1326
1332
  */
1327
1333
  get _socketBuilt() {
@@ -38,9 +38,12 @@ const kShapeMode =
38
38
  const VALIDATED_HEADER_NAMES = new Map();
39
39
  // The names and values that recur on every response, kept as Buffers for the uWS crossing: a Buffer
40
40
  // is memcpy'd as it is, a string pays a UTF-8 scan and copy per call. A header that is not here
41
- // crosses as the string it was. Names must stay lowercase, as writeHeaders receives them.
42
- const HEADER_NAME_BUF = { __proto__: null };
43
- const HEADER_VALUE_BUF = { __proto__: null };
41
+ // crosses as the string it was. Names must stay lowercase, as writeHeaders receives them. The
42
+ // casts are for the __proto__ key, which the type checker reads as a property
43
+ /** @type {Record<string, Buffer>} */
44
+ const HEADER_NAME_BUF = /** @type {any} */ ({ __proto__: null });
45
+ /** @type {Record<string, Buffer>} */
46
+ const HEADER_VALUE_BUF = /** @type {any} */ ({ __proto__: null });
44
47
  for (const s of ["connection", "keep-alive", "content-type", "vary", "x-powered-by", "content-encoding"]) {
45
48
  HEADER_NAME_BUF[s] = Buffer.from(s);
46
49
  }
@@ -81,11 +84,45 @@ function statusLine(code, text) {
81
84
  return `${code} ${text ?? statuses.message[code] ?? "unknown"}`.trim();
82
85
  }
83
86
 
87
+ /**
88
+ * The TypeError node throws for a chunk that is not a string, a Buffer or a Uint8Array, worded as
89
+ * node words it.
90
+ *
91
+ * @param {unknown} chunk what end() was handed, known here not to be a string or a Uint8Array
92
+ * @returns {NodeJS.ErrnoException}
93
+ */
94
+ function invalidChunkError(chunk) {
95
+ let received;
96
+ if (chunk === null) {
97
+ received = "null";
98
+ } else if (typeof chunk === "object" || typeof chunk === "function") {
99
+ const name = /** @type {any} */ (chunk).constructor?.name;
100
+ received = name ? `an instance of ${name}` : "an instance of Object";
101
+ } else if (typeof chunk === "bigint") {
102
+ received = `type bigint (${chunk}n)`;
103
+ } else if (typeof chunk === "symbol") {
104
+ received = `type symbol (${String(chunk)})`;
105
+ } else {
106
+ received = `type ${typeof chunk} (${chunk})`;
107
+ }
108
+ /** @type {NodeJS.ErrnoException} */
109
+ const err = new TypeError(
110
+ `The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received ${received}`
111
+ );
112
+ err.code = "ERR_INVALID_ARG_TYPE";
113
+ // as node: the bracketed name goes into the stack's first line, then err.name reads TypeError
114
+ err.name = "TypeError [ERR_INVALID_ARG_TYPE]";
115
+ void err.stack;
116
+ delete (/** @type {Record<string, unknown>} */ (/** @type {unknown} */ (err)).name);
117
+ return err;
118
+ }
119
+
84
120
  module.exports = {
85
121
  kOutHeaders,
86
122
  kShapeMode,
87
123
  VALIDATED_HEADER_NAMES,
88
124
  HEADER_NAME_BUF,
89
125
  HEADER_VALUE_BUF,
90
- statusLine
126
+ statusLine,
127
+ invalidChunkError
91
128
  };
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
@@ -128,6 +96,15 @@ module.exports = class Response extends LazyWritable {
128
96
  /** @type {((err?: Error|null) => void)|null} */
129
97
  #pendingCallback = null;
130
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
+
131
108
  /**
132
109
  * Chunks written but not yet handed to uWS: a write costs uWS everything buffered behind it, so
133
110
  * many small pieces cost quadratically, 500 writes of 66 bytes measured 13ms against 0.4ms in
@@ -333,6 +310,7 @@ module.exports = class Response extends LazyWritable {
333
310
  * A finished or aborted response only tears the stream down, touching an aborted uWS response
334
311
  * is a use after free. writableEnded reads true after this, node keeps it false until end().
335
312
  *
313
+ * @override
336
314
  * @param {Error} [error] whatever the caller is destroying the response with
337
315
  * @returns {this}
338
316
  */
@@ -382,8 +360,9 @@ module.exports = class Response extends LazyWritable {
382
360
  get [kOutHeaders]() {
383
361
  if (!this.#outHeaders) {
384
362
  this.#outHeaders = new Proxy(this.headers, {
363
+ // node keys the slot by lowercased name, and stores [name, value]
385
364
  set: (obj, prop, value) => {
386
- this.set(prop, value[1]);
365
+ this.set(/** @type {string} */ (prop), value[1]);
387
366
  return true;
388
367
  },
389
368
  get: (obj, prop) => {
@@ -394,6 +373,7 @@ module.exports = class Response extends LazyWritable {
394
373
  return this.#outHeaders;
395
374
  }
396
375
 
376
+ /** @param {Response["headers"]|null} value */
397
377
  set [kOutHeaders](value) {
398
378
  this.#outHeaders = value;
399
379
  }
@@ -456,6 +436,7 @@ module.exports = class Response extends LazyWritable {
456
436
  this.#pendingCallback = null;
457
437
  this.writingChunk = false;
458
438
  if (cb) cb(null);
439
+ this.#afterPending();
459
440
  return true;
460
441
  });
461
442
  } else {
@@ -464,6 +445,18 @@ module.exports = class Response extends LazyWritable {
464
445
  }
465
446
  }
466
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
+
467
460
  /**
468
461
  * The booked flush. Static, so a response that never writes in pieces allocates no closure.
469
462
  *
@@ -479,6 +472,7 @@ module.exports = class Response extends LazyWritable {
479
472
  * Writable's sink: the head if not out yet, then the chunk to uWS through the queue for a
480
473
  * chunked response or tryEnd with a Content-Length. Backpressure defers the callback.
481
474
  *
475
+ * @override
482
476
  * @param {any} chunk whatever a Writable was handed
483
477
  * @param {BufferEncoding} encoding
484
478
  * @param {(err?: Error|null) => void} callback
@@ -488,11 +482,13 @@ module.exports = class Response extends LazyWritable {
488
482
  /** @type {NodeJS.ErrnoException} */
489
483
  const err = new Error("Request aborted");
490
484
  err.code = "ECONNABORTED";
491
- return this.destroy(err);
485
+ this.destroy(err);
486
+ return;
492
487
  }
493
488
  if (this.finished) {
494
489
  const err = new Error("Response already finished");
495
- return this.destroy(err);
490
+ this.destroy(err);
491
+ return;
496
492
  }
497
493
 
498
494
  this.writingChunk = true;
@@ -554,6 +550,7 @@ module.exports = class Response extends LazyWritable {
554
550
  this.writingChunk = false;
555
551
  handlerUsed = true;
556
552
  callback(null);
553
+ this.#afterPending();
557
554
  }
558
555
  return ok;
559
556
  });
@@ -697,6 +694,7 @@ module.exports = class Response extends LazyWritable {
697
694
  }
698
695
 
699
696
  /**
697
+ * @override
700
698
  * @param {string|Buffer|Uint8Array|null|(() => void)} [data] the last body piece, or the callback in
701
699
  * node's one-argument shape
702
700
  * @param {BufferEncoding|(() => void)} [encoding] how a string body is encoded, or the callback in
@@ -726,13 +724,11 @@ module.exports = class Response extends LazyWritable {
726
724
  }
727
725
  // uWS takes a string as utf-8 only: res.end(data, "binary") is how old code sends an image
728
726
  if (typeof data === "string" && encoding !== undefined && encoding !== "utf8" && encoding !== "utf-8") {
729
- data = Buffer.from(data, encoding);
727
+ data = Buffer.from(data, /** @type {BufferEncoding} */ (encoding));
730
728
  }
731
729
 
732
730
  if (this.writingChunk) {
733
- this.once("drain", () => {
734
- this.end(data, cb);
735
- });
731
+ this.#deferredEnd = () => this.end(data, cb);
736
732
  return this;
737
733
  }
738
734
  if (this.finished) {
@@ -743,10 +739,12 @@ module.exports = class Response extends LazyWritable {
743
739
  this.writeHead(this.statusCode);
744
740
  }
745
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);
746
744
  if (this._corkNeeded) {
747
- this._res.cork(() => this._finish(data, cb));
745
+ this._res.cork(() => this._finish(chunk, cb));
748
746
  } else {
749
- this._finish(data, cb);
747
+ this._finish(chunk, cb);
750
748
  }
751
749
  return this;
752
750
  }
@@ -823,7 +821,8 @@ module.exports = class Response extends LazyWritable {
823
821
  * Sends the body, with a Content-Type when none was set and an ETag when the setting asks.
824
822
  * A number is a value to serialise, never a status: that is `sendStatus()`.
825
823
  *
826
- * @param {string|number|boolean|object|Buffer|null} [body]
824
+ * @param {string|number|boolean|object|null} [body] a Buffer or a Uint8Array is sent as bytes,
825
+ * any other object is serialised
827
826
  * @returns {this}
828
827
  */
829
828
  send(body) {
@@ -840,6 +839,12 @@ module.exports = class Response extends LazyWritable {
840
839
  delete this.headers["content-length"];
841
840
  delete this.headers["transfer-encoding"];
842
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");
843
848
  }
844
849
  return this.end("");
845
850
  }
@@ -866,11 +871,12 @@ module.exports = class Response extends LazyWritable {
866
871
  } else if (!isBuffer && typeof body !== "string") {
867
872
  // a symbol, a bigint or a function: what node's byteLength or from() throws is the
868
873
  // answer. A string never gets here, measuring it twice cost 157us per thousand requests
874
+ const unsendable = /** @type {any} */ (body);
869
875
  const generateETag = !this.headers["etag"] && typeof this.app._hot().etagFn === "function";
870
- if (!generateETag && body.length < 1000) {
871
- Buffer.byteLength(body, "utf8");
876
+ if (!generateETag && unsendable.length < 1000) {
877
+ Buffer.byteLength(unsendable, "utf8");
872
878
  }
873
- Buffer.from(body, "utf8");
879
+ Buffer.from(unsendable, "utf8");
874
880
  }
875
881
  if (typeof body === "string" && !isBuffer) {
876
882
  const contentType = this.headers["content-type"];
@@ -898,7 +904,7 @@ module.exports = class Response extends LazyWritable {
898
904
  !this.req.noEtag &&
899
905
  (hot.etagMethods === null || hot.etagMethods.has(this.req.method))
900
906
  ) {
901
- const etag = etagFn(body);
907
+ const etag = etagFn(/** @type {string|Buffer} */ (body));
902
908
  // an application's own etag function may decline
903
909
  if (etag) {
904
910
  this.headers["etag"] = etag;
@@ -924,11 +930,11 @@ module.exports = class Response extends LazyWritable {
924
930
  // Transfer-Encoding the application set, as express 5.3
925
931
  if (this.req.method === "HEAD") {
926
932
  if (this.statusCode !== 204 && this.statusCode !== 304 && !this.headers["transfer-encoding"]) {
927
- this.headers["content-length"] = String(Buffer.byteLength(body));
933
+ this.headers["content-length"] = String(Buffer.byteLength(/** @type {string|Buffer} */ (body)));
928
934
  }
929
935
  return this.end();
930
936
  }
931
- return this.end(body);
937
+ return this.end(/** @type {string|Buffer} */ (body));
932
938
  }
933
939
 
934
940
  /**
@@ -1195,6 +1201,7 @@ module.exports = class Response extends LazyWritable {
1195
1201
  });
1196
1202
  } else {
1197
1203
  // large files and ranges are piped
1204
+ /** @type {{highWaterMark: number, start?: number, end?: number}} */
1198
1205
  const opts = {
1199
1206
  highWaterMark: HIGH_WATERMARK
1200
1207
  };
@@ -1258,6 +1265,7 @@ module.exports = class Response extends LazyWritable {
1258
1265
  }
1259
1266
 
1260
1267
  // a header option of sendFile, as Express does: it only goes out once the stat succeeded
1268
+ /** @type {Record<string, string>} */
1261
1269
  const headers = {
1262
1270
  "Content-Disposition": contentDisposition(name || path)
1263
1271
  };
@@ -1550,8 +1558,9 @@ module.exports = class Response extends LazyWritable {
1550
1558
  */
1551
1559
  set(field, value) {
1552
1560
  if (typeof field === "object") {
1553
- for (const header in field) {
1554
- this.set(header, field[header]);
1561
+ const fields = /** @type {Record<string, string|string[]>} */ (field);
1562
+ for (const header in fields) {
1563
+ this.set(header, fields[header]);
1555
1564
  }
1556
1565
  } else {
1557
1566
  const name = field.toLowerCase();
@@ -1893,8 +1902,10 @@ module.exports = class Response extends LazyWritable {
1893
1902
  url = status;
1894
1903
  status = 302;
1895
1904
  }
1905
+ // read from the closures below too, where the checker does not see the shuffle above
1906
+ const code = /** @type {number} */ (status);
1896
1907
  this.location(/** @type {string} */ (url));
1897
- this.status(/** @type {number} */ (status));
1908
+ this.status(code);
1898
1909
 
1899
1910
  const address = /** @type {string} */ (this.get("Location"));
1900
1911
  let body;
@@ -1917,11 +1928,11 @@ module.exports = class Response extends LazyWritable {
1917
1928
  this.format({
1918
1929
  text: () => {
1919
1930
  this.set("Content-Type", "text/plain; charset=utf-8");
1920
- body = `${statuses.message[status]}. Redirecting to ${address}`;
1931
+ body = `${statuses.message[code]}. Redirecting to ${address}`;
1921
1932
  },
1922
1933
  html: () => {
1923
1934
  this.set("Content-Type", "text/html; charset=utf-8");
1924
- body = `<p>${statuses.message[status]}. Redirecting to ${escapeHtml(address)}</p>`;
1935
+ body = `<p>${statuses.message[code]}. Redirecting to ${escapeHtml(address)}</p>`;
1925
1936
  },
1926
1937
  default: () => {
1927
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 Route.prototype[method] === "function") {
173
+ if (method !== "all" && typeof prototype[method] === "function") {
172
174
  continue;
173
175
  }
174
- Route.prototype[method] = function (/** @type {unknown[]} */ ...handlers) {
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
@@ -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 = ["set", "setHeader", "header", "send", "end", "append", "status", "json", "sendStatus"];
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({ path: pathPrefix + r.path, use: r.use === true, method: r.method, all: r.all === true });
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
- entry = called.get(name);
1078
- if (entry && (entry.match === value || (entry.error && entry.error !== "route"))) {
1079
- req.params[name] = entry.value;
1080
- return nextParam(entry.error);
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
- err = this._generateErrorPage(err, response.statusCode, checkEnv);
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(err);
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
- module.exports.prototype[method] = function (
1388
+ prototype[method] = function (
1375
1389
  /** @type {string|RegExp|(string|RegExp)[]} */ path,
1376
1390
  /** @type {unknown[]} */ ...callbacks
1377
1391
  ) {