fulmine.js 5.19.8 → 5.20.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.
@@ -27,14 +27,21 @@ class HotSettings {
27
27
  constructor() {
28
28
  this.epoch = 0;
29
29
  this.xPoweredBy = false;
30
+ /** @type {((body: string|Buffer|import("fs").Stats, encoding?: BufferEncoding) => string)|undefined} */
30
31
  this.etagFn = undefined;
31
32
  // null means every method, which is express's behaviour and the default
33
+ /** @type {Set<string>|null} */
32
34
  this.etagMethods = null;
35
+ /** @type {((query: string|null) => Record<string, any>)|undefined} */
33
36
  this.queryParserFn = undefined;
37
+ /** @type {import("./utils.js").TrustFn|undefined} */
34
38
  this.trustProxyFn = undefined;
35
39
  this.trustProxyProtocol = false;
40
+ /** @type {boolean|undefined} */
36
41
  this.jsonEscape = undefined;
42
+ /** @type {any} the "json replacer" setting, as stringify takes it */
37
43
  this.jsonReplacer = undefined;
44
+ /** @type {string|number|undefined} */
38
45
  this.jsonSpaces = undefined;
39
46
  }
40
47
  }
package/src/index.js CHANGED
@@ -21,6 +21,28 @@ limitations under the License.
21
21
  // package ships, so the module is read through a loose alias
22
22
  const uWS = require("uWebSockets.js");
23
23
  const uWSAny = /** @type {any} */ (uWS);
24
+
25
+ // A project on pnpm owns the uWebSockets.js dependency itself, see `npx fulmine.js pnpm`, so the
26
+ // one installed can drift from the one this package pins and was tested against. Said once, at
27
+ // require time, where it reaches every deployment rather than only the ones that run verify.
28
+ {
29
+ const pinned = /#v?([\d.]+)$/.exec(require("../package.json").dependencies["uWebSockets.js"])?.[1];
30
+ /** @type {string|undefined} */
31
+ let installed;
32
+ try {
33
+ // its exports map does not expose package.json, so it is read beside the entry point
34
+ const beside = require("path").join(require.resolve("uWebSockets.js"), "..", "package.json");
35
+ installed = JSON.parse(require("fs").readFileSync(beside, "utf8")).version;
36
+ } catch {
37
+ // nothing to compare against, which is not worth a warning of its own
38
+ }
39
+ if (pinned && installed && installed !== pinned) {
40
+ console.warn(
41
+ `fulmine.js: uWebSockets.js ${installed} is installed, this version was tested with ${pinned}.\n` +
42
+ " On pnpm the pin is the project's own: `npx fulmine.js pnpm` writes the tested one."
43
+ );
44
+ }
45
+ }
24
46
  const Application = require("./application.js");
25
47
  const Router = require("./router.js");
26
48
  const Route = require("./route.js");
@@ -68,6 +90,7 @@ try {
68
90
  module.exports = /** @type {any} */ (Application);
69
91
 
70
92
  // a router is a function too: it has to be callable to be used as middleware
93
+ /** @param {object} [options] the options express.Router() takes */
71
94
  module.exports.Router = function (options) {
72
95
  return new Router(options)._asCallable();
73
96
  };
@@ -139,6 +139,7 @@ function stripBom(text) {
139
139
  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
140
140
  }
141
141
 
142
+ /** @type {typeof import("iconv-lite")|undefined} */
142
143
  let iconv;
143
144
 
144
145
  /**
@@ -966,6 +967,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
966
967
  (contentType) => !!typeis.is(contentType, /** @type {string[]} */ (options.type))
967
968
  );
968
969
 
970
+ /** @type {string[]|null|undefined} the "body methods" setting, read on the first request */
969
971
  let additionalMethods;
970
972
 
971
973
  const parserMiddleware = (req, res, next) => {
@@ -1038,6 +1040,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1038
1040
  // halves sit on either side of the encoding, which is the order body-parser reads
1039
1041
  // them in: the charset this parser accepts at all, then the Content-Encoding, then
1040
1042
  // whether iconv knows the charset.
1043
+ /** @type {string|undefined} */
1041
1044
  let encoding;
1042
1045
  if (charsetPolicy) {
1043
1046
  encoding = charsetOf(type) ?? defaultCharset;
@@ -1129,7 +1132,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1129
1132
  // not us, and would wait for an end that is never coming
1130
1133
  req.complete = true;
1131
1134
  req.readable = false;
1132
- req._res.collectBody(limit, (body) => {
1135
+ req._res.collectBody(limit, (/** @type {ArrayBuffer|null} */ body) => {
1133
1136
  if (body === null) {
1134
1137
  // over maxSize: uWS refused it natively
1135
1138
  return next(
@@ -1165,6 +1168,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1165
1168
  // known and we are not inflating, the final size is known up front, so chunks go
1166
1169
  // straight into one buffer and the body is copied once. The cap means a client that
1167
1170
  // declares a body and never sends it costs no more than one that sends it
1171
+ /** @type {Buffer[]} */
1168
1172
  const abs = [];
1169
1173
  const declaredLength = inflate ? -1 : Number(length);
1170
1174
  let target =
@@ -1310,7 +1314,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1310
1314
  // if we are fast enough (not async), we can do it
1311
1315
  // otherwise we need to use a stream since it already started streaming it
1312
1316
  if (!req.receivedData) {
1313
- req._res.onData((ab, isLast) => {
1317
+ req._res.onData((/** @type {ArrayBuffer} */ ab, /** @type {boolean} */ isLast) => {
1314
1318
  onData(ab);
1315
1319
  if (isLast) {
1316
1320
  // this subscription replaced the Readable's own, so the stream will
package/src/node-shim.js CHANGED
@@ -39,21 +39,30 @@ function addressToBytes(address) {
39
39
  if (!address) {
40
40
  return new ArrayBuffer(0);
41
41
  }
42
- // node reports an IPv4 client on a dual stack socket as ::ffff:127.0.0.1, and the address that
43
- // belongs in req.ip is the v4 one
44
- const mapped = address.startsWith("::ffff:") ? address.slice(7) : address;
45
- if (mapped.includes(".")) {
46
- const parts = mapped.split(".");
42
+ // node reports an IPv4 client on a dual stack socket as ::ffff:127.0.0.1 and one on an IPv4
43
+ // socket as 127.0.0.1, which is the difference req.ip reads back out of the width: uWS hands
44
+ // the mapped peer over as the sixteen bytes, the plain one as four. Keeping node's own form
45
+ // rather than flattening both to four is what makes the shim answer what node answers,
46
+ // whichever socket accepted the connection
47
+ const mapped = address.startsWith("::ffff:") && address.includes(".");
48
+ const dotted = mapped ? address.slice(7) : address;
49
+ if (dotted.includes(".")) {
50
+ const parts = dotted.split(".");
47
51
  if (parts.length !== 4) {
48
52
  return new ArrayBuffer(0);
49
53
  }
50
- const bytes = new Uint8Array(4);
54
+ const bytes = new Uint8Array(mapped ? 16 : 4);
55
+ const offset = mapped ? 12 : 0;
56
+ if (mapped) {
57
+ bytes[10] = 0xff;
58
+ bytes[11] = 0xff;
59
+ }
51
60
  for (let i = 0; i < 4; i++) {
52
61
  const value = Number(parts[i]);
53
62
  if (!Number.isInteger(value) || value < 0 || value > 255) {
54
63
  return new ArrayBuffer(0);
55
64
  }
56
- bytes[i] = value;
65
+ bytes[offset + i] = value;
57
66
  }
58
67
  return bytes.buffer;
59
68
  }
@@ -80,13 +89,19 @@ function addressToBytes(address) {
80
89
  return view.buffer;
81
90
  }
82
91
 
83
- /** A chunk as the ArrayBuffer uWS deals in, copied rather than viewed so nothing aliases node's. */
92
+ /**
93
+ * A chunk as the ArrayBuffer uWS deals in, copied rather than viewed so nothing aliases node's.
94
+ *
95
+ * @param {ArrayBuffer|Buffer|string} chunk
96
+ * @returns {ArrayBuffer}
97
+ */
84
98
  function toArrayBuffer(chunk) {
85
99
  if (chunk instanceof ArrayBuffer) {
86
100
  return chunk;
87
101
  }
88
102
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
89
- return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
103
+ // node types the store as possibly shared, but a Buffer it made or was given here never is
104
+ return /** @type {ArrayBuffer} */ (buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength));
90
105
  }
91
106
 
92
107
  /**
@@ -175,6 +190,7 @@ class NodeHttpResponse {
175
190
  // how much of the body has gone out, which is what uWS reports through getWriteOffset and
176
191
  // hands back to an onWritable callback
177
192
  this._offset = 0;
193
+ /** @type {((offset: number) => boolean)|null} */
178
194
  this._onWritable = null;
179
195
  this._aborted = false;
180
196
  // the current body handler: uWS keeps one and a second onData replaces it, so this does too
@@ -195,6 +211,8 @@ class NodeHttpResponse {
195
211
  * uWS batches everything written inside this into one syscall. node has no equivalent that
196
212
  * means the same thing, and its own cork would hold the write until the callback returned
197
213
  * without changing what is sent, so this only runs it.
214
+ *
215
+ * @param {() => void} cb
198
216
  */
199
217
  cork(cb) {
200
218
  cb();
@@ -300,13 +318,19 @@ class NodeHttpResponse {
300
318
  * Called when there is room to write again. uWS asks the handler to answer whether it managed
301
319
  * to write everything, and calls it again if not; node's drain says nothing, so the handler is
302
320
  * kept until the next drain and its answer ignored.
321
+ *
322
+ * @param {(offset: number) => boolean} handler
303
323
  */
304
324
  onWritable(handler) {
305
325
  this._onWritable = handler;
306
326
  return this;
307
327
  }
308
328
 
309
- /** Called when the connection goes before the response is finished. */
329
+ /**
330
+ * Called when the connection goes before the response is finished.
331
+ *
332
+ * @param {() => void} handler
333
+ */
310
334
  onAborted(handler) {
311
335
  this._nodeRes.on("close", () => {
312
336
  if (!this._nodeRes.writableFinished) {
package/src/optimizer.js CHANGED
@@ -51,6 +51,7 @@ const {
51
51
 
52
52
  // router.js requires this file while it is still being evaluated, so its class cannot be required
53
53
  // from here: it hands it over on the line under its own export instead.
54
+ /** @type {typeof import("./router.js")} */
54
55
  let Router;
55
56
 
56
57
  /**
@@ -233,6 +234,12 @@ function compileOptimizedRoutes(root) {
233
234
 
234
235
  // pathPrefix/chainPrefix accumulate across nested sole-callback mounts, and outerGuards
235
236
  // carries what was written before them and answers only part of what is under them
237
+ /**
238
+ * @param {Router} router
239
+ * @param {string} pathPrefix
240
+ * @param {RouteEntry[]} chainPrefix
241
+ * @param {import("./router-utils.js").MountGuard[]} outerGuards
242
+ */
236
243
  const walk = (router, pathPrefix, chainPrefix, outerGuards) => {
237
244
  for (const route of router._routes) {
238
245
  if (route.use) {
@@ -344,7 +351,7 @@ function compileOptimizedRoutes(root) {
344
351
  if (route._caseGuards) {
345
352
  // compared against the whole path µWS matched, so they carry the mount
346
353
  // prefix, folded along with the rest of it
347
- registered._caseGuards = route._caseGuards.map((p) => pathPrefix + p);
354
+ registered._caseGuards = route._caseGuards.map((/** @type {string} */ p) => pathPrefix + p);
348
355
  }
349
356
  root._registerUwsRoute(registered, chain);
350
357
  // the chain holds the original object, so the request-time guard has to find
@@ -388,18 +395,27 @@ function registerUwsRoute(router, route, optimizedPath) {
388
395
  method = "del";
389
396
  }
390
397
  if (route.path.includes(":")) {
391
- route.optimizedParams = route.path.match(regExParam).map((p) => p.slice(1));
398
+ route.optimizedParams = route.path.match(regExParam).map((/** @type {string} */ p) => p.slice(1));
392
399
  }
393
400
  // null for almost every route: only a parameter route with an earlier literal that a case
394
401
  // variant could slip past carries one, see _optimizeRoute. Built once here, and matched
395
402
  // insensitively, since that is the folding the guard exists for
396
403
  const caseGuards = route._caseGuards
397
- ? route._caseGuards.map((p) => (needsConversionToRegex(p) ? patternToRegex(p, false, false) : p.toLowerCase()))
404
+ ? route._caseGuards.map((/** @type {string} */ p) =>
405
+ needsConversionToRegex(p) ? patternToRegex(p, false, false) : p.toLowerCase()
406
+ )
398
407
  : null;
408
+ /**
409
+ * @param {RouteEntry[]} chain
410
+ * @param {import("./router-utils.js").NativePreset|undefined} preset
411
+ * @param {{skipHeaders: boolean, skipQuery: boolean}} skips
412
+ * @param {string|null} wireMethod
413
+ */
399
414
  const makeHandler = (chain, preset, skips, wireMethod) => {
400
415
  // the mutable object a granted skip lives on, so a middleware arriving after listen can
401
416
  // take it back: a literal registration's preset doubles as it, a parameterised one gets a
402
417
  // holder of its own. It carries the method too, so the constructor settles it in one compare
418
+ /** @type {import("./router-utils.js").SkipHolder|undefined} */
403
419
  let skipHolder = preset;
404
420
  if (skipHolder === undefined && (skips.skipHeaders || skips.skipQuery || wireMethod !== null)) {
405
421
  skipHolder = {
@@ -504,6 +520,11 @@ function registerUwsRoute(router, route, optimizedPath) {
504
520
  }
505
521
  }
506
522
  // remembered so a middleware or setting arriving after listen can take the skips back
523
+ /**
524
+ * @param {string} path
525
+ * @param {string} method
526
+ * @param {{skipHeaders: boolean, skipQuery: boolean}} skips
527
+ */
507
528
  const makePreset = (path, method, skips) => {
508
529
  const preset = nativePreset(path, method);
509
530
  if (skips.skipHeaders || skips.skipQuery) {
@@ -104,8 +104,9 @@ function isMappedIPv4(bytes) {
104
104
 
105
105
  /**
106
106
  * Whether node would report an IPv4 peer of this app in mapped form, "::ffff:a.b.c.d". Node maps it
107
- * whenever the listener is dual stack, which is every listen() without an IPv4 address. uWS already
108
- * gives mapped peers as sixteen bytes, four bytes come only from a v4 listener or the node shim.
107
+ * whenever the listener is dual stack, which is every listen() without an IPv4 address. Only the
108
+ * address invented after the response has ended asks: every address actually read says which form
109
+ * it is in by its width, uWS handing a mapped peer over as sixteen bytes and a plain one as four.
109
110
  *
110
111
  * @param {import("./application.js").Application} app the application the request arrived at
111
112
  * @returns {boolean}
package/src/request.js CHANGED
@@ -204,13 +204,6 @@ module.exports = class Request extends LazyReadable {
204
204
  */
205
205
  rawIp;
206
206
 
207
- /**
208
- * Whether rawIp came from a PROXY protocol preamble rather than from the socket. Only the
209
- * IPv4 mapping reads it, see parsedIp. Declared for the same reason as rawIp.
210
- * @type {boolean}
211
- */
212
- _ipFromProxy = false;
213
-
214
207
  /**
215
208
  * Whether the request declared a body, content-length or transfer-encoding, spotted during
216
209
  * the header copy. Declared for the same reason as rawIp.
@@ -457,7 +450,9 @@ module.exports = class Request extends LazyReadable {
457
450
  this._lastMethod = this.method;
458
451
  // the folded _opPath and the percent scan of _originalPath, built on the hop that first
459
452
  // wants them and dropped by every rewrite, see _pathMatches and Walk#dispatch
453
+ /** @type {string|null} */
460
454
  this._opPathLower = null;
455
+ /** @type {boolean|null} */
461
456
  this._mayFailDecode = null;
462
457
  this.params = {};
463
458
 
@@ -478,6 +473,7 @@ module.exports = class Request extends LazyReadable {
478
473
  this._paramStack = null;
479
474
  // route and application in pairs, one pair per mounted application entered from another
480
475
  // application, so handing back puts the one that was current back, see rememberApp
476
+ /** @type {any[]|undefined} route and app alternating, loose because the pairs share one array */
481
477
  this._appStack = undefined;
482
478
  this.receivedData = false;
483
479
  // node's IncomingMessage flag: false until the whole body has arrived. on-finished
@@ -1048,7 +1044,6 @@ module.exports = class Request extends LazyReadable {
1048
1044
  const proxied = uwsRes.getProxiedRemoteAddress();
1049
1045
  // empty unless a preamble arrived, which is the only thing that tells the two apart
1050
1046
  if (proxied.byteLength !== 0) {
1051
- this._ipFromProxy = true;
1052
1047
  return proxied;
1053
1048
  }
1054
1049
  }
@@ -1084,14 +1079,12 @@ module.exports = class Request extends LazyReadable {
1084
1079
  /** @type {string|undefined} */
1085
1080
  let ip;
1086
1081
  if (rawIp.byteLength === 4) {
1087
- // ipv4
1082
+ // ipv4, and plain: four bytes mean the peer arrived over IPv4 on an IPv4 socket, which
1083
+ // is what node writes plain as well. A dual stack listener hands an IPv4 peer over as
1084
+ // the mapped sixteen below, the node shim passes node's own form through, and an
1085
+ // address a proxy declared is the four numbers the proxy sent, so none of them wants
1086
+ // a prefix invented here
1088
1087
  ip = new Uint8Array(rawIp).join(".");
1089
- // the mapped form belongs to a dual stack listener, which is what makes an IPv4 peer
1090
- // arrive as ::ffff:a.b.c.d. An address a proxy declared never came through that socket,
1091
- // so it is left as the four numbers the proxy sent
1092
- if (!this._ipFromProxy && mapsIPv4Peer(this.app)) {
1093
- ip = "::ffff:" + ip;
1094
- }
1095
1088
  } else if (rawIp.byteLength === 16) {
1096
1089
  const bytes = new Uint8Array(rawIp);
1097
1090
  if (isMappedIPv4(bytes)) {
@@ -68,7 +68,7 @@ for (const s of [
68
68
  // One status line per code, built on first use: the default path, with no custom reason phrase,
69
69
  // paid a template string and a trim per request for a line that never changes. Bounded to real
70
70
  // HTTP codes so a wild writeHead value cannot grow the array or flip it into dictionary mode.
71
- const STATUS_LINES = [];
71
+ const STATUS_LINES = /** @type {string[]} */ ([]);
72
72
  /**
73
73
  * @param {number} code
74
74
  * @param {string|undefined} text an explicit reason phrase, which bypasses the cache
package/src/response.js CHANGED
@@ -208,6 +208,10 @@ module.exports = class Response extends LazyWritable {
208
208
  this.req = req;
209
209
  this._res = res;
210
210
  this.headersSent = false;
211
+ // whether a body goes out, decided from the method that arrived, as node decides it when
212
+ // it builds its ServerResponse: a middleware that rewrites req.method afterwards, which
213
+ // method-override does, changes what the router matches and not what the wire carries
214
+ this._hasBody = req._isHead !== true;
211
215
  this.app = app;
212
216
  this.locals = new NullObject();
213
217
  this.finished = false;
@@ -238,6 +242,7 @@ module.exports = class Response extends LazyWritable {
238
242
  this.headers["x-powered-by"] = "Fulmine";
239
243
  }
240
244
 
245
+ /** @type {unknown} a slot for whatever a middleware puts on res.body, never read here */
241
246
  this.body = undefined;
242
247
  // what was handed to uWS, kept so a caller asking for content-length after the fact can be
243
248
  // answered, see get(). Undefined until the response ends, and for one that sends no body
@@ -811,7 +816,7 @@ module.exports = class Response extends LazyWritable {
811
816
  } else {
812
817
  // a Buffer goes to uWS as the view it is: copying it into a fresh ArrayBuffer was
813
818
  // an allocation per body, and uWS reads the view's own offset and length
814
- if (this.req.method === "HEAD") {
819
+ if (!this._hasBody) {
815
820
  const length = Buffer.byteLength(data ?? "");
816
821
  this.headers["content-length"] = String(length);
817
822
  this._res.endWithoutBody(length, closeConnection);
@@ -961,6 +966,15 @@ module.exports = class Response extends LazyWritable {
961
966
  delete this.headers["transfer-encoding"];
962
967
  body = "";
963
968
  }
969
+ // express's send reads req.method here and hands node's end() nothing for a HEAD, the
970
+ // length already set, and node frames what it is handed: a GET a middleware made a HEAD
971
+ // answers its length and no body. end() alone decides by the wire, see _hasBody
972
+ if (this.req.method === "HEAD") {
973
+ if (this.statusCode !== 204 && this.statusCode !== 304) {
974
+ this.headers["content-length"] = String(Buffer.byteLength(body));
975
+ }
976
+ return this.end();
977
+ }
964
978
  return this.end(body);
965
979
  }
966
980
 
package/src/route.js CHANGED
@@ -48,6 +48,10 @@ class Route {
48
48
  * @returns {boolean}
49
49
  */
50
50
  handlesMethod(method) {
51
+ // all() answers every verb, registered or not, which is what express reads _all for
52
+ if (this.methods._all) {
53
+ return true;
54
+ }
51
55
  const lowered = method.toLowerCase();
52
56
  return Boolean(this.methods[lowered] || (lowered === "head" && this.methods.get));
53
57
  }
@@ -87,6 +91,7 @@ class Route {
87
91
  }
88
92
  req.route = this;
89
93
 
94
+ /** @param {unknown} [err] */
90
95
  const next = (err) => {
91
96
  // next("route") leaves this route, and next("router") leaves whoever is running it
92
97
  if (err === "route") {
@@ -129,7 +134,7 @@ class Route {
129
134
  // does it. A bare rejection carries none and gets the one express invents. Thenable
130
135
  // too, which express deprecates but still waits for
131
136
  if (out && typeof out.then === "function") {
132
- out.then(null, (thrown) => next(thrown || new Error("Rejected promise")));
137
+ out.then(null, (/** @type {unknown} */ thrown) => next(thrown || new Error("Rejected promise")));
133
138
  }
134
139
  } catch (thrown) {
135
140
  next(thrown);
@@ -162,14 +167,15 @@ for (const method of ["all", ...METHODS.map((verb) => verb.toLowerCase())]) {
162
167
  if (method !== "all" && typeof Route.prototype[method] === "function") {
163
168
  continue;
164
169
  }
165
- Route.prototype[method] = function (...handlers) {
170
+ Route.prototype[method] = function (/** @type {unknown[]} */ ...handlers) {
166
171
  const flattened = handlers.flat(Infinity);
167
172
  checkRouteHandlers(method, flattened);
168
- for (const handle of flattened) {
173
+ // the check above threw on anything else
174
+ for (const handle of /** @type {Function[]} */ (flattened)) {
169
175
  this.stack.push({ method: method === "all" ? undefined : method, handle });
170
- if (method !== "all") {
171
- this.methods[method] = true;
172
- }
176
+ // express marks the route _all rather than naming every verb, and whoever reads
177
+ // req.route.methods reads that key, so it is written the same way here
178
+ this.methods[method === "all" ? "_all" : method] = true;
173
179
  }
174
180
  return this;
175
181
  };
@@ -175,8 +175,14 @@ function layerFor(route, callback) {
175
175
  * @returns {Layer} the layer object, which is express's shape and not one of ours
176
176
  */
177
177
  function routeLayer(route) {
178
+ /**
179
+ * @param {Request} req
180
+ * @param {Response} res
181
+ * @param {(err?: unknown) => void} next
182
+ */
178
183
  const handle = function handle(req, res, next) {
179
184
  let index = 0;
185
+ /** @param {unknown} [err] */
180
186
  const step = (err) => {
181
187
  const callback = route.callbacks[index++];
182
188
  if (callback === undefined) {
@@ -206,6 +212,7 @@ function routeLayer(route) {
206
212
  * The 404 epilogue stays on a microtask, where the await used to resume: a middleware that writes
207
213
  * after calling next() must still win the headersSent check, as it does in express.
208
214
  * @this {Walk}
215
+ * @param {RouteEntry|false} matched what the walk ended on, as the resolve receives it
209
216
  */
210
217
  function nativeDone(matched) {
211
218
  if (this.settled) {
@@ -238,6 +245,7 @@ function nativeDone(matched) {
238
245
  * an unhandled rejection. Deferred like the resolve, since every rejection used to reach the
239
246
  * handler's catch through an await.
240
247
  * @this {Walk}
248
+ * @param {unknown} err
241
249
  */
242
250
  function nativeFail(err) {
243
251
  if (this.settled) {
@@ -313,7 +321,7 @@ const PATH_PROPERTY = {
313
321
  const ABSORB_URL = Request.prototype._absorbUrlRewrite;
314
322
  const ABSORB_METHOD = Request.prototype._absorbMethodRewrite;
315
323
 
316
- const NO_PARAM_NAMES = [];
324
+ const NO_PARAM_NAMES = /** @type {string[]} */ ([]);
317
325
 
318
326
  /**
319
327
  * The parameter names a route captures with its own pattern.
@@ -636,7 +644,8 @@ function hasErrorMiddleware(router) {
636
644
  }
637
645
 
638
646
  /**
639
- *
647
+ * @param {unknown[]} handlers what a registration was given, flattened
648
+ * @param {string} [emptyMessage]
640
649
  */
641
650
  function checkHandlers(handlers, emptyMessage = "argument handler is required") {
642
651
  if (handlers.length === 0) {
package/src/router.js CHANGED
@@ -169,6 +169,7 @@ module.exports = class Router extends EventEmitter {
169
169
 
170
170
  this._paramCallbacks = new Map();
171
171
  this._mountpathCache = new Map();
172
+ /** @type {RouteEntry[]} */
172
173
  this._routes = [];
173
174
  // websocket routes, kept apart from the HTTP ones: µWS serves them itself and listen()
174
175
  // hands them over whole, mount paths and all
@@ -908,6 +909,7 @@ module.exports = class Router extends EventEmitter {
908
909
  */
909
910
  _handleError(err, handler, request, response) {
910
911
  if (handler) {
912
+ /** @param {unknown} [pass] */
911
913
  const next = (pass) => {
912
914
  delete request._error;
913
915
  delete request._errorKey;
@@ -1154,15 +1156,20 @@ module.exports = class Router extends EventEmitter {
1154
1156
  return new Promise((resolve) => {
1155
1157
  let index = 0;
1156
1158
  let name = "";
1159
+ /** @type {unknown} */
1157
1160
  let value;
1158
1161
  let entry;
1162
+ /** @type {Function[]} */
1159
1163
  let fns = [];
1160
1164
  let fnIndex = 0;
1161
1165
 
1162
1166
  // one parameter after the other, err being what the last one's callbacks ended with
1167
+ /** @param {unknown} [err] */
1163
1168
  const nextParam = (err) => {
1164
1169
  if (err) {
1165
- if (err !== "route") {
1170
+ // an error already in flight stays the one in flight: express reaches a mount's
1171
+ // param callbacks with it pending and goes on with next(layerError || err)
1172
+ if (err !== "route" && !req._error) {
1166
1173
  req._error = err;
1167
1174
  req._errorKey = route.routeKey;
1168
1175
  req._errorGroup = route.group;
@@ -1188,6 +1195,7 @@ module.exports = class Router extends EventEmitter {
1188
1195
  };
1189
1196
 
1190
1197
  // and one callback of the current parameter after the other
1198
+ /** @param {unknown} [err] */
1191
1199
  const nextCallback = (err) => {
1192
1200
  const fn = fns[fnIndex++];
1193
1201
  // read before the callback runs and again after it: one that rewrites
@@ -1248,6 +1256,13 @@ module.exports = class Router extends EventEmitter {
1248
1256
 
1249
1257
  /**
1250
1258
  * Resolves with the route that answered, or false when nothing matched.
1259
+ *
1260
+ * @param {Request} req
1261
+ * @param {Response} res
1262
+ * @param {number} [startIndex]
1263
+ * @param {RouteEntry[]} [routes]
1264
+ * @param {boolean} [skipCheck] take the route at the index without matching it, see Walk
1265
+ * @param {RouteEntry} [skipUntil] route to resume after when this chain runs out, see Walk
1251
1266
  * @returns {Promise<RouteEntry|false>}
1252
1267
  */
1253
1268
  _routeRequest(req, res, startIndex = 0, routes = this._routes, skipCheck = false, skipUntil) {
@@ -1366,11 +1381,16 @@ module.exports = class Router extends EventEmitter {
1366
1381
  // one map for the whole chain, because express builds one Route for it: a request answered
1367
1382
  // by the get() of an app.route() reads post() in its req.route.methods too
1368
1383
  const groupMethods = new NullObject();
1384
+ /** @type {(Omit<Layer, "route"> & {method: string|undefined})[]} express's Route#stack, see createRoute */
1369
1385
  const groupStack = [];
1370
1386
  // express hands back a Route, which carries these three beside the verb methods
1371
1387
  fns.path = path;
1372
1388
  fns.methods = groupMethods;
1373
1389
  fns.stack = groupStack;
1390
+ /**
1391
+ * @param {string} method
1392
+ * @param {unknown[]} callbacks
1393
+ */
1374
1394
  const inGroup = (method, callbacks) => {
1375
1395
  this._pendingGroup = group;
1376
1396
  this._pendingGroupMethods = groupMethods;
@@ -1384,9 +1404,9 @@ module.exports = class Router extends EventEmitter {
1384
1404
  }
1385
1405
  };
1386
1406
  for (const method of methods) {
1387
- fns[method] = (...callbacks) => inGroup(method, callbacks);
1407
+ fns[method] = (/** @type {unknown[]} */ ...callbacks) => inGroup(method, callbacks);
1388
1408
  }
1389
- fns.get = (...callbacks) => inGroup("GET", callbacks);
1409
+ fns.get = (/** @type {unknown[]} */ ...callbacks) => inGroup("GET", callbacks);
1390
1410
  return fns;
1391
1411
  }
1392
1412
 
@@ -1482,7 +1502,10 @@ module.exports = class Router extends EventEmitter {
1482
1502
  useRouterClass(module.exports);
1483
1503
 
1484
1504
  for (const method of methods) {
1485
- module.exports.prototype[method] = function (path, ...callbacks) {
1505
+ module.exports.prototype[method] = function (
1506
+ /** @type {string|RegExp|(string|RegExp)[]} */ path,
1507
+ /** @type {unknown[]} */ ...callbacks
1508
+ ) {
1486
1509
  return this.createRoute(method, path, this, ...callbacks);
1487
1510
  };
1488
1511
  }
package/src/testing.js CHANGED
@@ -134,7 +134,7 @@ function select(app, patterns, caller) {
134
134
  throw new TypeError(`${caller} needs a path, or a list of them, to check`);
135
135
  }
136
136
  const report = routeReport(app);
137
- const selected = [];
137
+ const selected = /** @type {ReturnType<typeof routeReport>} */ ([]);
138
138
  for (const pattern of wanted) {
139
139
  const matched = report.filter((entry) => names(entry, pattern));
140
140
  if (matched.length === 0) {