fulmine.js 5.19.4 → 5.19.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -989,6 +989,10 @@ suite, so what is compared is what their own build produces.
989
989
  - ✅ [Apollo Server](https://www.apollographql.com/docs/apollo-server) through
990
990
  [`@as-integrations/express5`](https://www.npmjs.com/package/@as-integrations/express5)
991
991
  - ✅ [tRPC](https://trpc.io) through `@trpc/server/adapters/express`
992
+ - ✅ [MCP](https://modelcontextprotocol.io) through
993
+ [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) on the
994
+ Streamable HTTP transport, with the body read off the stream or handed over by `express.json()`.
995
+ Runnable: [`examples/mcp.js`](./examples/mcp.js)
992
996
  - ✅ [Angular SSR](#angular-ssr), which is an ordinary Express `server.ts` plus one line of build
993
997
  configuration
994
998
 
@@ -1010,10 +1014,11 @@ Any Express view engine should work. Here's list of engines we include in our te
1010
1014
  ## Examples
1011
1015
 
1012
1016
  [`examples/`](./examples/README.md) has one runnable file per thing this does that Express does not:
1013
- the cluster option, `app.ws()`, socket.io through `attachApp`, the pre-compressed twins,
1014
- `express.compression()`, `express.serverTiming()`, TLS through `uwsOptions`, the PROXY protocol,
1015
- what `listen()` decided about each route, and the app answering as an `http.Server`. What an
1016
- Express application already does is documented by Express and is not repeated there.
1017
+ the cluster option, `app.ws()`, socket.io through `attachApp`, an MCP server on the official SDK,
1018
+ the pre-compressed twins, `express.compression()`, `express.serverTiming()`, TLS through
1019
+ `uwsOptions`, the PROXY protocol, what `listen()` decided about each route, and the app answering as
1020
+ an `http.Server`. What an Express application already does is documented by Express and is not
1021
+ repeated there.
1017
1022
 
1018
1023
  ```sh
1019
1024
  cd examples
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.19.4",
3
+ "version": "5.19.6",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -18,7 +18,7 @@ limitations under the License.
18
18
  */
19
19
 
20
20
  const acorn = require("acorn");
21
- const { stringify, withDefaultCharset, withUtf8Charset, contentTypeFor } = require("./utils.js");
21
+ const { stringify, contentTypeSet, withUtf8Charset, contentTypeFor } = require("./utils.js");
22
22
  // H3App, DeclarativeResponse and _cfg exist at runtime but are missing from the .d.ts the
23
23
  // package ships, so the module is read through a loose alias
24
24
  const uWS = require("uWebSockets.js");
@@ -250,10 +250,16 @@ function readStatusAndHeaders(callExprs, headers) {
250
250
 
251
251
  for (let [header, value] of pairs) {
252
252
  const name = String(header).toLowerCase();
253
- // res.set adds a charset to a content-type, res.setHeader does not: setHeader
254
- // is node's and node does not know what a media type is
253
+ // res.set resolves a content-type through the mime database, res.setHeader does
254
+ // not: setHeader is node's and node does not know what a media type is
255
255
  if (call.obj.propertyName !== "setHeader" && name === "content-type") {
256
- value = withDefaultCharset(value);
256
+ const resolved = contentTypeSet(String(value));
257
+ if (resolved === false) {
258
+ // res.set stores false for it and the body method writes its own type
259
+ // instead; left to the ordinary path rather than worked out twice here
260
+ return null;
261
+ }
262
+ value = resolved;
257
263
  }
258
264
  const index = headers.findIndex((entry) => String(entry[0]).toLowerCase() === name);
259
265
  if (index === -1) {
@@ -823,10 +829,10 @@ module.exports = function compileDeclarative(cb, app) {
823
829
  if (!connection && advertise) {
824
830
  decRes = decRes.writeHeader("connection", "keep-alive");
825
831
  }
826
- // not when the handler is closing: Keep-Alive describes a connection that stays open, and
827
- // the ordinary path leaves it out for the same reason
828
- const closing = typeof connection?.[1] === "string" && connection[1].toLowerCase() === "close";
829
- if (advertise && !closing && !headers.some((header) => header[0].toLowerCase() === "keep-alive")) {
832
+ // not when the route wrote its own Connection, whatever it says: node writes the two as a
833
+ // pair and writes neither once the response has set Connection, and the ordinary path
834
+ // leaves it out for the same reason
835
+ if (advertise && !connection && !headers.some((header) => header[0].toLowerCase() === "keep-alive")) {
830
836
  decRes = decRes.writeHeader("keep-alive", "timeout=10");
831
837
  }
832
838
 
@@ -810,6 +810,53 @@ function serveStatic(root, options) {
810
810
  };
811
811
  }
812
812
 
813
+ /**
814
+ * A zlib throw as the 400 body-parser answers a corrupt body with. zlib reports it twice, and the
815
+ * 'error' a tick later would land on nothing and end the process, so the listener goes back on.
816
+ *
817
+ * @param {Inflater} inflate
818
+ * @param {HttpError} err what inflate.process threw
819
+ * @returns {HttpError} the same error, carrying its status
820
+ */
821
+ function inflateError(inflate, err) {
822
+ inflate.instance?.on?.("error", () => {});
823
+ err.status = 400;
824
+ err.statusCode = 400;
825
+ err.expose = true;
826
+ return err;
827
+ }
828
+
829
+ /**
830
+ * What a Content-Encoding means here: the decompressor to run, or the 415 it is refused with. An
831
+ * empty body is judged the same way, so both callers share this.
832
+ *
833
+ * @param {string|undefined} rawContentEncoding
834
+ * @param {any} options the parser's options, read loosely: only inflate is looked at
835
+ * @returns {{inflate?: Inflater, error?: HttpError}}
836
+ */
837
+ function encodingFor(rawContentEncoding, options) {
838
+ if (!options.inflate) {
839
+ const contentEncoding = (rawContentEncoding || "identity").toLowerCase();
840
+ if (contentEncoding !== "identity") {
841
+ return {
842
+ error: bodyError("content encoding unsupported", 415, "encoding.unsupported", {
843
+ encoding: contentEncoding
844
+ })
845
+ };
846
+ }
847
+ return {};
848
+ }
849
+ const inflate = createInflate(rawContentEncoding);
850
+ if (inflate === false) {
851
+ return {
852
+ error: bodyError('unsupported content encoding "' + rawContentEncoding + '"', 415, "encoding.unsupported", {
853
+ encoding: rawContentEncoding
854
+ })
855
+ };
856
+ }
857
+ return { inflate };
858
+ }
859
+
813
860
  /**
814
861
  * The decompressor for a Content-Encoding, or undefined when the body is not compressed. An
815
862
  * encoding nobody knows throws, since decoding it wrong is worse than refusing.
@@ -986,8 +1033,11 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
986
1033
  }
987
1034
  }
988
1035
 
989
- // the charset is settled before anything is read, as body-parser settles it: a bad one
990
- // answers 415 even for an empty body, and before the verify hook can run
1036
+ // The charset is settled before anything is read, as body-parser settles it: a bad one
1037
+ // answers 415 even for an empty body, and before the verify hook can run. Its two
1038
+ // halves sit on either side of the encoding, which is the order body-parser reads
1039
+ // them in: the charset this parser accepts at all, then the Content-Encoding, then
1040
+ // whether iconv knows the charset.
991
1041
  let encoding;
992
1042
  if (charsetPolicy) {
993
1043
  encoding = charsetOf(type) ?? defaultCharset;
@@ -997,9 +1047,16 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
997
1047
  ) {
998
1048
  return next(charsetError(encoding));
999
1049
  }
1000
- if (!BUFFER_CHARSETS.has(encoding) && !loadIconv().encodingExists(encoding)) {
1001
- return next(charsetError(encoding));
1002
- }
1050
+ }
1051
+
1052
+ const encoded = encodingFor(req._rawHeader("content-encoding"), options);
1053
+ if (encoded.error) {
1054
+ return next(encoded.error);
1055
+ }
1056
+ const inflate = encoded.inflate;
1057
+
1058
+ if (encoding !== undefined && !BUFFER_CHARSETS.has(encoding) && !loadIconv().encodingExists(encoding)) {
1059
+ return next(charsetError(encoding));
1003
1060
  }
1004
1061
 
1005
1062
  // an empty body still has to produce this parser's empty value the way express does -
@@ -1008,15 +1065,28 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1008
1065
  // and the verify hook still runs first: webhook signature checks rely on that
1009
1066
  if (lengthNumber === 0) {
1010
1067
  req.bodyRead = true;
1011
- const empty = Buffer.alloc(0);
1068
+ /** @type {Buffer<ArrayBufferLike>} what the parser is handed: zlib's tail is wider */
1069
+ let empty = Buffer.alloc(0);
1070
+ if (inflate) {
1071
+ // nothing to inflate is a stream cut short for zlib, and body-parser answers
1072
+ // that with a 400 rather than with the empty value
1073
+ try {
1074
+ empty = inflate.process(EMPTY_BUFFER, inflate._finishFlag);
1075
+ } catch (e) {
1076
+ return next(inflateError(inflate, /** @type {HttpError} */ (e)));
1077
+ }
1078
+ }
1012
1079
  if (!runVerify(req, res, next, options, empty, encoding)) {
1013
1080
  return;
1014
1081
  }
1015
1082
  return beforeReturn(req, res, next, options, empty, encoding);
1016
1083
  }
1017
1084
 
1018
- // skip reading too large body; NaN compares false, so no declared length passes
1019
- if (lengthNumber > limit) {
1085
+ // skip reading too large body; NaN compares false, so no declared length passes.
1086
+ // Not while inflating: content-length counts the compressed bytes and the limit is
1087
+ // about the ones that come out, which body-parser says by leaving the length unset.
1088
+ // The limit is still enforced per chunk as they inflate, see keepChunk
1089
+ if (!inflate && lengthNumber > limit) {
1020
1090
  return next(
1021
1091
  bodyError("request entity too large", 413, "entity.too.large", {
1022
1092
  expected: lengthNumber,
@@ -1039,33 +1109,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1039
1109
  return next();
1040
1110
  }
1041
1111
 
1042
- /** @type {Inflater|false|undefined} */
1043
- let inflate;
1044
1112
  let totalSize = 0;
1045
- const rawContentEncoding = req._rawHeader("content-encoding");
1046
- const contentEncoding = (rawContentEncoding || "identity").toLowerCase();
1047
- if (!options.inflate && contentEncoding !== "identity") {
1048
- return next(
1049
- bodyError("content encoding unsupported", 415, "encoding.unsupported", {
1050
- encoding: contentEncoding
1051
- })
1052
- );
1053
- }
1054
- if (options.inflate) {
1055
- inflate = createInflate(rawContentEncoding);
1056
- if (inflate === false) {
1057
- return next(
1058
- bodyError(
1059
- 'unsupported content encoding "' + rawContentEncoding + '"',
1060
- 415,
1061
- "encoding.unsupported",
1062
- {
1063
- encoding: rawContentEncoding
1064
- }
1065
- )
1066
- );
1067
- }
1068
- }
1069
1113
 
1070
1114
  // From here the body really gets read, and uWS delivers it on native callbacks that
1071
1115
  // carry no async context, so this is the one continuation that has to be bound: an
@@ -1137,24 +1181,16 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
1137
1181
  let finished = false;
1138
1182
 
1139
1183
  /**
1140
- * A zlib throw becomes the 400 body-parser answers a corrupt body with.
1141
- *
1142
- * zlib reports it twice: process() throws, and the stream emits 'error' a tick later.
1143
- * fast-zlib removes its own listeners on the way out, so that second one lands on
1144
- * nothing, and an unhandled 'error' event ends the process. The listener goes on after
1145
- * the throw, since process() would have removed it.
1184
+ * A zlib throw becomes the 400 body-parser answers a corrupt body with, and what was
1185
+ * kept of the body goes.
1146
1186
  *
1147
1187
  * @param {HttpError} err what inflate.process threw
1148
1188
  */
1149
1189
  function failInflate(err) {
1150
- /** @type {Inflater} */ (inflate).instance?.on?.("error", () => {});
1151
1190
  finished = true;
1152
1191
  abs.length = 0;
1153
1192
  target = null;
1154
- err.status = 400;
1155
- err.statusCode = 400;
1156
- err.expose = true;
1157
- next(err);
1193
+ next(inflateError(/** @type {Inflater} */ (inflate), err));
1158
1194
  }
1159
1195
 
1160
1196
  /**
@@ -57,7 +57,10 @@ for (const s of [
57
57
  "gzip",
58
58
  "br",
59
59
  "deflate",
60
- "zstd"
60
+ "zstd",
61
+ // res.set("Content-Type", x) stores false when the mime database knows nothing about x, the
62
+ // way express does, and the lookup below turns that back into the bytes node writes for it
63
+ "false"
61
64
  ]) {
62
65
  HEADER_VALUE_BUF[s] = Buffer.from(s);
63
66
  }
package/src/response.js CHANGED
@@ -34,13 +34,13 @@ const {
34
34
  validateHeaderName,
35
35
  validateHeaderValue,
36
36
  headerIsWritable,
37
- withDefaultCharset,
38
37
  withUtf8Charset,
39
38
  asStatError,
40
39
  httpError,
41
40
  headersSentError,
42
41
  applyWriteHead,
43
42
  contentTypeFor,
43
+ contentTypeSet,
44
44
  statTag,
45
45
  cachedStat,
46
46
  NullObject
@@ -49,6 +49,41 @@ 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, 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
+ // node stamps the code into the stack's first line and then puts the name back, so err.name
80
+ // reads TypeError while the error page, which prints the stack, shows the bracketed form
81
+ err.name = "TypeError [ERR_INVALID_ARG_TYPE]";
82
+ void err.stack;
83
+ // through the index signature: name is not optional on Error, so a plain delete is a type error
84
+ delete (/** @type {Record<string, unknown>} */ (/** @type {unknown} */ (err)).name);
85
+ return err;
86
+ }
52
87
  const { sign } = require("cookie-signature");
53
88
  const ms = require("ms");
54
89
  const Socket = require("./socket.js");
@@ -77,6 +112,10 @@ const COALESCE_BELOW = 4 * 1024;
77
112
  const HIGH_WATERMARK = 128 * 1024;
78
113
  // the exact string json() writes, so send() can skip recomputing the charset on it
79
114
  const JSON_UTF8 = "application/json; charset=utf-8";
115
+
116
+ // The Keep-Alive every response is seeded with, kept as a constant so setHeader can tell it apart
117
+ // from one the application set itself.
118
+ const SEEDED_KEEP_ALIVE = "timeout=10";
80
119
  // send's ceiling for maxAge, one year in milliseconds. Anything larger is clamped to it rather
81
120
  // than written out, since a year is already longer than any cache will honour.
82
121
  const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
@@ -187,7 +226,7 @@ module.exports = class Response extends LazyWritable {
187
226
  ? {}
188
227
  : {
189
228
  connection: "keep-alive",
190
- "keep-alive": "timeout=10"
229
+ "keep-alive": SEEDED_KEEP_ALIVE
191
230
  };
192
231
  // the client asked for the connection to be closed, and uWS closes it, so saying otherwise
193
232
  // would be telling the client something the transport contradicts. A declarative response
@@ -535,7 +574,17 @@ module.exports = class Response extends LazyWritable {
535
574
  throw headersSentError("write");
536
575
  }
537
576
  this.statusCode = statusCode;
538
- const reason = applyWriteHead(this, statusMessage, headers);
577
+ let reason;
578
+ try {
579
+ reason = applyWriteHead(this, statusMessage, headers);
580
+ } catch (err) {
581
+ // node fixes the phrase before it reads the headers and keeps it, so a 500 after a
582
+ // writeHead that threw goes out as "500 OK"
583
+ if (!this.statusText) {
584
+ this.statusText = typeof statusMessage === "string" ? statusMessage : statuses.message[statusCode];
585
+ }
586
+ throw err;
587
+ }
539
588
  if (reason !== undefined) {
540
589
  this.statusText = reason;
541
590
  }
@@ -665,6 +714,15 @@ module.exports = class Response extends LazyWritable {
665
714
  if (typeof cb !== "function") {
666
715
  cb = undefined;
667
716
  }
717
+ // node refuses a chunk that is not a string, a Buffer or a Uint8Array, and writes it only
718
+ // `if (chunk)`, so end(0) sends nothing. A number reached uWS and answered nothing at all.
719
+ // The typeof goes first so a string body, which is nearly every body, leaves on it alone
720
+ if (typeof data !== "string" && data !== undefined && !(data instanceof Uint8Array)) {
721
+ if (data) {
722
+ throw invalidChunkError(data);
723
+ }
724
+ data = undefined;
725
+ }
668
726
  // uWS takes a string as utf-8 and nothing else, so any other encoding is applied here, the
669
727
  // way write() applies it: res.end(data, "binary") is how old code sends an image
670
728
  if (typeof data === "string" && encoding !== undefined && encoding !== "utf8" && encoding !== "utf-8") {
@@ -790,6 +848,25 @@ module.exports = class Response extends LazyWritable {
790
848
  * @returns {this}
791
849
  */
792
850
  send(body) {
851
+ // undefined means nothing was passed, and Express treats that differently from a value
852
+ // that happens to be empty: no content-type and no ETag for send(), both for send(null)
853
+ // and send(""). It writes no header for it either, so it does not refuse a head that has
854
+ // gone out: after a res.write(), express answers this and we hung up on it.
855
+ if (body === undefined) {
856
+ if (!this.headersSent) {
857
+ // freshness and the bodiless statuses, as every express send goes through: a 204
858
+ // answered with json(undefined) loses the type json had set
859
+ if (this.req.fresh) {
860
+ this.status(304);
861
+ }
862
+ if (this.statusCode === 204 || this.statusCode === 304) {
863
+ delete this.headers["content-type"];
864
+ delete this.headers["content-length"];
865
+ delete this.headers["transfer-encoding"];
866
+ }
867
+ }
868
+ return this.end("");
869
+ }
793
870
  if (this.headersSent) {
794
871
  // what express's send meets first once the head is out is setHeader's refusal
795
872
  throw headersSentError("set");
@@ -802,12 +879,6 @@ module.exports = class Response extends LazyWritable {
802
879
  body = Buffer.from(body.buffer, body.byteOffset, body.byteLength);
803
880
  }
804
881
  const isBuffer = Buffer.isBuffer(body);
805
- // undefined means nothing was passed, and Express treats that differently from a value
806
- // that happens to be empty: no content-type and no ETag for send(), both for send(null)
807
- // and send("").
808
- if (body === undefined) {
809
- return this.end("");
810
- }
811
882
  // null is an object as far as Express's switch is concerned, so it becomes the empty
812
883
  // string without ever reaching the branch that gives a string its content-type. It still
813
884
  // earns an ETag. send("") takes the string branch and does get one.
@@ -823,8 +894,15 @@ module.exports = class Response extends LazyWritable {
823
894
  return this.json(body);
824
895
  } else if (typeof body === "boolean") {
825
896
  return this.json(body);
826
- } else if (!isBuffer) {
827
- body = String(body);
897
+ } else if (!isBuffer && typeof body !== "string") {
898
+ // a symbol, a bigint or a function: express sizes it with byteLength or from(), and
899
+ // neither takes it, so what node throws is the answer. A string never gets here: it
900
+ // is the common body, and measuring it twice cost 157us per thousand requests
901
+ const generateETag = !this.headers["etag"] && typeof this.app._hot().etagFn === "function";
902
+ if (!generateETag && body.length < 1000) {
903
+ Buffer.byteLength(body, "utf8");
904
+ }
905
+ Buffer.from(body, "utf8");
828
906
  }
829
907
  if (typeof body === "string" && !isBuffer) {
830
908
  const contentType = this.headers["content-type"];
@@ -1130,10 +1208,13 @@ module.exports = class Response extends LazyWritable {
1130
1208
  }
1131
1209
 
1132
1210
  if (ranges === -1) {
1133
- // the header goes on the response itself, as send writes it before raising
1134
- // the error, and the status stays on the error for the handler to apply
1135
- this.headers["content-range"] = `bytes */${len}`;
1136
- return done(httpError(416));
1211
+ // on the response as send writes it, and on the error too: the error page
1212
+ // drops the content headers and writes back only what the error carries
1213
+ const unsatisfiable = `bytes */${len}`;
1214
+ this.headers["content-range"] = unsatisfiable;
1215
+ const err = httpError(416);
1216
+ err.headers = { "Content-Range": unsatisfiable };
1217
+ return done(err);
1137
1218
  }
1138
1219
  if (ranges !== -2 && ranges.length === 1) {
1139
1220
  this.status(206);
@@ -1329,6 +1410,14 @@ module.exports = class Response extends LazyWritable {
1329
1410
  // catch it
1330
1411
  const out = Array.isArray(value) ? value.map(String) : String(value);
1331
1412
  validateHeaderValue(field, out);
1413
+ // node writes Connection and Keep-Alive as a pair, and writes neither once the response has
1414
+ // set Connection itself, so the seeded Keep-Alive goes with it. Anything that opens an
1415
+ // event stream sets Connection: the MCP transport does, and so does every SSE library.
1416
+ if (key === "connection" && this.headers["keep-alive"] === SEEDED_KEEP_ALIVE) {
1417
+ // through an index signature: the seeded pair is not optional in what the constructor
1418
+ // infers, so a plain delete of it is a type error
1419
+ delete (/** @type {Record<string, string|string[]>} */ (this.headers)["keep-alive"]);
1420
+ }
1332
1421
  this.headers[key] = out;
1333
1422
  return this;
1334
1423
  }
@@ -1609,9 +1698,17 @@ module.exports = class Response extends LazyWritable {
1609
1698
  if (Array.isArray(out)) {
1610
1699
  throw new TypeError("Content-Type cannot be set to an Array");
1611
1700
  }
1612
- // every type the mime database gives a charset, not a list of three. The list was
1613
- // missing application/manifest+json among others, which Express does charset.
1614
- out = withDefaultCharset(out);
1701
+ // an extension becomes its media type here, charset included
1702
+ const resolved = contentTypeSet(out);
1703
+ if (resolved === false) {
1704
+ // what the mime database knows nothing about is stored as the false express
1705
+ // stores, so send() and json() read it as unset and write their own type.
1706
+ // Through setHeader first, for the checks, then the boolean over the string
1707
+ this.setHeader(field, "false");
1708
+ this.headers[name] = /** @type {any} */ (false);
1709
+ return this;
1710
+ }
1711
+ out = resolved;
1615
1712
  }
1616
1713
  // the name as it was written, not the lowercased one: setHeader lowercases it itself,
1617
1714
  // and it is the name that a refused header is reported by, which Express takes from
@@ -2016,7 +2113,8 @@ module.exports = class Response extends LazyWritable {
2016
2113
 
2017
2114
  /**
2018
2115
  * Sets Content-Type. An extension is looked up as a mime type and gets a charset; anything
2019
- * containing a slash is used as written. Also available as `contentType()`.
2116
+ * containing a slash is used as written. An extension the database does not know falls back
2117
+ * to octet-stream here, which res.set does not do. Also available as `contentType()`.
2020
2118
  * @param {string} type
2021
2119
  * @returns {this}
2022
2120
  */
package/src/router.js CHANGED
@@ -930,13 +930,21 @@ module.exports = class Router extends EventEmitter {
930
930
  }
931
931
  return;
932
932
  }
933
- if (response.statusCode === 200) {
934
- // the status the error carries, as express's own final handler reads it: a body that
935
- // was too large or a request cut short is the client's 4xx, not a 500 from here
936
- const status = err?.status ?? err?.statusCode;
937
- response.statusCode = Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500;
933
+ // the status express's final handler picks: the error's own when it is an error status,
934
+ // else the response's when that is one, else 500
935
+ const own = [err?.status, err?.statusCode].find((s) => typeof s === "number" && s >= 400 && s < 600);
936
+ let carried;
937
+ if (own !== undefined) {
938
+ response.statusCode = own;
939
+ // only with a status of its own does the error's headers go out, and only after the
940
+ // page drops the content ones: a 416 carries the Content-Range it wants kept
941
+ if (err.headers && typeof err.headers === "object") {
942
+ carried = err.headers;
943
+ }
944
+ } else if (!(response.statusCode >= 400 && response.statusCode <= 599)) {
945
+ response.statusCode = 500;
938
946
  }
939
- this._sendErrorPage(request, response, err, true);
947
+ this._sendErrorPage(request, response, err, true, carried);
940
948
  }
941
949
 
942
950
  /**
@@ -1390,13 +1398,21 @@ module.exports = class Router extends EventEmitter {
1390
1398
  * @param {Response} response
1391
1399
  * @param {unknown} err whatever was thrown, which need not be an Error
1392
1400
  * @param {boolean} [checkEnv] whether production should redact it
1401
+ * @param {Record<string, any>} [carried] the headers the error asked for, written last
1393
1402
  */
1394
- _sendErrorPage(request, response, err, checkEnv = false) {
1403
+ _sendErrorPage(request, response, err, checkEnv = false, carried = undefined) {
1395
1404
  err = this._generateErrorPage(err, response.statusCode, checkEnv);
1396
1405
  request.noEtag = true;
1397
1406
  // a header that cannot be written is what brought the request here in the first place when
1398
1407
  // the throw came out of the flush, and writing it again would throw with nobody left
1399
1408
  response._dropUnwritableHeaders();
1409
+ // the content headers a handler set before failing describe a body that is not this one
1410
+ response.removeHeader("Content-Encoding");
1411
+ response.removeHeader("Content-Language");
1412
+ response.removeHeader("Content-Range");
1413
+ for (const name in carried) {
1414
+ response.setHeader(name, carried[name]);
1415
+ }
1400
1416
  response.setHeader("Content-Type", "text/html; charset=utf-8");
1401
1417
  response.setHeader("X-Content-Type-Options", "nosniff");
1402
1418
  response.setHeader("Content-Security-Policy", "default-src 'none'");
package/src/utils.js CHANGED
@@ -39,7 +39,8 @@ const { Stats } = require("fs");
39
39
  * expose?: boolean,
40
40
  * code?: string,
41
41
  * type?: string,
42
- * types?: string[]
42
+ * types?: string[],
43
+ * headers?: Record<string, any>
43
44
  * }} HttpError
44
45
  */
45
46
 
@@ -807,6 +808,16 @@ const lookupType = memoizeByString((type) => mime.lookup(type) || "application/o
807
808
  */
808
809
  const contentTypeFor = memoizeByString((type) => mime.contentType(type) || "application/octet-stream");
809
810
 
811
+ /**
812
+ * The content-type res.set stores for a value, which is what express stores: an extension resolved
813
+ * to its media type with the charset the database gives it, and false when it resolves to nothing.
814
+ * A false there is falsy for every default below it, so send() and json() write their own.
815
+ *
816
+ * @param {string} value
817
+ * @returns {string|false}
818
+ */
819
+ const contentTypeSet = memoizeByString((value) => mime.contentType(value));
820
+
810
821
  /**
811
822
  * A media type from either spelling: "html" is looked up in the mime database, while anything
812
823
  * containing a slash is already one and is parsed for its parameters.
@@ -1740,6 +1751,7 @@ module.exports = {
1740
1751
  entityTag,
1741
1752
  statTag,
1742
1753
  contentTypeFor,
1754
+ contentTypeSet,
1743
1755
  negotiateEncoding,
1744
1756
  ENCODING_BR,
1745
1757
  ENCODING_GZIP,