fulmine.js 5.19.3 → 5.19.5

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
@@ -34,11 +34,13 @@ const {
34
34
  validateHeaderName,
35
35
  validateHeaderValue,
36
36
  headerIsWritable,
37
- withDefaultCharset,
38
37
  withUtf8Charset,
39
38
  asStatError,
40
39
  httpError,
40
+ headersSentError,
41
+ applyWriteHead,
41
42
  contentTypeFor,
43
+ contentTypeSet,
42
44
  statTag,
43
45
  cachedStat,
44
46
  NullObject
@@ -47,6 +49,35 @@ const { isAbsolute } = require("path");
47
49
  const fs = require("fs");
48
50
  const Path = require("path");
49
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, named the
55
+ * way node names it: a primitive by type and value, an object by its constructor.
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
+ return err;
80
+ }
50
81
  const { sign } = require("cookie-signature");
51
82
  const ms = require("ms");
52
83
  const Socket = require("./socket.js");
@@ -75,10 +106,17 @@ const COALESCE_BELOW = 4 * 1024;
75
106
  const HIGH_WATERMARK = 128 * 1024;
76
107
  // the exact string json() writes, so send() can skip recomputing the charset on it
77
108
  const JSON_UTF8 = "application/json; charset=utf-8";
109
+
110
+ // The Keep-Alive every response is seeded with, kept as a constant so setHeader can tell it apart
111
+ // from one the application set itself.
112
+ const SEEDED_KEEP_ALIVE = "timeout=10";
78
113
  // send's ceiling for maxAge, one year in milliseconds. Anything larger is clamped to it rather
79
114
  // than written out, since a year is already longer than any cache will honour.
80
115
  const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
81
116
 
117
+ // what send takes as a range request: the bytes unit, checked on the header's text before parsing
118
+ const BYTES_RANGE = /^ *bytes=/;
119
+
82
120
  module.exports = class Response extends LazyWritable {
83
121
  /** @type {Socket|null} */
84
122
  #socket = null;
@@ -106,15 +144,21 @@ module.exports = class Response extends LazyWritable {
106
144
  /** Whether a flush is already booked for the end of this turn. */
107
145
  #flushBooked = false;
108
146
 
109
- /** @type {any} */
110
- #outHeaders = null;
147
+ /** Whether the status line and the headers have reached uWS, which only a body write does. */
148
+ #headOut = false;
111
149
 
112
150
  /**
113
- * Whether node's writeHead has run, which only the per-app subclass sets. _sendOptionsReply
114
- * refuses to write a second head over it, as node's setHeader does.
115
- * @type {boolean|undefined}
151
+ * The status line as writeHead settled it, which is the one the wire gets: node stores the
152
+ * head at writeHead, so a status set later never reaches the client.
153
+ * @type {number}
116
154
  */
117
- _headWritten;
155
+ #status = 200;
156
+
157
+ /** @type {string|undefined} */
158
+ #statusText = undefined;
159
+
160
+ /** @type {Response["headers"]|null} */
161
+ #outHeaders = null;
118
162
 
119
163
  /**
120
164
  * The request this response answers, linked so either reaches the other.
@@ -127,10 +171,9 @@ module.exports = class Response extends LazyWritable {
127
171
  * two that describe the connection, since every response carries them, and x-powered-by only
128
172
  * when the setting asks for it.
129
173
  *
130
- * @param {any} res the uWS response
131
- * @param {any} req the Request, already built. Loose because the per-app subclass in
132
- * application.js inherits this constructor and its own shape has to stay assignable
133
- * @param {any} app the application or router this request arrived at
174
+ * @param {import("uWebSockets.js").HttpResponse} res the uWS response
175
+ * @param {InstanceType<typeof import("./request.js")>} req the Request, already built
176
+ * @param {import("./application.js").Application} app the application this request arrived at
134
177
  */
135
178
  constructor(res, req, app) {
136
179
  super();
@@ -139,6 +182,7 @@ module.exports = class Response extends LazyWritable {
139
182
  // the order node's own Writable constructor lays down, so the hidden class is the one every
140
183
  // other stream in the process has, and node's init keeps this object when the state is
141
184
  // finally built
185
+ /** @type {Record<string, Function|undefined>} */
142
186
  this._events = {
143
187
  close: undefined,
144
188
  error: undefined,
@@ -176,7 +220,7 @@ module.exports = class Response extends LazyWritable {
176
220
  ? {}
177
221
  : {
178
222
  connection: "keep-alive",
179
- "keep-alive": "timeout=10"
223
+ "keep-alive": SEEDED_KEEP_ALIVE
180
224
  };
181
225
  // the client asked for the connection to be closed, and uWS closes it, so saying otherwise
182
226
  // would be telling the client something the transport contradicts. A declarative response
@@ -191,7 +235,7 @@ module.exports = class Response extends LazyWritable {
191
235
  this.body = undefined;
192
236
  // what was handed to uWS, kept so a caller asking for content-length after the fact can be
193
237
  // answered, see get(). Undefined until the response ends, and for one that sends no body
194
- /** @type {string|Buffer|undefined} */
238
+ /** @type {string|Buffer|Uint8Array|undefined} */
195
239
  this._sentBody = undefined;
196
240
  // false while the uWS route handler is still in its synchronous window, where uWS holds
197
241
  // the socket corked itself; the two uWS entry points flip it once that window closes
@@ -206,8 +250,7 @@ module.exports = class Response extends LazyWritable {
206
250
  //
207
251
  // The condition is the safety of it: a fresh response has no listeners, so both slots are
208
252
  // free, and anything else falls back to on(), which finds what this wrote.
209
- // cast because _events and _eventsCount are EventEmitter's own bookkeeping and have no type
210
- const self = /** @type {any} */ (this);
253
+ const self = this;
211
254
  const events = self._events;
212
255
  if (
213
256
  self._eventsCount === 0 &&
@@ -252,7 +295,7 @@ module.exports = class Response extends LazyWritable {
252
295
  * aborted uWS response is a use after free. writableEnded reads true after this, where node
253
296
  * leaves it false until end() is called.
254
297
  *
255
- * @param {any} [error] whatever the caller is destroying the response with
298
+ * @param {Error} [error] whatever the caller is destroying the response with
256
299
  * @returns {this}
257
300
  */
258
301
  destroy(error) {
@@ -282,7 +325,7 @@ module.exports = class Response extends LazyWritable {
282
325
  return;
283
326
  }
284
327
  this._pendingLinked = false;
285
- const pending = /** @type {any} */ (this)._pendingIn;
328
+ const pending = /** @type {{_pendingIn?: {head: Response|null}}} */ (this)._pendingIn;
286
329
  const prev = this._pendingPrev;
287
330
  const next = this._pendingNext;
288
331
  if (prev) {
@@ -337,7 +380,7 @@ module.exports = class Response extends LazyWritable {
337
380
  * over, as node's does; the request's `socket` is the same object and stays, so it comes
338
381
  * through here instead.
339
382
  *
340
- * @returns {any}
383
+ * @returns {Socket}
341
384
  */
342
385
  _socketShim() {
343
386
  if (!this.#socket) {
@@ -428,13 +471,15 @@ module.exports = class Response extends LazyWritable {
428
471
 
429
472
  this.writingChunk = true;
430
473
  this._res.cork(() => {
431
- if (!this.headersSent) {
432
- this.writeHead(this.statusCode);
474
+ if (!this.#headOut) {
475
+ if (!this.headersSent) {
476
+ this.writeHead(this.statusCode);
477
+ }
433
478
  // "unknown" and not the bare number: node writes that reason phrase for a code it
434
479
  // has no message for, so the raw status lines match. The default 200 with no
435
480
  // phrase is uWS's own head, byte for byte, so it is not written at all
436
- if (this.statusCode !== 200 || this.statusText !== undefined) {
437
- this._res.writeStatus(statusLine(this.statusCode, this.statusText));
481
+ if (this.#status !== 200 || this.#statusText !== undefined) {
482
+ this._res.writeStatus(statusLine(this.#status, this.#statusText));
438
483
  }
439
484
  this.writeHeaders(typeof chunk === "string");
440
485
  }
@@ -503,8 +548,9 @@ module.exports = class Response extends LazyWritable {
503
548
 
504
549
  /**
505
550
  * Sets the status and, optionally, a batch of headers, the way node does. The second argument
506
- * is either the status message or the headers, since node allows both shapes. Nothing is
507
- * written here despite the name: the headers go out when the body does.
551
+ * is either the status message or the headers, since node allows both shapes. The bytes go out
552
+ * with the body, but the head is settled here, as node's is: headersSent reads true from now
553
+ * on, what is set later throws, and a status written later never reaches the wire.
508
554
  *
509
555
  * Every header goes through setHeader and not through set. This is node's method, not
510
556
  * Express's: a content-type given here keeps the value it was given, where res.set would append
@@ -512,38 +558,33 @@ module.exports = class Response extends LazyWritable {
512
558
  * @sveltejs/adapter-node included, so the charset was added to pages nobody asked it for.
513
559
  *
514
560
  * @param {number} statusCode
515
- * @param {string|Record<string, any>|any[]} [statusMessage] the reason phrase, or the headers
516
- * @param {Record<string, any>|any[]} [headers]
561
+ * @param {string|import("http").OutgoingHttpHeaders|import("http").OutgoingHttpHeader[]} [statusMessage] the
562
+ * reason phrase, or the headers
563
+ * @param {import("http").OutgoingHttpHeaders|import("http").OutgoingHttpHeader[]} [headers]
517
564
  * @returns {this}
518
565
  */
519
566
  writeHead(statusCode, statusMessage, headers) {
567
+ if (this.headersSent) {
568
+ throw headersSentError("write");
569
+ }
520
570
  this.statusCode = statusCode;
521
- if (typeof statusMessage === "string") {
522
- this.statusText = statusMessage;
523
- }
524
- if (!headers) {
525
- if (!statusMessage) return this;
526
- // the two-argument shape, where what looked like a reason phrase is the headers. A
527
- // string reaching here was already taken as the phrase above and simply has no keys.
528
- headers = /** @type {Record<string, any>} */ (statusMessage);
529
- }
530
- if (Array.isArray(headers)) {
531
- // node takes a flat list here, name then value, and not a list of pairs. An odd length
532
- // is the caller's mistake and node names the argument in what it throws
533
- if (headers.length % 2 !== 0) {
534
- /** @type {NodeJS.ErrnoException} */
535
- const err = new TypeError(`The argument 'headers' is invalid. Received ${JSON.stringify(headers)}`);
536
- err.code = "ERR_INVALID_ARG_VALUE";
537
- throw err;
538
- }
539
- for (let i = 0; i < headers.length; i += 2) {
540
- this.setHeader(headers[i], headers[i + 1]);
571
+ let reason;
572
+ try {
573
+ reason = applyWriteHead(this, statusMessage, headers);
574
+ } catch (err) {
575
+ // node fixes the phrase before it reads the headers and keeps it, so a 500 after a
576
+ // writeHead that threw goes out as "500 OK"
577
+ if (!this.statusText) {
578
+ this.statusText = typeof statusMessage === "string" ? statusMessage : statuses.message[statusCode];
541
579
  }
542
- return this;
580
+ throw err;
543
581
  }
544
- for (const header in headers) {
545
- this.setHeader(header, headers[header]);
582
+ if (reason !== undefined) {
583
+ this.statusText = reason;
546
584
  }
585
+ this.#status = statusCode;
586
+ this.#statusText = this.statusText;
587
+ this.headersSent = true;
547
588
  return this;
548
589
  }
549
590
 
@@ -597,16 +638,19 @@ module.exports = class Response extends LazyWritable {
597
638
  }
598
639
  }
599
640
  this.headersSent = true;
641
+ this.#headOut = true;
600
642
  }
601
643
 
602
644
  /**
603
- * What node calls before writing a body when the caller never called writeHead. Here there is
604
- * nothing to flush, since the headers are written with the body, so this only fixes the status.
645
+ * What node calls before writing a body when the caller never called writeHead: it settles the
646
+ * head, and nothing is flushed, since the headers are written with the body. Not once the head
647
+ * is settled: the compression module calls this on the strength of node's _header, which this
648
+ * response does not keep, so the guard is here instead of there.
605
649
  */
606
650
  _implicitHeader() {
607
- // compatibility function
608
- // usually should send headers but this is useless for us
609
- this.writeHead(this.statusCode);
651
+ if (!this.headersSent) {
652
+ this.writeHead(this.statusCode);
653
+ }
610
654
  }
611
655
 
612
656
  /**
@@ -645,17 +689,38 @@ module.exports = class Response extends LazyWritable {
645
689
  }
646
690
 
647
691
  /**
648
- * @param {any} [data] the last body piece, or the callback in node's two-argument shape
649
- * @param {any} [cb]
692
+ * @param {string|Buffer|Uint8Array|null|(() => void)} [data] the last body piece, or the callback in
693
+ * node's one-argument shape
694
+ * @param {BufferEncoding|(() => void)} [encoding] how a string body is encoded, or the callback in
695
+ * node's two-argument shape
696
+ * @param {() => void} [cb]
650
697
  * @returns {this}
651
698
  */
652
- end(data, cb) {
699
+ end(data, encoding, cb) {
653
700
  if (typeof data === "function") {
654
701
  cb = data;
655
702
  data = undefined;
703
+ encoding = undefined;
704
+ } else if (typeof encoding === "function") {
705
+ cb = encoding;
706
+ encoding = undefined;
656
707
  }
657
708
  if (typeof cb !== "function") {
658
- cb = undefined; // silence the error?
709
+ cb = undefined;
710
+ }
711
+ // node refuses a chunk that is not a string, a Buffer or a Uint8Array, and writes it only
712
+ // `if (chunk)`, so end(0) sends nothing. A number reached uWS and answered nothing at all.
713
+ // The typeof goes first so a string body, which is nearly every body, leaves on it alone
714
+ if (typeof data !== "string" && data !== undefined && !(data instanceof Uint8Array)) {
715
+ if (data) {
716
+ throw invalidChunkError(data);
717
+ }
718
+ data = undefined;
719
+ }
720
+ // uWS takes a string as utf-8 and nothing else, so any other encoding is applied here, the
721
+ // way write() applies it: res.end(data, "binary") is how old code sends an image
722
+ if (typeof data === "string" && encoding !== undefined && encoding !== "utf8" && encoding !== "utf-8") {
723
+ data = Buffer.from(data, encoding);
659
724
  }
660
725
 
661
726
  if (this.writingChunk) {
@@ -667,7 +732,11 @@ module.exports = class Response extends LazyWritable {
667
732
  if (this.finished) {
668
733
  return this;
669
734
  }
670
- this.writeHead(this.statusCode);
735
+ // as node's end() calls _implicitHeader: not after an explicit writeHead, which settled the
736
+ // head already and told on-headers' listeners
737
+ if (!this.headersSent) {
738
+ this.writeHead(this.statusCode);
739
+ }
671
740
  // uWS holds the socket corked for the synchronous window of its route handler, and
672
741
  // cork inside cork is a passthrough: the wrapper and its closure are only paid once the
673
742
  // answer has outlived that window, which is what _corkNeeded records
@@ -683,23 +752,23 @@ module.exports = class Response extends LazyWritable {
683
752
  * The corked tail of end(): status, headers, body and the finish events. Split out so a
684
753
  * synchronous answer calls it straight, already inside uWS's own cork.
685
754
  *
686
- * @param {any} data the last body piece
687
- * @param {any} cb
755
+ * @param {string|Buffer|Uint8Array|null|undefined} data the last body piece
756
+ * @param {(() => void)|undefined} cb
688
757
  */
689
758
  _finish(data, cb) {
690
759
  // read before the head is written below, which is what sets the flag: what matters further
691
760
  // down is whether something had already committed the framing, a flushHeaders() or a first
692
761
  // res.write(), not whether this call is about to write the head itself
693
- const headWasAlreadyOut = this.headersSent;
694
- if (!this.headersSent) {
762
+ const headWasAlreadyOut = this.#headOut;
763
+ if (!this.#headOut) {
695
764
  // freshness is not decided here. node's end() knows nothing about conditional requests,
696
765
  // and Express answers 304 from send() and from sendFile(), each of which strips the
697
766
  // entity headers first. Deciding it here made res.end("body") answer 304 and drop the
698
767
  // body the caller had just written.
699
768
  // "unknown" for a code without a message, as node's status line has it. The default 200
700
769
  // with no phrase is not written at all: uWS emits the identical head on its own
701
- if (this.statusCode !== 200 || this.statusText !== undefined) {
702
- this._res.writeStatus(statusLine(this.statusCode, this.statusText));
770
+ if (this.#status !== 200 || this.#statusText !== undefined) {
771
+ this._res.writeStatus(statusLine(this.#status, this.#statusText));
703
772
  }
704
773
  this.writeHeaders(true);
705
774
  }
@@ -714,7 +783,7 @@ module.exports = class Response extends LazyWritable {
714
783
  const closeConnection = this.req._connectionClose === true;
715
784
  // 204 and 304 carry no body, so no Content-Length may describe one either; 1xx is the
716
785
  // third case, by range
717
- if (this.statusCode === 204 || this.statusCode === 304 || this.statusCode < 200) {
786
+ if (this.#status === 204 || this.#status === 304 || this.#status < 200) {
718
787
  // no body and no length describing one, whatever the caller passed. node decides
719
788
  // this the same way, from the status alone, so res.status(304).end("x") sends the
720
789
  // status and nothing else on either.
@@ -739,7 +808,7 @@ module.exports = class Response extends LazyWritable {
739
808
  if (this.req.method === "HEAD") {
740
809
  const length = Buffer.byteLength(data ?? "");
741
810
  this.headers["content-length"] = String(length);
742
- this._res.endWithoutBody(length.toString(), closeConnection);
811
+ this._res.endWithoutBody(length, closeConnection);
743
812
  } else {
744
813
  // remembered rather than measured: only a caller that asks for content-length pays
745
814
  // for it, and uWS is measuring the same bytes for the wire anyway
@@ -774,7 +843,8 @@ module.exports = class Response extends LazyWritable {
774
843
  */
775
844
  send(body) {
776
845
  if (this.headersSent) {
777
- throw new Error("Can't write body: Response was already sent");
846
+ // what express's send meets first once the head is out is setHeader's refusal
847
+ throw headersSentError("set");
778
848
  }
779
849
  // a typed array is bytes to send, not an object to serialise: res.send(new Uint8Array([104,
780
850
  // 101, 121])) is "hey" and not {"0":104,"1":101,"2":121}. Uint8Array and not every view
@@ -788,6 +858,16 @@ module.exports = class Response extends LazyWritable {
788
858
  // that happens to be empty: no content-type and no ETag for send(), both for send(null)
789
859
  // and send("").
790
860
  if (body === undefined) {
861
+ // still through freshness and the bodiless statuses, as every express send is: a 204
862
+ // answered with json(undefined) loses the type json had set
863
+ if (this.req.fresh) {
864
+ this.status(304);
865
+ }
866
+ if (this.statusCode === 204 || this.statusCode === 304) {
867
+ delete this.headers["content-type"];
868
+ delete this.headers["content-length"];
869
+ delete this.headers["transfer-encoding"];
870
+ }
791
871
  return this.end("");
792
872
  }
793
873
  // null is an object as far as Express's switch is concerned, so it becomes the empty
@@ -805,8 +885,15 @@ module.exports = class Response extends LazyWritable {
805
885
  return this.json(body);
806
886
  } else if (typeof body === "boolean") {
807
887
  return this.json(body);
808
- } else if (!isBuffer) {
809
- body = String(body);
888
+ } else if (!isBuffer && typeof body !== "string") {
889
+ // a symbol, a bigint or a function: express sizes it with byteLength or from(), and
890
+ // neither takes it, so what node throws is the answer. A string never gets here: it
891
+ // is the common body, and measuring it twice cost 157us per thousand requests
892
+ const generateETag = !this.headers["etag"] && typeof this.app._hot().etagFn === "function";
893
+ if (!generateETag && body.length < 1000) {
894
+ Buffer.byteLength(body, "utf8");
895
+ }
896
+ Buffer.from(body, "utf8");
810
897
  }
811
898
  if (typeof body === "string" && !isBuffer) {
812
899
  const contentType = this.headers["content-type"];
@@ -874,10 +961,11 @@ module.exports = class Response extends LazyWritable {
874
961
  * options position is the callback.
875
962
  *
876
963
  * Options: `root`, `maxAge`, `lastModified`, `headers`, `dotfiles` ("allow", "deny" or
877
- * "ignore"), `acceptRanges`, `cacheControl`, `immutable`, `etag` and `setHeaders`.
964
+ * "ignore"), `acceptRanges`, `cacheControl`, `immutable` and `etag`.
878
965
  *
879
966
  * @param {string} path
880
- * @param {import("./options").SendFileOptions} [options]
967
+ * @param {import("./options").SendFileOptions|((err?: Error) => void)} [options] or the callback in
968
+ * its place
881
969
  * @param {(err?: Error) => void} [callback] called once sent, or with the error
882
970
  */
883
971
  sendFile(path, options = new NullObject(), callback) {
@@ -890,7 +978,7 @@ module.exports = class Response extends LazyWritable {
890
978
  throw new TypeError("path must be a string to res.sendFile");
891
979
  }
892
980
  if (typeof options === "function") {
893
- callback = /** @type {any} */ (options);
981
+ callback = options;
894
982
  options = new NullObject();
895
983
  }
896
984
  if (!options) options = new NullObject();
@@ -900,7 +988,7 @@ module.exports = class Response extends LazyWritable {
900
988
  // The router's next and not the route's: express reports a file it could not serve past the
901
989
  // rest of the route, so a four argument handler inside the route never sees it
902
990
  const next = this.req._leaveRoute ?? this.req.next;
903
- const done = /** @type {(err?: any) => void} */ (
991
+ const done = /** @type {(err?: NodeJS.ErrnoException) => void} */ (
904
992
  (err) => {
905
993
  if (callback) return callback(err);
906
994
  if (err && err.code === "EISDIR") return next();
@@ -914,7 +1002,9 @@ module.exports = class Response extends LazyWritable {
914
1002
  // the branch that is already a number: ms() answers undefined for a duration it cannot
915
1003
  // read, and Number.isNaN(undefined) is false, so an unreadable string reached it as NaN
916
1004
  const maxAge = Number(
917
- typeof options.maxAge === "string" ? ms(/** @type {any} */ (options.maxAge)) : options.maxAge
1005
+ typeof options.maxAge === "string"
1006
+ ? ms(/** @type {import("ms").StringValue} */ (options.maxAge))
1007
+ : options.maxAge
918
1008
  );
919
1009
  options.maxAge = Number.isNaN(maxAge) ? 0 : Math.min(Math.max(0, maxAge), MAX_MAXAGE);
920
1010
  if (typeof options.lastModified === "undefined") {
@@ -1005,14 +1095,15 @@ module.exports = class Response extends LazyWritable {
1005
1095
  } catch (err) {
1006
1096
  // the fs error itself, carrying its errno and path, with send's status written on
1007
1097
  // it: a missing file is the request's 404, an unreadable one is the server's 500
1008
- return done(asStatError(/** @type {any} */ (err)));
1098
+ return done(asStatError(/** @type {import("./utils.js").HttpError} */ (err)));
1009
1099
  }
1010
1100
  if (stat.isDirectory()) {
1011
1101
  // Express reports a directory as an EISDIR with no status, because send tells it
1012
1102
  // apart from an error: it emits "directory", and res.sendFile has no listener for
1013
1103
  // one. So this is not a 404, and an error handler reading err.code sees the code
1014
1104
  // it expects. Without a callback, done() turns it into a plain next().
1015
- const err = /** @type {any} */ (new Error("EISDIR, read"));
1105
+ /** @type {NodeJS.ErrnoException} */
1106
+ const err = new Error("EISDIR, read");
1016
1107
  err.code = "EISDIR";
1017
1108
  return done(err);
1018
1109
  }
@@ -1040,8 +1131,10 @@ module.exports = class Response extends LazyWritable {
1040
1131
  this.setHeader(header, options.headers[header]);
1041
1132
  }
1042
1133
  }
1043
- if (options.setHeaders) {
1044
- options.setHeaders(/** @type {any} */ (this), fullpath, stat);
1134
+ // express.static's setHeaders, which res.sendFile does not take: express ignores it here,
1135
+ // so it travels under a name only the middleware writes
1136
+ if (options._setHeaders) {
1137
+ options._setHeaders(this, fullpath, stat);
1045
1138
  }
1046
1139
 
1047
1140
  // etag, from the stat and never from the app's "etag fn". send computes this itself with
@@ -1090,7 +1183,10 @@ module.exports = class Response extends LazyWritable {
1090
1183
 
1091
1184
  // range requests
1092
1185
  if (options.acceptRanges) {
1093
- if (this.req.headers.range) {
1186
+ // only the bytes unit, and send checks the header's text for it before parsing:
1187
+ // "items=0-1" or "Bytes=0-1" is not a range request, and answers the whole file
1188
+ const rangeHeader = this.req.headers.range;
1189
+ if (rangeHeader !== undefined && BYTES_RANGE.test(rangeHeader)) {
1094
1190
  // the branch above established the header is there, so range() cannot answer
1095
1191
  // the undefined it uses to mean "no Range header"
1096
1192
  let ranges = /** @type {ReturnType<typeof import("range-parser")>} */ (
@@ -1103,10 +1199,13 @@ module.exports = class Response extends LazyWritable {
1103
1199
  }
1104
1200
 
1105
1201
  if (ranges === -1) {
1106
- // the header goes on the response itself, as send writes it before raising
1107
- // the error, and the status stays on the error for the handler to apply
1108
- this.headers["content-range"] = `bytes */${len}`;
1109
- return done(httpError(416));
1202
+ // on the response as send writes it, and on the error too: the error page
1203
+ // drops the content headers and writes back only what the error carries
1204
+ const unsatisfiable = `bytes */${len}`;
1205
+ this.headers["content-range"] = unsatisfiable;
1206
+ const err = httpError(416);
1207
+ err.headers = { "Content-Range": unsatisfiable };
1208
+ return done(err);
1110
1209
  }
1111
1210
  if (ranges !== -2 && ranges.length === 1) {
1112
1211
  this.status(206);
@@ -1149,7 +1248,8 @@ module.exports = class Response extends LazyWritable {
1149
1248
  // ECONNABORTED to a callback and never to next(), so aborts stay out of
1150
1249
  // the error middleware.
1151
1250
  if (this.aborted && callback) {
1152
- const err = /** @type {any} */ (new Error("Request aborted"));
1251
+ /** @type {NodeJS.ErrnoException} */
1252
+ const err = new Error("Request aborted");
1153
1253
  err.code = "ECONNABORTED";
1154
1254
  callback(err);
1155
1255
  }
@@ -1267,7 +1367,8 @@ module.exports = class Response extends LazyWritable {
1267
1367
  * what a media type is. res.set does that, and is what Express code should use.
1268
1368
  *
1269
1369
  * @param {string} field
1270
- * @param {any} value an array sends the header once per entry
1370
+ * @param {number|string|readonly string[]|undefined} value an array sends the header once per
1371
+ * entry; undefined is refused, as node refuses it
1271
1372
  * @returns {this}
1272
1373
  * @throws {Error} once the headers have gone out
1273
1374
  * @throws {TypeError} if the name is not a token, the value is undefined, or the value holds a
@@ -1275,7 +1376,7 @@ module.exports = class Response extends LazyWritable {
1275
1376
  */
1276
1377
  setHeader(field, value) {
1277
1378
  if (this.headersSent) {
1278
- throw new Error("Cannot set headers after they are sent to the client");
1379
+ throw headersSentError("set");
1279
1380
  }
1280
1381
  // one Map hit for a name already validated and lowercased: middleware writes the same
1281
1382
  // constant names on every request. Insert-only after validation, so no bad name can enter
@@ -1300,6 +1401,14 @@ module.exports = class Response extends LazyWritable {
1300
1401
  // catch it
1301
1402
  const out = Array.isArray(value) ? value.map(String) : String(value);
1302
1403
  validateHeaderValue(field, out);
1404
+ // node writes Connection and Keep-Alive as a pair, and writes neither once the response has
1405
+ // set Connection itself, so the seeded Keep-Alive goes with it. Anything that opens an
1406
+ // event stream sets Connection: the MCP transport does, and so does every SSE library.
1407
+ if (key === "connection" && this.headers["keep-alive"] === SEEDED_KEEP_ALIVE) {
1408
+ // through an index signature: the seeded pair is not optional in what the constructor
1409
+ // infers, so a plain delete of it is a type error
1410
+ delete (/** @type {Record<string, string|string[]>} */ (this.headers)["keep-alive"]);
1411
+ }
1303
1412
  this.headers[key] = out;
1304
1413
  return this;
1305
1414
  }
@@ -1338,15 +1447,17 @@ module.exports = class Response extends LazyWritable {
1338
1447
  * @returns {void}
1339
1448
  */
1340
1449
  flushHeaders() {
1341
- if (this.headersSent || this.finished || this.aborted) {
1450
+ if (this.#headOut || this.finished || this.aborted) {
1342
1451
  return;
1343
1452
  }
1344
1453
  this._res.cork(() => {
1345
- this.writeHead(this.statusCode);
1454
+ if (!this.headersSent) {
1455
+ this.writeHead(this.statusCode);
1456
+ }
1346
1457
  // the same rule the chunked write path follows: uWS emits the 200 head itself, byte for
1347
1458
  // byte, so writing it again would only cost a crossing
1348
- if (this.statusCode !== 200 || this.statusText !== undefined) {
1349
- this._res.writeStatus(statusLine(this.statusCode, this.statusText));
1459
+ if (this.#status !== 200 || this.#statusText !== undefined) {
1460
+ this._res.writeStatus(statusLine(this.#status, this.#statusText));
1350
1461
  }
1351
1462
  // true, as the chunked path passes for a string chunk: what follows a flush is a body
1352
1463
  // written in pieces, and the framing has to be the one that allows them
@@ -1403,10 +1514,7 @@ module.exports = class Response extends LazyWritable {
1403
1514
  */
1404
1515
  #refuseInformationAfterHead() {
1405
1516
  if (this.headersSent) {
1406
- /** @type {NodeJS.ErrnoException} */
1407
- const err = new Error("Cannot write headers after they are sent to the client");
1408
- err.code = "ERR_HTTP_HEADERS_SENT";
1409
- throw err;
1517
+ throw headersSentError("write");
1410
1518
  }
1411
1519
  }
1412
1520
 
@@ -1439,7 +1547,7 @@ module.exports = class Response extends LazyWritable {
1439
1547
  * node's `assignSocket`, which the http server uses when a response is
1440
1548
  * handed a raw socket. There is no such socket here.
1441
1549
  *
1442
- * @param {any} [socket] node takes one here and there is none to take
1550
+ * @param {import("net").Socket} [socket] node takes one here and there is none to take
1443
1551
  * @returns {void}
1444
1552
  */
1445
1553
  assignSocket(socket) {}
@@ -1447,7 +1555,7 @@ module.exports = class Response extends LazyWritable {
1447
1555
  /**
1448
1556
  * node's `detachSocket`, which the http server uses when a response is
1449
1557
  * handed a raw socket. There is no such socket here.
1450
- * @param {any} [socket] node takes one here and there is none to take
1558
+ * @param {import("net").Socket} [socket] node takes one here and there is none to take
1451
1559
  * @returns {void}
1452
1560
  */
1453
1561
  detachSocket(socket) {}
@@ -1508,10 +1616,10 @@ module.exports = class Response extends LazyWritable {
1508
1616
  const key = name.toLowerCase();
1509
1617
  const current = this.headers[key];
1510
1618
  if (current === undefined) {
1511
- return this.setHeader(name, /** @type {any} */ (value));
1619
+ return this.setHeader(name, value);
1512
1620
  }
1513
- const merged = [].concat(/** @type {any} */ (current), /** @type {any} */ (value));
1514
- return this.setHeader(name, /** @type {any} */ (merged));
1621
+ const merged = /** @type {string[]} */ ([]).concat(current, value);
1622
+ return this.setHeader(name, merged);
1515
1623
  }
1516
1624
 
1517
1625
  /**
@@ -1526,15 +1634,15 @@ module.exports = class Response extends LazyWritable {
1526
1634
  if (typeof Headers === "function" && headers instanceof Headers) {
1527
1635
  for (const name of new Set([...headers.keys()])) {
1528
1636
  if (name === "set-cookie") {
1529
- this.setHeader(name, /** @type {any} */ (headers.getSetCookie()));
1637
+ this.setHeader(name, headers.getSetCookie());
1530
1638
  } else {
1531
- this.setHeader(name, /** @type {any} */ (headers.get(name)));
1639
+ this.setHeader(name, /** @type {string} */ (headers.get(name)));
1532
1640
  }
1533
1641
  }
1534
1642
  return this;
1535
1643
  }
1536
1644
  for (const [name, value] of headers) {
1537
- this.setHeader(name, /** @type {any} */ (value));
1645
+ this.setHeader(name, value);
1538
1646
  }
1539
1647
  return this;
1540
1648
  }
@@ -1551,8 +1659,8 @@ module.exports = class Response extends LazyWritable {
1551
1659
 
1552
1660
  /**
1553
1661
  * The Express name for set(), including the charset it adds to a content-type.
1554
- * @param {any} field a header name, or an object of them
1555
- * @param {any} [value] the header value, or nothing when the first argument is an object
1662
+ * @param {string|object} field a header name, or an object of them
1663
+ * @param {string|string[]} [value] the header value, or nothing when the first argument is an object
1556
1664
  * @returns {this}
1557
1665
  */
1558
1666
  header(field, value) {
@@ -1581,9 +1689,17 @@ module.exports = class Response extends LazyWritable {
1581
1689
  if (Array.isArray(out)) {
1582
1690
  throw new TypeError("Content-Type cannot be set to an Array");
1583
1691
  }
1584
- // every type the mime database gives a charset, not a list of three. The list was
1585
- // missing application/manifest+json among others, which Express does charset.
1586
- out = withDefaultCharset(out);
1692
+ // an extension becomes its media type here, charset included
1693
+ const resolved = contentTypeSet(out);
1694
+ if (resolved === false) {
1695
+ // what the mime database knows nothing about is stored as the false express
1696
+ // stores, so send() and json() read it as unset and write their own type.
1697
+ // Through setHeader first, for the checks, then the boolean over the string
1698
+ this.setHeader(field, "false");
1699
+ this.headers[name] = /** @type {any} */ (false);
1700
+ return this;
1701
+ }
1702
+ out = resolved;
1587
1703
  }
1588
1704
  // the name as it was written, not the lowercased one: setHeader lowercases it itself,
1589
1705
  // and it is the name that a refused header is reported by, which Express takes from
@@ -1644,6 +1760,9 @@ module.exports = class Response extends LazyWritable {
1644
1760
  * @param {string} field
1645
1761
  */
1646
1762
  removeHeader(field) {
1763
+ if (this.headersSent) {
1764
+ throw headersSentError("remove");
1765
+ }
1647
1766
  const key = field.toLowerCase();
1648
1767
  // the delete is a runtime call, and helmet removes a header most responses never carry
1649
1768
  if (key in this.headers) {
@@ -1677,12 +1796,13 @@ module.exports = class Response extends LazyWritable {
1677
1796
  * Renders a view and sends it. With a callback the result goes to the callback instead, and
1678
1797
  * nothing is sent. A function in the options position is taken as the callback.
1679
1798
  * @param {string} view view name
1680
- * @param {Record<string, any>} [options] locals for the view
1799
+ * @param {Record<string, any>|((err: Error|null, html?: string) => void)} [options] locals for the
1800
+ * view, or the callback in its place
1681
1801
  * @param {(err: Error|null, html?: string) => void} [callback]
1682
1802
  */
1683
1803
  render(view, options, callback) {
1684
1804
  if (typeof options === "function") {
1685
- callback = /** @type {any} */ (options);
1805
+ callback = /** @type {(err: Error|null, html?: string) => void} */ (options);
1686
1806
  options = {};
1687
1807
  }
1688
1808
  if (!options) {
@@ -1720,7 +1840,7 @@ module.exports = class Response extends LazyWritable {
1720
1840
  const opt = { ...(options ?? {}) }; // create a new ref because we change original object (https://github.com/dimdenGD/ultimate-express/issues/68)
1721
1841
  // cookie-parser hangs the secret on the request, so it is read off it rather than
1722
1842
  // declared here: without that middleware there is none, which is what this checks
1723
- const req = /** @type {any} */ (this.req);
1843
+ const req = /** @type {{secret?: string}} */ (this.req);
1724
1844
  if (opt.signed && !req.secret) {
1725
1845
  // the message has to read like this: it is the one Express throws, and it names the
1726
1846
  // thing that is actually missing rather than the library that noticed
@@ -1739,7 +1859,7 @@ module.exports = class Response extends LazyWritable {
1739
1859
  delete opt.maxAge;
1740
1860
  }
1741
1861
  if (opt.signed) {
1742
- val = "s:" + sign(val, req.secret);
1862
+ val = "s:" + sign(val, /** @type {string} */ (req.secret));
1743
1863
  }
1744
1864
 
1745
1865
  if (opt.path == null) {
@@ -1784,7 +1904,7 @@ module.exports = class Response extends LazyWritable {
1784
1904
  * Answers according to the Accept header, calling the handler whose key matches best. A
1785
1905
  * `default` key catches everything else; without one an unmatched request gets 406.
1786
1906
  * Sets Vary: Accept.
1787
- * @param {Record<string, any>} object handlers keyed by extension or mime type
1907
+ * @param {Record<string, Function>} object handlers keyed by extension or mime type
1788
1908
  * @returns {this}
1789
1909
  */
1790
1910
  format(object) {
@@ -1822,11 +1942,14 @@ module.exports = class Response extends LazyWritable {
1822
1942
  * @returns {this}
1823
1943
  */
1824
1944
  json(body) {
1945
+ const hot = this.app._hot();
1946
+ // serialised before the type is set, as express orders it: a body JSON.stringify refuses,
1947
+ // a BigInt for one, throws out of here with the response's headers as they were
1948
+ const json = stringify(body, hot.jsonReplacer, hot.jsonSpaces, hot.jsonEscape);
1825
1949
  if (!this.headers["content-type"]) {
1826
1950
  this.headers["content-type"] = JSON_UTF8;
1827
1951
  }
1828
- const hot = this.app._hot();
1829
- return this.send(stringify(body, hot.jsonReplacer, hot.jsonSpaces, hot.jsonEscape));
1952
+ return this.send(json);
1830
1953
  }
1831
1954
 
1832
1955
  /**
@@ -1878,7 +2001,7 @@ module.exports = class Response extends LazyWritable {
1878
2001
 
1879
2002
  /**
1880
2003
  * Adds to the Link header, one entry per key, the key being the rel.
1881
- * @param {Record<string, any>} links rel to url
2004
+ * @param {Record<string, string>} links rel to url
1882
2005
  * @returns {this}
1883
2006
  */
1884
2007
  links(links) {
@@ -1981,7 +2104,8 @@ module.exports = class Response extends LazyWritable {
1981
2104
 
1982
2105
  /**
1983
2106
  * Sets Content-Type. An extension is looked up as a mime type and gets a charset; anything
1984
- * containing a slash is used as written. Also available as `contentType()`.
2107
+ * containing a slash is used as written. An extension the database does not know falls back
2108
+ * to octet-stream here, which res.set does not do. Also available as `contentType()`.
1985
2109
  * @param {string} type
1986
2110
  * @returns {this}
1987
2111
  */
@@ -2001,7 +2125,7 @@ module.exports = class Response extends LazyWritable {
2001
2125
  vary(field) {
2002
2126
  // the vary package decides: it throws when there is no field at all, and does nothing at
2003
2127
  // all for an empty list, which is not the same thing and used to be refused here as well
2004
- vary(/** @type {any} */ (this), field);
2128
+ vary(/** @type {import("http").ServerResponse} */ (/** @type {unknown} */ (this)), field);
2005
2129
  return this;
2006
2130
  }
2007
2131
 
@@ -2036,4 +2160,5 @@ module.exports = class Response extends LazyWritable {
2036
2160
 
2037
2161
  // res.contentType is res.type under express's other name. On the prototype rather than an instance
2038
2162
  // field, which wrote one own property per response in the constructor.
2039
- /** @type {any} */ (module.exports.prototype).contentType = module.exports.prototype.type;
2163
+ /** @type {{contentType?: typeof module.exports.prototype.type}} */ (module.exports.prototype).contentType =
2164
+ module.exports.prototype.type;