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/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
- return this.destroy(err);
485
+ this.destroy(err);
486
+ return;
482
487
  }
483
488
  if (this.finished) {
484
489
  const err = new Error("Response already finished");
485
- return this.destroy(err);
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.once("drain", () => {
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(data, cb));
745
+ this._res.cork(() => this._finish(chunk, cb));
727
746
  } else {
728
- this._finish(data, cb);
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
- const length = Buffer.byteLength(data ?? "");
774
- this.headers["content-length"] = String(length);
775
- this._res.endWithoutBody(length, closeConnection);
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|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
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 && body.length < 1000) {
843
- Buffer.byteLength(body, "utf8");
876
+ if (!generateETag && unsendable.length < 1000) {
877
+ Buffer.byteLength(unsendable, "utf8");
844
878
  }
845
- Buffer.from(body, "utf8");
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
- for (const header in field) {
1525
- 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]);
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(/** @type {number} */ (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[status]}. Redirecting to ${address}`;
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[status]}. Redirecting to ${escapeHtml(address)}</p>`;
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 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
  ) {
@@ -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}
@@ -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
- unwanted[field] = false;
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) {