fulmine.js 5.12.2 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -31,6 +31,13 @@ limitations under the License.
31
31
  //
32
32
  // Whether this machine and this project can run it at all: the node version, the C library, the
33
33
  // µWebSockets.js binary, the base image a Dockerfile names. See src/verify.js.
34
+ //
35
+ // npx fulmine override [dir]
36
+ // npx fulmine angular [dir]
37
+ //
38
+ // The two things a project needs that are a line in a JSON file rather than a specifier in a source
39
+ // file: the package manager substitution, for a framework that requires express in its own code,
40
+ // and angular.json's externalDependencies. See src/adopt.js.
34
41
 
35
42
  const fs = require("fs");
36
43
  const path = require("path");
@@ -38,6 +45,7 @@ const acorn = require("acorn");
38
45
  // the same walk express.testing asserts on, so the command and the assertions cannot drift
39
46
  const { collectRoutes } = require("./testing.js");
40
47
  const { verify } = require("./verify.js");
48
+ const { override, angular } = require("./adopt.js");
41
49
 
42
50
  const FROM = "express";
43
51
  const TO = "fulmine.js";
@@ -85,11 +93,12 @@ const DIFFERENCES = [
85
93
  'Express sends X-Powered-By: Express unless told not to. Set app.set("x-powered-by", true) to send it.'
86
94
  ],
87
95
  [
88
- "a compiled route is framed differently and keeps its connection header",
89
- "A handler simple enough to be read at registration time is answered natively: chunked framing\n" +
90
- "with no Content-Length, and a client that sent Connection: close is still told keep-alive,\n" +
91
- "though the socket does close. A response that would carry a validator is never compiled, so\n" +
92
- 'conditional requests behave as on Express. app.set("declarative responses", false) turns it off.'
96
+ "a compiled route keeps its connection header",
97
+ "A handler simple enough to be read at registration time is answered natively, and a client\n" +
98
+ "that sent Connection: close is still told keep-alive, though the socket does close. A body\n" +
99
+ "with a piece of the query in it is framed chunked, since its length is not known until the\n" +
100
+ "request arrives. A response that would carry a validator is never compiled, so conditional\n" +
101
+ 'requests behave as on Express. app.set("declarative responses", false) turns it off.'
93
102
  ],
94
103
  [
95
104
  "headers are capped at 4096 bytes by default",
@@ -822,9 +831,19 @@ function main(argv) {
822
831
  if (command === "explain") {
823
832
  return explain(argv.slice(1));
824
833
  }
834
+ if (command === "override") {
835
+ return override(argv.slice(1));
836
+ }
837
+ if (command === "angular") {
838
+ return angular(argv.slice(1));
839
+ }
825
840
  if (command !== "migrate") {
826
841
  console.log(`Usage:
827
842
  npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
843
+ npx ${TO} override [dir] answer ${FROM} with this package for the whole dependency tree, for
844
+ when a framework requires ${FROM} in its own code and not in yours
845
+ npx ${TO} angular [dir] declare this package external in angular.json's server build, which
846
+ esbuild otherwise tries to inline a native binary into
828
847
  npx ${TO} profile [entry] load an application without listening and print what compiling
829
848
  its routes decided, route by route
830
849
  npx ${TO} explain <route> what happens when a request for that route arrives
@@ -832,7 +851,7 @@ function main(argv) {
832
851
  npx ${TO} differences print what behaves differently, without changing anything
833
852
 
834
853
  Options:
835
- --dry-run migrate: say what would change and change nothing`);
854
+ --dry-run migrate, override, angular: say what would change and change nothing`);
836
855
  return command ? 1 : 0;
837
856
  }
838
857
 
@@ -54,6 +54,11 @@ const MAX_INSTRUCTION_LENGTH = 65535;
54
54
  // so it cannot honour one, and a handler that sets one has to stay on the ordinary path.
55
55
  const VALIDATOR_HEADERS = new Set(["etag", "last-modified"]);
56
56
 
57
+ // The statuses whose message carries no content. 205 is here for node's reason rather than
58
+ // express's: express strips the body for 204 and 304, and node answers a 205 with a lone
59
+ // Content-Length of zero, so all three come out of the ordinary path with no body at all.
60
+ const BODILESS_STATUSES = new Set([204, 205, 304]);
61
+
57
62
  const bodyMethods = new Set(["send", "json", "end"]);
58
63
  // and the four that finish the response, after which nothing a handler does is observable
59
64
  const terminalMethods = new Set(["send", "json", "end", "sendStatus"]);
@@ -710,6 +715,14 @@ module.exports = function compileDeclarative(cb, app) {
710
715
  return false;
711
716
  }
712
717
 
718
+ // A status that carries no content. Compiled, the body went out with it, and a client
719
+ // frames these as bodiless whatever the headers say, so those bytes were read as the start
720
+ // of the next answer on the connection. The ordinary path already writes all three the way
721
+ // express does.
722
+ if (BODILESS_STATUSES.has(statusCode) || statusCode < 200) {
723
+ return false;
724
+ }
725
+
713
726
  let decRes = new uWSAny.DeclarativeResponse();
714
727
 
715
728
  if (statusCode !== 200) {
@@ -755,15 +768,10 @@ module.exports = function compileDeclarative(cb, app) {
755
768
  body.push({ type: "text", value: statuses.message[statusCode] || String(statusCode) });
756
769
  }
757
770
 
758
- // A response that would carry a validator is not compiled at all.
759
- //
760
- // µWS answers a declarative response without reading the request, so it cannot answer a
761
- // conditional GET: it used to write an ETag computed over the compiled body at listen and
762
- // then ignore it, so every revalidation got 200 and the whole body where Express answers
763
- // 304 with none. Dropping the ETag instead would have kept the route compiled, at the
764
- // price of no validator at all on the simplest routes of every application. Refusing
765
- // keeps Express's answer, and `etag` false is how a route that does not need one stays
766
- // compiled, which is what both benchmarks here already set.
771
+ // A response that would carry a validator is not compiled at all: µWS answers it without
772
+ // reading the request, so it could never turn a conditional GET into the 304 the validator
773
+ // invites. Dropping the ETag instead would leave the simplest routes of an application
774
+ // without one, so `etag` false is how a route stays compiled.
767
775
  if (headers.some((header) => VALIDATOR_HEADERS.has(header[0].toLowerCase()))) {
768
776
  return false;
769
777
  }
@@ -810,13 +810,20 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
810
810
  return next();
811
811
  }
812
812
 
813
+ // The property goes on the request before anything is decided, and its value stays
814
+ // undefined: body-parser's read() does exactly this, and the two halves both matter.
815
+ // Undefined, so a handler can still tell "nothing parsed this" from "the body was
816
+ // empty", which seeding an empty object would lose. Present, because `"body" in req`
817
+ // is how a library asks whether a parser has run at all: Apollo's express middleware
818
+ // refuses the request with a 500 when the property is missing, and tRPC's adapter
819
+ // reads the body itself when it is, so getting either half wrong breaks one of them.
820
+ if (!("body" in req)) {
821
+ req.body = undefined;
822
+ }
823
+
813
824
  // straight from the raw entries: three headers do not justify building the object
814
825
  const type = req._rawHeader("content-type");
815
826
 
816
- // req.body is deliberately left undefined until a parser claims the request. That is
817
- // what lets a handler tell "nothing parsed this" apart from "the body was empty",
818
- // so it must not be seeded with an empty object first.
819
-
820
827
  // skip reading body for no content type
821
828
  // a function decides for itself, and body-parser lets it see a request that carries no
822
829
  // content-type at all. Only the string and array forms need one to match against
package/src/nest.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { ExpressAdapter } from "@nestjs/platform-express";
18
+
19
+ /**
20
+ * Nest's Express adapter, listening on uWebSockets.js instead of on node.
21
+ *
22
+ * ```ts
23
+ * import { NestFactory } from "@nestjs/core";
24
+ * import { FulmineExpressAdapter } from "fulmine.js/nest";
25
+ *
26
+ * const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
27
+ * await app.listen(3000);
28
+ * ```
29
+ *
30
+ * `@nestjs/platform-express` is an optional peer dependency: this entry point is the only thing
31
+ * that needs it, and nothing loads it unless you import this.
32
+ */
33
+ export declare class FulmineExpressAdapter extends ExpressAdapter {
34
+ /**
35
+ * @param instance an application from `fulmine()`; one is created when omitted. Pass your own
36
+ * when it needs options, TLS being the usual reason: `fulmine({ uwsOptions })`.
37
+ */
38
+ constructor(instance?: any);
39
+ }
package/src/nest.js ADDED
@@ -0,0 +1,119 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // require("fulmine.js/nest"): the Nest HTTP adapter, so a Nest application runs on µWS without
18
+ // anyone having to write this file themselves.
19
+ //
20
+ // import { NestFactory } from "@nestjs/core";
21
+ // import { FulmineExpressAdapter } from "fulmine.js/nest";
22
+ //
23
+ // const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
24
+ // await app.listen(3000);
25
+ //
26
+ // @nestjs/platform-express takes any Express instance, and this is one, so everything above the
27
+ // adapter - controllers, pipes, guards, interceptors - is untouched. Three things below it are not,
28
+ // and they are the whole reason this file exists:
29
+ //
30
+ // - initHttpServer wraps the instance in http.createServer() and listens on that. Every request
31
+ // would then arrive through node's parser and be replayed into µWS's shapes by node-shim.js,
32
+ // which is the slow path that exists for supertest. The app already answers as an http.Server,
33
+ // so it is the server instead of being put inside one.
34
+ // - registerParserMiddleware decides whether Nest's body parsers are already in the chain by
35
+ // scanning app.router.stack for them. There is no layer array here to scan, routes are compiled
36
+ // rather than kept as layers, so the answer was always "no" and a second call added a second
37
+ // pair. It is remembered here instead, which is the same answer by a different route.
38
+ // - httpsOptions asks node to make a TLS server out of the instance. TLS here belongs to µWS and
39
+ // is configured when the app is built, so that combination is refused with the line to write
40
+ // rather than silently starting a plaintext server.
41
+ //
42
+ // @nestjs/platform-express is an optional peer dependency: this file is the only one that requires
43
+ // it, and nothing loads this file unless you ask for it by name.
44
+
45
+ "use strict";
46
+
47
+ const { ExpressAdapter } = require("@nestjs/platform-express");
48
+ const fulmine = require("./index.js");
49
+
50
+ /**
51
+ * Nest's Express adapter, listening on µWebSockets.js instead of on node.
52
+ *
53
+ * Pass a configured app when you need one, `new FulmineExpressAdapter(fulmine({ uwsOptions }))`;
54
+ * with no argument it builds a default one, the same as `new ExpressAdapter()` does.
55
+ */
56
+ class FulmineExpressAdapter extends ExpressAdapter {
57
+ /**
58
+ * @param {any} [instance] an application from `fulmine()`; one is created when omitted
59
+ */
60
+ constructor(instance) {
61
+ super(instance || fulmine());
62
+ /**
63
+ * Whether Nest's body parsers are in the chain, standing in for the layer array Express
64
+ * has and this does not. See registerParserMiddleware below.
65
+ * @type {boolean}
66
+ */
67
+ this._parsersRegistered = false;
68
+ }
69
+
70
+ /**
71
+ * The app is the server. Nest calls this once, from NestApplication's constructor.
72
+ *
73
+ * @param {any} [options] the options NestFactory.create was given
74
+ * @returns {void}
75
+ */
76
+ initHttpServer(options) {
77
+ if (options?.httpsOptions) {
78
+ throw new Error(
79
+ "fulmine.js: httpsOptions cannot be used here, since there is no node server to give " +
80
+ "them to. TLS belongs to µWS and is configured when the app is built:\n" +
81
+ ' new FulmineExpressAdapter(fulmine({ uwsOptions: { key_file_name: "key.pem", cert_file_name: "cert.pem" } }))'
82
+ );
83
+ }
84
+ this.httpServer = this.getInstance();
85
+ if (options?.forceCloseConnections) {
86
+ // trackOpenConnections() listens for 'connection', which nothing emits: the sockets
87
+ // belong to µWS and never become node ones. Said out loud, because a shutdown that
88
+ // quietly waits forever for what it thinks it can destroy is worse than one that does
89
+ // not offer to. Through Nest's own logger, so the line arrives where every other line
90
+ // from the framework does; it is private in the typings and inherited all the same
91
+ /** @type {any} */ (this).logger.warn(
92
+ "forceCloseConnections has no effect on fulmine.js: the sockets belong to µWS. " +
93
+ "app.close() stops accepting and waits for the requests in flight; an idle keep-alive " +
94
+ "connection is closed by µWS through uwsOptions.idleTimeout, not by node."
95
+ );
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Nest's json and urlencoded parsers, added once however often this is called.
101
+ *
102
+ * Express answers "are they there already" by scanning `app.router.stack` for a layer whose
103
+ * handler is named `jsonParser` or `urlencodedParser`. There is no such array here, so the scan
104
+ * answered no every time and a second call put a second pair in front of every request.
105
+ *
106
+ * @param {string} [prefix]
107
+ * @param {boolean} [rawBody]
108
+ * @returns {void}
109
+ */
110
+ registerParserMiddleware(prefix, rawBody) {
111
+ if (this._parsersRegistered) return;
112
+ this._parsersRegistered = true;
113
+ super.registerParserMiddleware(prefix, rawBody);
114
+ }
115
+ }
116
+
117
+ // a named export and nothing else: `import { FulmineExpressAdapter } from "fulmine.js/nest"`, which
118
+ // is how @nestjs/platform-express exports ExpressAdapter too
119
+ module.exports = { FulmineExpressAdapter };
package/src/request.js CHANGED
@@ -152,6 +152,51 @@ const discardedDuplicates = new Set([
152
152
  // 128 KB of body buffered before uWS is asked to pause
153
153
  const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
154
154
 
155
+ // The methods node's parser accepts, which is the set a request can arrive with behind Express and
156
+ // the set a route can be registered for here. µWS accepts any token, so without this a line like
157
+ // `{"a":1}GET /path HTTP/1.1` is a request to it, with `{"A":1}GET` as the method. See _mustRefuse.
158
+ const KNOWN_METHODS = new Set(require("http").METHODS);
159
+
160
+ /**
161
+ * Whether a transfer-encoding leaves the body's length knowable, which is RFC 9112's rule that
162
+ * `chunked` comes last. `gzip, chunked` is fine and `chunked, gzip` is not: with a coding applied
163
+ * after the framing one, nothing can say where the body ends, and node answers 400 rather than
164
+ * guess. µWS guesses, and what it guesses wrong becomes the next request on the connection.
165
+ *
166
+ * Read per header rather than over the joined value, so a request splitting the list across two
167
+ * transfer-encoding headers is refused even when the codings would be legal joined up. That is
168
+ * stricter than node by a hair, on a shape nothing sends, and stricter is the safe direction here.
169
+ *
170
+ * @param {string} value one transfer-encoding header, as uWS hands it over
171
+ * @returns {boolean}
172
+ */
173
+ function endsWithChunked(value) {
174
+ const last = value.slice(value.lastIndexOf(",") + 1).trim();
175
+ // a coding may carry parameters, which are not part of its name
176
+ const semicolon = last.indexOf(";");
177
+ return (semicolon === -1 ? last : last.slice(0, semicolon)).trim().toLowerCase() === "chunked";
178
+ }
179
+
180
+ /**
181
+ * The path of the url a request carries right now, without the query.
182
+ *
183
+ * Express reads it off req.url on every access, so a middleware that assigns req.url is seen by
184
+ * whatever runs next, the callback after it in the same route included: the router only takes a
185
+ * rewrite over at its next hop. The cached field answers while the two agree, which is every read
186
+ * of a request nobody rewrote.
187
+ *
188
+ * @param {any} req
189
+ * @returns {string}
190
+ */
191
+ function currentPath(req) {
192
+ const url = req.url;
193
+ if (url === req._lastUrl) {
194
+ return req._path;
195
+ }
196
+ const query = url.indexOf("?");
197
+ return query === -1 ? url : url.slice(0, query);
198
+ }
199
+
155
200
  /**
156
201
  * Whether a content-length is a plain count of bytes, which is the only thing RFC 9112 allows.
157
202
  *
@@ -173,6 +218,12 @@ function isByteCount(value) {
173
218
  return false;
174
219
  }
175
220
  }
221
+ // A count nothing can represent is not a count. Node refuses one that overflows, and µWS framed
222
+ // the request as if it had said something else, which put the bytes after it in a request of
223
+ // their own. The length test first, so an ordinary value never parses.
224
+ if (value.length > 15 && Number(value) > Number.MAX_SAFE_INTEGER) {
225
+ return false;
226
+ }
176
227
  return true;
177
228
  }
178
229
 
@@ -317,11 +368,12 @@ module.exports = class Request extends LazyReadable {
317
368
  /** A bodyless request whose empty end has not been delivered yet, see the constructor. */
318
369
  #emptyBody = false;
319
370
 
320
- /**
321
- * What a body parser left behind, and undefined until one claims the request.
322
- * @type {any}
323
- */
324
- body;
371
+ // `body` is deliberately not declared here. A class field would put the property on every
372
+ // request, and on Express there is none until a body parser assigns one. `"body" in req` is how
373
+ // a library asks whether the body has already been read, and tRPC's express adapter asks
374
+ // exactly that: answering yes on a request nobody had parsed handed it an undefined body and
375
+ // turned every mutation into "Unexpected end of JSON input". Its type lives in types.d.ts,
376
+ // where the rest of the public request surface is described.
325
377
 
326
378
  /**
327
379
  * The response this request arrived with, linked so either reaches the other.
@@ -363,11 +415,15 @@ module.exports = class Request extends LazyReadable {
363
415
  if (headerKey.length === 14) {
364
416
  // a second content-length whatever it says, and one that is not a count of bytes:
365
417
  // both make uWS frame the request differently from what is on the wire, see
366
- // _badFraming and isByteCount
418
+ // _mustRefuse and isByteCount
367
419
  if (r._sawContentLength || !isByteCount(value)) {
368
- r._badFraming = true;
420
+ r._mustRefuse = true;
369
421
  }
370
422
  r._sawContentLength = true;
423
+ } else if (!endsWithChunked(value)) {
424
+ // chunked has to be the last coding: anything after it and the length of the body
425
+ // is not knowable, which node answers 400 to and µWS served. See endsWithChunked
426
+ r._mustRefuse = true;
371
427
  }
372
428
  // saying anything about framing at all, "0" included. A parser that can see a
373
429
  // content-length answers about the body it describes, even an empty one: a zero length
@@ -480,9 +536,9 @@ module.exports = class Request extends LazyReadable {
480
536
  _sawContentLength;
481
537
 
482
538
  /**
483
- * Whether the request said two different things about how long its body is, so uWS may have
484
- * framed it differently from the client that sent it and the proxy that forwarded it. Two
485
- * shapes reach this, and node's parser refuses both outright:
539
+ * Whether this request must not be routed at all. Node's parser refuses each of these outright
540
+ * and answers 400; every one of them is a way for bytes the client did not send as a request to
541
+ * be served as one, which is request smuggling.
486
542
  *
487
543
  * a repeated content-length uWS frames on the first and drops the rest, so a proxy in
488
544
  * front reading the last one instead forwards bytes uWS then
@@ -491,13 +547,17 @@ module.exports = class Request extends LazyReadable {
491
547
  * included, and frames the request as carrying no body at all,
492
548
  * which turns the body the client sent into that same second
493
549
  * request. See isByteCount
550
+ * a method nobody defines uWS takes any token as the method, so anything at all
551
+ * followed by a space and a path is a request line to it. A
552
+ * request with no content-length and no transfer-encoding has
553
+ * no body, so the bytes after it are the next request: node
554
+ * reads them and answers 400, uWS served them. See KNOWN_METHODS
494
555
  *
495
- * Either way it is request smuggling, and the request is refused rather than routed. Declared
496
- * for the same reason as rawIp.
556
+ * Declared for the same reason as rawIp.
497
557
  *
498
558
  * @type {boolean|undefined}
499
559
  */
500
- _badFraming;
560
+ _mustRefuse;
501
561
 
502
562
  /**
503
563
  * Whether the client asked for the connection to be closed. Declared for the same reason.
@@ -567,7 +627,7 @@ module.exports = class Request extends LazyReadable {
567
627
  // A content-length of "0" declares no body and used to stay on the cheap side, but
568
628
  // getHeader only ever returns the first of a repeated header, so a duplicate cannot be
569
629
  // seen from here, and a duplicate has to be refused rather than routed: see
570
- // _badFraming. Anything that says a word about framing takes the full copy instead.
630
+ // _mustRefuse. Anything that says a word about framing takes the full copy instead.
571
631
  //
572
632
  // One shape stays invisible here, a content-length present with an empty value: uWS
573
633
  // answers "" for that and for a header that was never sent, and nothing in its API
@@ -632,7 +692,7 @@ module.exports = class Request extends LazyReadable {
632
692
  }
633
693
  if (preset) {
634
694
  // the registration's constants: two native crossings and their strings not asked for
635
- this.path = preset.path;
695
+ this._path = preset.path;
636
696
  this.originalUrl = preset.path + this.urlQuery;
637
697
  this.url = this.originalUrl;
638
698
  this._lastUrl = this.originalUrl;
@@ -646,17 +706,27 @@ module.exports = class Request extends LazyReadable {
646
706
  // getUrl() is the path already, so the query is joined on and then not split off
647
707
  // again. Building originalUrl and picking the path back out of it with indexOf and
648
708
  // substring was a search and a second string for something uWS had just handed over.
649
- this.path = req.getUrl();
650
- this.originalUrl = this.path + this.urlQuery;
709
+ this._path = req.getUrl();
710
+ this.originalUrl = this._path + this.urlQuery;
651
711
  this.url = this.originalUrl;
652
712
  // what the router last wrote to req.url. A middleware assigning something else is a
653
713
  // rewrite, which express honours, and dispatch compares against this to notice it
654
714
  this._lastUrl = this.originalUrl;
655
715
  // charCodeAt rather than indexing: s[i] builds a one character string to throw away
656
- this.endsWithSlash = this.path.charCodeAt(this.path.length - 1) === 0x2f;
657
- this._opPath = this.path;
658
- this._originalPath = this.path;
659
- this.method = req.getCaseSensitiveMethod().toUpperCase();
716
+ this.endsWithSlash = this._path.charCodeAt(this._path.length - 1) === 0x2f;
717
+ this._opPath = this._path;
718
+ this._originalPath = this._path;
719
+ const rawMethod = req.getCaseSensitiveMethod();
720
+ this.method = rawMethod.toUpperCase();
721
+ // node's parser knows a fixed set and refuses everything else; µWS takes the token as
722
+ // it finds it, so a request line is anything with a space in it. Compared before the
723
+ // uppercasing on purpose: a method is case sensitive, node refuses "post", and µWS
724
+ // folds it to POST and serves it. Only asked of a method the framework cannot route
725
+ // anyway, since a route can only be registered for one of these, see the loop that
726
+ // builds the verb methods at the end of router.js
727
+ if (!KNOWN_METHODS.has(rawMethod)) {
728
+ this._mustRefuse = true;
729
+ }
660
730
  this._isOptions = this.method === "OPTIONS";
661
731
  this._isHead = this.method === "HEAD";
662
732
  }
@@ -1004,6 +1074,17 @@ module.exports = class Request extends LazyReadable {
1004
1074
  return index !== -1 ? header.slice(0, index).trim() : header.trim();
1005
1075
  }
1006
1076
 
1077
+ /**
1078
+ * The path of the current url, without the query and relative to the mount the request is in.
1079
+ * A getter rather than a field, because express recomputes it from req.url on every read, see
1080
+ * currentPath.
1081
+ *
1082
+ * @returns {string}
1083
+ */
1084
+ get path() {
1085
+ return currentPath(this);
1086
+ }
1087
+
1007
1088
  /**
1008
1089
  * Takes over what a middleware assigned to req.url: the remaining routing matches the new
1009
1090
  * path, and req.query reflects the new query string. The assigned url is relative to the
@@ -1025,7 +1106,7 @@ module.exports = class Request extends LazyReadable {
1025
1106
  // a rewrite to "/a?" keeps its "?", as one arriving that way does
1026
1107
  this.urlQuery = queryIndex === -1 ? "" : "?" + this._rawQuery;
1027
1108
  this._originalPath = prefix + newPath;
1028
- this.path = newPath;
1109
+ this._path = newPath;
1029
1110
  this.endsWithSlash = newPath.charCodeAt(newPath.length - 1) === 0x2f;
1030
1111
  this._opPath = newPath;
1031
1112
  this._opPathLower = null;
package/src/response.js CHANGED
@@ -589,9 +589,16 @@ module.exports = class Response extends LazyWritable {
589
589
  *
590
590
  * Nothing is written here despite the name: the headers go out when the body does.
591
591
  *
592
+ * Every header goes through setHeader and not through set. This is node's method, not
593
+ * Express's: Express does not override it, so a content-type given here keeps the value it was
594
+ * given, where `res.set("content-type", "text/html")` would have a charset appended to it. A
595
+ * handler that builds its own response and writes it with writeHead is how every meta-framework
596
+ * on top of Express answers, @astrojs/node and @sveltejs/adapter-node included, so the charset
597
+ * was being added to pages nobody asked it for.
598
+ *
592
599
  * @param {number} statusCode
593
- * @param {string|Record<string, any>} [statusMessage] the reason phrase, or the headers
594
- * @param {Record<string, any>} [headers]
600
+ * @param {string|Record<string, any>|any[]} [statusMessage] the reason phrase, or the headers
601
+ * @param {Record<string, any>|any[]} [headers]
595
602
  * @returns {this}
596
603
  */
597
604
  writeHead(statusCode, statusMessage, headers) {
@@ -605,8 +612,22 @@ module.exports = class Response extends LazyWritable {
605
612
  // string reaching here was already taken as the phrase above and simply has no keys.
606
613
  headers = /** @type {Record<string, any>} */ (statusMessage);
607
614
  }
615
+ if (Array.isArray(headers)) {
616
+ // node takes a flat list here, name then value, and not a list of pairs. An odd length
617
+ // is the caller's mistake and node names the argument in what it throws
618
+ if (headers.length % 2 !== 0) {
619
+ /** @type {NodeJS.ErrnoException} */
620
+ const err = new TypeError(`The argument 'headers' is invalid. Received ${JSON.stringify(headers)}`);
621
+ err.code = "ERR_INVALID_ARG_VALUE";
622
+ throw err;
623
+ }
624
+ for (let i = 0; i < headers.length; i += 2) {
625
+ this.setHeader(headers[i], headers[i + 1]);
626
+ }
627
+ return this;
628
+ }
608
629
  for (const header in headers) {
609
- this.set(header, headers[header]);
630
+ this.setHeader(header, headers[header]);
610
631
  }
611
632
  return this;
612
633
  }