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 +9 -4
- package/package.json +1 -1
- package/src/declarative.js +14 -8
- package/src/middlewares.js +81 -45
- package/src/response-utils.js +4 -1
- package/src/response.js +117 -19
- package/src/router.js +23 -7
- package/src/utils.js +13 -1
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
|
|
1014
|
-
`express.compression()`, `express.serverTiming()`, TLS through
|
|
1015
|
-
what `listen()` decided about each route, and the app answering as
|
|
1016
|
-
Express application already does is documented by Express and is not
|
|
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
package/src/declarative.js
CHANGED
|
@@ -18,7 +18,7 @@ limitations under the License.
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const acorn = require("acorn");
|
|
21
|
-
const { stringify,
|
|
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
|
|
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
|
-
|
|
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
|
|
827
|
-
// the
|
|
828
|
-
|
|
829
|
-
if (advertise && !
|
|
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
|
|
package/src/middlewares.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
-
err.statusCode = 400;
|
|
1156
|
-
err.expose = true;
|
|
1157
|
-
next(err);
|
|
1193
|
+
next(inflateError(/** @type {Inflater} */ (inflate), err));
|
|
1158
1194
|
}
|
|
1159
1195
|
|
|
1160
1196
|
/**
|
package/src/response-utils.js
CHANGED
|
@@ -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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1134
|
-
// the
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
//
|
|
1613
|
-
|
|
1614
|
-
|
|
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.
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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,
|