fulmine.js 5.3.0 → 5.4.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/README.md CHANGED
@@ -53,6 +53,7 @@ shared machine, so the number would describe the machine rather than the framewo
53
53
  - [WebSockets](#websockets)
54
54
  - [socket.io](#socketio)
55
55
  - [HTTP/3](#http3)
56
+ - [Behind a proxy](#behind-a-proxy)
56
57
  - [Versioning](#versioning)
57
58
  - [Compatibility](#compatibility)
58
59
  - [express](#express)
@@ -246,6 +247,56 @@ app.listen(3000, () => {
246
247
 
247
248
  ## Performance tips
248
249
 
250
+ Where the speed comes from, before the rules that govern it. Express finds a route by walking its
251
+ stack and testing each layer against the path. Fulmine hands every route it can to µWS's own router,
252
+ which matches in C++, and works out at `listen()` which layers stand in front of each one, so
253
+ arriving at a handler costs no matching at all:
254
+
255
+ ```text
256
+ Express Fulmine
257
+ GET /users/42 GET /users/42
258
+ | |
259
+ v v
260
+ +--------------+ +------------------+
261
+ | layer 1 | path? no | µWS router | one match, in C++,
262
+ | layer 2 | path? no | /users/:id | against every path
263
+ | ... | +--------+---------+ registered
264
+ | layer 214 | path? yes -+ |
265
+ +--------------+ | v
266
+ a test per layer, | +------------------+
267
+ every request | | the chain, known | the layers in front,
268
+ | | since listen() | in order, no matching
269
+ v +--------+---------+
270
+ handler |
271
+ v
272
+ handler
273
+ ```
274
+
275
+ That is the whole difference on a large route table: the scan grows with the table and the match
276
+ does not, which is why a thousand routes measure 10x and a handful measure 3x.
277
+
278
+ Two more things happen on the way in, and `npx fulmine profile` will tell you which of them your
279
+ routes get:
280
+
281
+ ```text
282
+ a request arriving at a compiled route
283
+
284
+ µWS match ──► the chain ──────────────────────────► handler ──► response
285
+ | |
286
+ | a body parser is stepped over | the Readable is not
287
+ | when the request declared no | built unless something
288
+ | body and the verb reads none | asks the body for one
289
+ | |
290
+ | the headers are not copied out | the two internal
291
+ | of µWS when the analysis proved | listeners are written
292
+ | nothing in the chain reads one | into the event map
293
+ v v
294
+ work that does not happen work that is not prepared
295
+
296
+ and when the handler is simple enough to be read at registration time, none of the
297
+ above happens either: µWS answers from a response written once, at startup
298
+ ```
299
+
249
300
  1. Fulmine tries to optimize routing as much as possible, but it's only possible if:
250
301
 
251
302
  - the path is a plain string, or its parameters are whole segments: `/users/:id` and `/a/:b/c/:d` qualify, `/flights/:from-:to` does not, and neither does a `*splat` or a `{}` group. Routing is case-insensitive by default, as in Express; a request in the registered case is still served natively, any other case takes the ordinary path, and a route whose overlap with an earlier one leans on a cased literal goes the ordinary way for every request.
@@ -378,6 +429,32 @@ const app = express({
378
429
  });
379
430
  ```
380
431
 
432
+ ## Behind a proxy
433
+
434
+ `trust proxy` works as it does in Express: set it and `req.ip`, `req.ips`, `req.protocol` and
435
+ `req.hostname` are read from `X-Forwarded-*` when the connection comes from a peer you trust.
436
+
437
+ Fulmine adds the other way of being told, the one that does not use headers at all. HAProxy, AWS
438
+ NLB, nginx with `proxy_protocol` and Envoy can prepend a **PROXY protocol** preamble to the
439
+ connection, and µWebSockets.js parses it. Off by default, and one line turns it on:
440
+
441
+ ```js
442
+ app.set("trust proxy protocol", true);
443
+ // req.ip, req.socket.remoteAddress and everything reading them are now the address the proxy
444
+ // declared, and fall back to the socket's own on a connection that sent no preamble
445
+ ```
446
+
447
+ > [!WARNING]
448
+ > **Only turn this on when nothing but the proxy can reach the server.** µWS reads the preamble
449
+ > from whoever sends it. There is no way to say which peers may use it, so on a port open to the
450
+ > internet the first sixteen bytes of any connection are enough for a client to become `10.0.0.1`
451
+ > for your rate limiter, your allow list and your audit log. Bind to the private interface, or
452
+ > keep this off.
453
+
454
+ `trust proxy` and this can both be on. The preamble decides what the connection's address is, and
455
+ `trust proxy` then peels `X-Forwarded-For` off that, so a proxy that sends both is read the way it
456
+ meant.
457
+
381
458
  ## Versioning
382
459
 
383
460
  **The major number tracks Express, not semver.** Fulmine 5.x follows Express 5. If Express 6
@@ -461,9 +538,11 @@ Two of these keep a compiled form alongside the value, which you can also set di
461
538
  - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
462
539
  - `query parser fn`, likewise for `query parser`.
463
540
 
464
- Fulmine adds one of its own:
541
+ Fulmine adds three of its own:
465
542
 
466
543
  - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
544
+ - `file cache`, on by default. Small files served by `res.sendFile` come from a bounded in-process cache, checked against the file's `stat` on every request, so an edited file is never served stale.
545
+ - `trust proxy protocol`, off by default. Takes `req.ip` from a PROXY protocol preamble, described under [Behind a proxy](#behind-a-proxy). Read the warning there before turning it on.
467
546
 
468
547
  ### Request
469
548
 
@@ -582,7 +661,8 @@ Almost all middlewares that are compatible with Express are compatible with Fulm
582
661
  - ✅ [express-rate-limit](https://npmjs.com/package/express-rate-limit)
583
662
  - ✅ [express-subdomain](https://npmjs.com/package/express-subdomain)
584
663
  - ✅ [vhost](https://npmjs.com/package/vhost)
585
- - ✅ [tsoa](https://github.com/lukeautry/tsoa)
664
+ - ✅ [http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware)
665
+ - ✅ [express-http-proxy](https://www.npmjs.com/package/express-http-proxy)
586
666
  - ✅ [express-mongo-sanitize](https://www.npmjs.com/package/express-mongo-sanitize)
587
667
  - ✅ [helmet](https://www.npmjs.com/package/helmet)
588
668
  - ✅ [passport](https://www.npmjs.com/package/passport)
@@ -592,6 +672,10 @@ Almost all middlewares that are compatible with Express are compatible with Fulm
592
672
  - ✅ [better-sse](https://www.npmjs.com/package/better-sse)
593
673
  - ✅ [supertest](https://www.npmjs.com/package/supertest)
594
674
 
675
+ [tsoa](https://github.com/lukeautry/tsoa) works too, but it is not in the suite above: it resolves
676
+ `express` itself, so testing it here needs a dependency override rather than the one-line swap
677
+ everything else takes.
678
+
595
679
  ## Tested view engines
596
680
 
597
681
  Any Express view engine should work. Here's list of engines we include in our test suite:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "cover:full": "nyc --silent npm run test && nyc --silent --no-clean npm run test:unit && nyc --silent --no-clean npm run test:express && nyc report",
31
31
  "cover:check": "nyc check-coverage --statements 93 --branches 88 --functions 92 --lines 93",
32
32
  "demo:start": "npm --prefix demo install && npm --prefix demo start",
33
- "demo:deploy": "fly deploy ./demo --config ./demo/fly.toml",
33
+ "demo:deploy": "cd demo && fly deploy",
34
34
  "demo:logs": "fly logs --app fulmine-demo"
35
35
  },
36
36
  "engines": {
@@ -128,6 +128,7 @@
128
128
  "exit-hook": "^2.2.1",
129
129
  "express": "^5",
130
130
  "express-art-template": "^1.0.1",
131
+ "express-basic-auth": "^1.2.1",
131
132
  "express-dot-engine": "^1.0.8",
132
133
  "express-fast-json-stringify": "^1.3.0",
133
134
  "express-fileupload": "^1.5.2",
@@ -137,6 +138,7 @@
137
138
  "express-rate-limit": "^8.5.2",
138
139
  "express-session": "^1.19.0",
139
140
  "express-subdomain": "^1.0.6",
141
+ "express-validator": "^7.3.2",
140
142
  "fast-querystring": "^1.1.2",
141
143
  "globals": "^17.8.0",
142
144
  "graphql-http": "^1.22.4",
@@ -149,6 +151,8 @@
149
151
  "multer": "^2.1.1",
150
152
  "mustache-express": "^1.3.2",
151
153
  "nyc": "^17.1.0",
154
+ "on-finished": "^2.4.1",
155
+ "on-headers": "^1.1.0",
152
156
  "pako": "^2.1.0",
153
157
  "passport": "^0.7.0",
154
158
  "passport-local": "^1.0.0",
@@ -157,6 +161,7 @@
157
161
  "pug": "^3.0.4",
158
162
  "release-it": "^21.0.1",
159
163
  "response-time": "^2.3.4",
164
+ "serve-favicon": "^2.5.1",
160
165
  "serve-index": "^1.9.2",
161
166
  "serve-static": "^2.2.1",
162
167
  "socket.io": "^4.8.3",
@@ -166,6 +166,15 @@ module.exports = function compileDeclarative(cb, app) {
166
166
  // out as `return false`, which is the fallback to ordinary routing. That is the design, and
167
167
  // it is why the tree is walked loosely: acorn types every shape JavaScript can take, and
168
168
  // enumerating them here would be a second, worse copy of that catch.
169
+ //
170
+ // The list below looks like the thing to widen, and it is not. Counted over the 1113
171
+ // handlers in this repository's tests, demo and benchmark scenarios: 42.6% call something
172
+ // that is not res, which no syntax admitted here can reach, and admitting `const` would
173
+ // unlock 7 handlers, 0.6%, one conditional a further 0.1%. On the demo and the benchmark
174
+ // alone, where the handlers look more like an application's, the share that calls something
175
+ // rises to 59% and the two would unlock nothing at all. What keeps a route off this path is
176
+ // that its answer is not knowable until the request arrives, and a variable is only another
177
+ // way to spell an answer that already was.
169
178
  /** @type {any[]} */
170
179
  const tokens = [...acorn.tokenizer(code, { ecmaVersion: "latest" })];
171
180
 
package/src/node-shim.js CHANGED
@@ -24,6 +24,9 @@ limitations under the License.
24
24
 
25
25
  const { IncomingMessage } = require("http");
26
26
 
27
+ /** What µWS returns for an address nobody declared. */
28
+ const emptyAddress = new ArrayBuffer(0);
29
+
27
30
  /**
28
31
  * An IP address as the four or sixteen bytes uWS hands over, since that is what req.ip parses.
29
32
  * Anything unreadable comes back empty, which req.ip reports as undefined, the same answer it gives
@@ -370,6 +373,14 @@ class NodeHttpResponse {
370
373
  return Buffer.from(this._nodeReq.socket?.remoteAddress || "");
371
374
  }
372
375
 
376
+ /**
377
+ * Always empty: node's server does not read the PROXY protocol, so a request that arrived
378
+ * through it never carries an address a proxy declared, whatever "trust proxy protocol" says.
379
+ */
380
+ getProxiedRemoteAddress() {
381
+ return emptyAddress;
382
+ }
383
+
373
384
  /** The client port, or 0 when the socket has already gone. */
374
385
  getRemotePort() {
375
386
  return this._nodeReq.socket?.remotePort ?? 0;
package/src/request.js CHANGED
@@ -125,6 +125,9 @@ function mapsIPv4Peer(app) {
125
125
  return !(host && isIP(host) === 4);
126
126
  }
127
127
 
128
+ /** What µWS returns for a proxied address when no PROXY protocol preamble arrived. */
129
+ const emptyAddress = new ArrayBuffer(0);
130
+
128
131
  const discardedDuplicates = new Set([
129
132
  "age",
130
133
  "authorization",
@@ -146,8 +149,6 @@ const discardedDuplicates = new Set([
146
149
  "user-agent"
147
150
  ]);
148
151
 
149
- let key = 0;
150
-
151
152
  // 128 KB of body buffered before uWS is asked to pause
152
153
  const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
153
154
 
@@ -418,6 +419,13 @@ module.exports = class Request extends LazyReadable {
418
419
  */
419
420
  rawIp;
420
421
 
422
+ /**
423
+ * Whether rawIp came from a PROXY protocol preamble rather than from the socket. Only the
424
+ * IPv4 mapping reads it, see parsedIp. Declared for the same reason as rawIp.
425
+ * @type {boolean}
426
+ */
427
+ _ipFromProxy = false;
428
+
421
429
  /**
422
430
  * Whether the request declared a body, content-length or transfer-encoding, spotted during
423
431
  * the header copy. Declared for the same reason as rawIp.
@@ -489,6 +497,14 @@ module.exports = class Request extends LazyReadable {
489
497
  // framework itself: body framing, keep-alive, and accept for the error page a
490
498
  // throw could still need. A GET that does declare a body is the rare case, and
491
499
  // the parsers and the stream want the whole picture, so it takes the full copy.
500
+ //
501
+ // Seven named reads against one forEach looks like it should lose, and does not: the
502
+ // seven are flat at 0.75us however many headers are on the wire, since each one is a
503
+ // napi crossing and the scan behind it is nothing, while the copy pays a hop back into
504
+ // JS per header and grows, 1.16us at four headers, 1.61 at eight, 2.90 at sixteen. They
505
+ // do not cross, and the gap widens exactly where real traffic lives, since a browser
506
+ // sends a dozen or more. The body case pays two of the seven and then copies anyway,
507
+ // which is 0.2us on a request that is about to read a body.
492
508
  const length = req.getHeader("content-length");
493
509
  const transferEncoding = req.getHeader("transfer-encoding");
494
510
  if (length !== "" || transferEncoding !== "") {
@@ -533,10 +549,6 @@ module.exports = class Request extends LazyReadable {
533
549
  currentRequest = null;
534
550
  }
535
551
  this.routeCount = 1;
536
- this.key = key++;
537
- if (key > 100000) {
538
- key = 0;
539
- }
540
552
  this.app = app;
541
553
  // both forms are kept, because both are asked for: the query with its "?" goes into
542
554
  // req.url, and req.query parses the raw one. Keeping only the first meant slicing the "?"
@@ -608,10 +620,15 @@ module.exports = class Request extends LazyReadable {
608
620
  this._appStack = undefined;
609
621
  this.receivedData = false;
610
622
  // reading ip is very slow in UWS, so its better to not do it unless truly needed
611
- if (this.app.needsIpAfterResponse || this.key < 100) {
612
- // if app needs ip after response, read it now because after response its not accessible
613
- // also read it for first 100 requests to not error
614
- this.rawIp = this._res.getRemoteAddress();
623
+ if (app.needsIpAfterResponse) {
624
+ // an app that has been seen asking after the response reads it now, because by then
625
+ // µWS has freed it
626
+ this.rawIp = this._readRawIp();
627
+ } else if (app._ipProbes < 100) {
628
+ // and until this app has been seen either way, the first hundred requests read it, so
629
+ // one of them can be the one that finds out
630
+ app._ipProbes++;
631
+ this.rawIp = this._readRawIp();
615
632
  }
616
633
 
617
634
  // A body exists on the wire only when the request declares one, content-length or
@@ -884,27 +901,39 @@ module.exports = class Request extends LazyReadable {
884
901
  }
885
902
 
886
903
  /**
887
- * The query string parsed by whichever parser the "query parser" setting names, cached for the
888
- * life of the request. A null-prototype object, so a key like "__proto__" cannot reach
889
- * Object.prototype. No setter, so assigning to req.query throws as it does on Express.
904
+ * The query string parsed by whichever parser the "query parser" setting names. A null-prototype
905
+ * object, so a key like "__proto__" cannot reach Object.prototype. No setter, so assigning to
906
+ * req.query throws as it does on Express.
907
+ *
908
+ * Every read answers a new object, because express's getter re-parses on every read and so hands
909
+ * one back too. Two consequences an application can see, and both of them bite: `req.query` is
910
+ * never the object another reader holds, and a write to a key of it is gone by the next read.
911
+ * That second one is how express-validator's sanitisers behave: `.trim()` on a query parameter
912
+ * changes nothing an ordinary handler will see, which is why it also offers matchedData(). With
913
+ * the parse cached and handed out as itself, the sanitised value leaked into req.query here and
914
+ * a handler written against express read a trimmed value where express gives it the raw one.
915
+ *
916
+ * The parse itself is still done once. What is copied per read is the shallow result, which is
917
+ * cheaper than express's re-parse and answers the same for everything but a write to a nested
918
+ * key, which only the extended parser can produce.
890
919
  *
891
920
  * @returns {Record<string, any>}
892
921
  */
893
922
  get query() {
894
- if (this.#cachedQuery) {
895
- return this.#cachedQuery;
923
+ let parsed = this.#cachedQuery;
924
+ if (parsed === null) {
925
+ const qp = this.app.get("query parser fn");
926
+ // the vendored default already answers on a bare null prototype, so it goes out as is;
927
+ // any other parser is copied onto one, which is what kept fast-querystring's result from
928
+ // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
929
+ parsed = qp
930
+ ? qp === parseQuery
931
+ ? parseQuery(this._rawQuery)
932
+ : Object.assign(Object.create(null), qp(this._rawQuery))
933
+ : Object.create(null);
934
+ this.#cachedQuery = parsed;
896
935
  }
897
- const qp = this.app.get("query parser fn");
898
- // the vendored default already answers on a bare null prototype, so it goes out as is;
899
- // any other parser is copied onto one, which is what kept fast-querystring's result from
900
- // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
901
- const parsed = qp
902
- ? qp === parseQuery
903
- ? parseQuery(this._rawQuery)
904
- : Object.assign(Object.create(null), qp(this._rawQuery))
905
- : Object.create(null);
906
- this.#cachedQuery = parsed;
907
- return parsed;
936
+ return Object.assign(Object.create(null), parsed);
908
937
  }
909
938
 
910
939
  /**
@@ -949,6 +978,32 @@ module.exports = class Request extends LazyReadable {
949
978
  return typeof val === "string" && val.toLowerCase() === "xmlhttprequest";
950
979
  }
951
980
 
981
+ /**
982
+ * The peer address bytes, from the socket or, when the application asked for it, from a PROXY
983
+ * protocol preamble the load balancer in front of this server sent ahead of the request.
984
+ *
985
+ * The setting is off by default and has to stay that way. µWS parses the preamble from whoever
986
+ * sends it, with nothing to ask for it at listen time and no way to restrict who may, so an
987
+ * application that took the address unconditionally would let any client claim any address:
988
+ * the first sixteen bytes of a connection are enough to become 10.0.0.1 for a rate limiter, an
989
+ * allow list or an audit log. Turn it on only when nothing can reach this server except the
990
+ * proxy in front of it.
991
+ *
992
+ * @returns {ArrayBuffer} the socket's own address when no preamble arrived
993
+ */
994
+ _readRawIp() {
995
+ const uwsRes = this._res;
996
+ if (this.app.get("trust proxy protocol")) {
997
+ const proxied = uwsRes.getProxiedRemoteAddress();
998
+ // empty unless a preamble arrived, which is the only thing that tells the two apart
999
+ if (proxied.byteLength !== 0) {
1000
+ this._ipFromProxy = true;
1001
+ return proxied;
1002
+ }
1003
+ }
1004
+ return uwsRes.getRemoteAddress();
1005
+ }
1006
+
952
1007
  /**
953
1008
  * The peer address as text, read from uWS and cached. Reading it is expensive and it is gone
954
1009
  * once the response has finished, so it is read up front for the first hundred requests, and
@@ -971,7 +1026,7 @@ module.exports = class Request extends LazyReadable {
971
1026
  // fallback once
972
1027
  return mapsIPv4Peer(this.app) ? "::ffff:127.0.0.1" : "127.0.0.1";
973
1028
  }
974
- this.rawIp = this._res.getRemoteAddress();
1029
+ this.rawIp = this._readRawIp();
975
1030
  }
976
1031
  // read once: the branch above settled it, and every use below wants the bytes
977
1032
  const rawIp = /** @type {ArrayBuffer} */ (this.rawIp);
@@ -980,7 +1035,10 @@ module.exports = class Request extends LazyReadable {
980
1035
  if (rawIp.byteLength === 4) {
981
1036
  // ipv4
982
1037
  ip = new Uint8Array(rawIp).join(".");
983
- if (mapsIPv4Peer(this.app)) {
1038
+ // the mapped form belongs to a dual stack listener, which is what makes an IPv4 peer
1039
+ // arrive as ::ffff:a.b.c.d. An address a proxy declared never came through that socket,
1040
+ // so it is left as the four numbers the proxy sent
1041
+ if (!this._ipFromProxy && mapsIPv4Peer(this.app)) {
984
1042
  ip = "::ffff:" + ip;
985
1043
  }
986
1044
  } else if (rawIp.byteLength === 16) {
@@ -1053,12 +1111,15 @@ module.exports = class Request extends LazyReadable {
1053
1111
  _detachFromResponse() {
1054
1112
  const uwsRes = this._res;
1055
1113
  if (!this.rawIp) {
1056
- this.rawIp = uwsRes.getRemoteAddress();
1114
+ this.rawIp = this._readRawIp();
1057
1115
  }
1058
1116
  const remotePort = uwsRes.getRemotePort();
1059
1117
  const rawIp = this.rawIp;
1060
1118
  this._res = {
1061
1119
  getRemoteAddress: () => rawIp,
1120
+ // whatever a preamble said is already in rawIp, and asking again is the use after free
1121
+ // this method exists to avoid
1122
+ getProxiedRemoteAddress: () => emptyAddress,
1062
1123
  getRemotePort: () => remotePort,
1063
1124
  // a body cannot arrive on an upgraded socket, and a stray reader must not reach µWS
1064
1125
  onData() {},
package/src/response.js CHANGED
@@ -121,7 +121,84 @@ function statusLine(code, text) {
121
121
  return `${code} ${text ?? statuses.message[code] ?? "unknown"}`.trim();
122
122
  }
123
123
 
124
- module.exports = class Response extends Writable {
124
+ /**
125
+ * A Writable that has not built its state yet, the mirror of LazyReadable in request.js and there
126
+ * for the same reason: a response is a Writable because middleware expects one, and the ordinary
127
+ * one never uses it. `send()` reaches `end()`, which is overridden here and goes straight to
128
+ * _finish, so the WritableState is allocated for every response and read by nobody. It is needed
129
+ * only by res.write(), by a stream piped into the response, by cork and by the writableX getters.
130
+ *
131
+ * `Response extends LazyWritable`, whose prototype is Writable's, so `res instanceof Writable`
132
+ * stays true and every Writable method is reachable; what is missing is `_writableState`, built on
133
+ * the first touch. Measured at 45 nanoseconds a response on the machine this was written on.
134
+ *
135
+ * As on the Readable side the wrapping is generated rather than written out: every own member of
136
+ * Writable's prototype gets a version that materialises first, so there is no list to keep in step.
137
+ * Missing one would not be a slow path, it would be a TypeError on `undefined._writableState`.
138
+ */
139
+ class LazyWritableBase {}
140
+ Object.setPrototypeOf(LazyWritableBase.prototype, Writable.prototype);
141
+ Object.setPrototypeOf(LazyWritableBase, Writable);
142
+
143
+ // what the chain says at runtime, said again for the type checker, which cannot see a prototype
144
+ // being reassigned
145
+ const LazyWritable = /** @type {typeof Writable} */ (/** @type {unknown} */ (LazyWritableBase));
146
+
147
+ /**
148
+ * Builds the stream this object has been pretending to be. Idempotent: everything reachable from
149
+ * outside goes through it, so it is called far more often than it does anything.
150
+ *
151
+ * EventEmitter's init keeps an _events that is already there, so both the shape the constructor
152
+ * wrote and any listener added before this survive it.
153
+ *
154
+ * @param {any} stream
155
+ */
156
+ function materialiseWritable(stream) {
157
+ if (stream._writableState === undefined) {
158
+ Writable.call(stream);
159
+ }
160
+ }
161
+
162
+ for (const member of [
163
+ ...Object.getOwnPropertyNames(Writable.prototype),
164
+ ...Object.getOwnPropertySymbols(Writable.prototype)
165
+ ]) {
166
+ if (member === "constructor") {
167
+ continue;
168
+ }
169
+ const descriptor = /** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(Writable.prototype, member));
170
+ if (typeof descriptor.value === "function") {
171
+ const inner = descriptor.value;
172
+ Object.defineProperty(LazyWritableBase.prototype, member, {
173
+ ...descriptor,
174
+ /** @this {any} @param {...any} args */
175
+ value: function (...args) {
176
+ materialiseWritable(this);
177
+ return inner.apply(this, args);
178
+ }
179
+ });
180
+ } else if (descriptor.get || descriptor.set) {
181
+ const innerGet = descriptor.get;
182
+ const innerSet = descriptor.set;
183
+ Object.defineProperty(LazyWritableBase.prototype, member, {
184
+ ...descriptor,
185
+ get: innerGet
186
+ ? /** @this {any} */ function () {
187
+ materialiseWritable(this);
188
+ return innerGet.call(this);
189
+ }
190
+ : undefined,
191
+ set: innerSet
192
+ ? /** @this {any} @param {any} value */ function (value) {
193
+ materialiseWritable(this);
194
+ innerSet.call(this, value);
195
+ }
196
+ : undefined
197
+ });
198
+ }
199
+ }
200
+
201
+ module.exports = class Response extends LazyWritable {
125
202
  /** @type {Socket|null} */
126
203
  #socket = null;
127
204
 
@@ -151,6 +228,19 @@ module.exports = class Response extends Writable {
151
228
  */
152
229
  constructor(res, req, app) {
153
230
  super();
231
+ // the EventEmitter half stays eager, since the stream half is what LazyWritable defers and
232
+ // the two listeners below are written straight into this map. These are the five keys and
233
+ // the order node's own Writable constructor lays down, so the hidden class is the one every
234
+ // other stream in the process has, and node's init keeps this object when the state is
235
+ // finally built
236
+ this._events = {
237
+ close: undefined,
238
+ error: undefined,
239
+ prefinish: undefined,
240
+ finish: undefined,
241
+ drain: undefined
242
+ };
243
+ this._eventsCount = 0;
154
244
  this._req = req;
155
245
  // linked here rather than by the caller: the pair is built together, and a field the
156
246
  // constructor leaves unset is a shape change on whoever assigns it first
@@ -185,6 +275,10 @@ module.exports = class Response extends Writable {
185
275
  }
186
276
 
187
277
  this.body = undefined;
278
+ // what was handed to uWS, kept so a caller asking for content-length after the fact can be
279
+ // answered, see get(). Undefined until the response ends, and for one that sends no body
280
+ /** @type {string|Buffer|undefined} */
281
+ this._sentBody = undefined;
188
282
  // false while the uWS route handler is still in its synchronous window, where uWS holds
189
283
  // the socket corked itself; the two uWS entry points flip it once that window closes
190
284
  this._corkNeeded = false;
@@ -448,6 +542,12 @@ module.exports = class Response extends Writable {
448
542
  // the value here is nearly always the 10-char "keep-alive", which paid a scan per response
449
543
  const closing =
450
544
  typeof connection === "string" && connection.length === 5 && connection.toLowerCase() === "close";
545
+ // for..in over an object some responses delete from, which is the shape the request side
546
+ // was taken off for #rawHeadersEntries. It stays here, and the difference is where the
547
+ // deletes are: a 200 with a body performs none. Only 204, 304, 205, the freshness branch of
548
+ // sendFile and removeHeader do, this object is built fresh per response, so a dictionary one
549
+ // of them made costs that response and nothing after it. The request side deleted on every
550
+ // request, which is what made it worth a different structure
451
551
  for (const header in headers) {
452
552
  if (closing && header === "keep-alive") {
453
553
  continue;
@@ -584,8 +684,12 @@ module.exports = class Response extends Writable {
584
684
  // an allocation per body, and uWS reads the view's own offset and length
585
685
  if (this.req.method === "HEAD") {
586
686
  const length = Buffer.byteLength(data ?? "");
687
+ this.headers["content-length"] = String(length);
587
688
  this._res.endWithoutBody(length.toString());
588
689
  } else {
690
+ // remembered rather than measured: only a caller that asks for content-length pays
691
+ // for it, and uWS is measuring the same bytes for the wire anyway
692
+ this._sentBody = data ?? "";
589
693
  this._res.end(data);
590
694
  }
591
695
  }
@@ -1185,7 +1289,19 @@ module.exports = class Response extends Writable {
1185
1289
  * @returns {string|string[]|undefined}
1186
1290
  */
1187
1291
  get(field) {
1188
- return this.headers[field.toLowerCase()];
1292
+ const name = field.toLowerCase();
1293
+ const value = this.headers[name];
1294
+ // Content-Length is on the wire but not in here: uWS measures the body it is handed and
1295
+ // writes the header itself, which saves measuring it twice. Express sets it in send(), so
1296
+ // anything reading it back finds it there, and morgan's common and combined formats do
1297
+ // exactly that on every line they write. Worked out here rather than in send() so a
1298
+ // response nobody asks pays nothing, and kept once worked out.
1299
+ if (value === undefined && name === "content-length" && this._sentBody !== undefined) {
1300
+ const length = Buffer.byteLength(this._sentBody);
1301
+ this.headers["content-length"] = String(length);
1302
+ return String(length);
1303
+ }
1304
+ return value;
1189
1305
  }
1190
1306
 
1191
1307
  /**
@@ -1362,23 +1478,22 @@ module.exports = class Response extends Writable {
1362
1478
 
1363
1479
  this.vary("Accept");
1364
1480
 
1365
- // req.next, not the router next sendFile and render use. Express's is the router's here
1366
- // too, so inside a route with a four argument handler of its own this hands the error to
1367
- // that handler where express would have left the route. Passing the router next instead
1368
- // fails express's own res.format test, which asserts the handler is given the very same
1369
- // function the surrounding middleware received: outside a route the two are equivalent but
1370
- // not identical. The whole thing is the open req.next question at Walk's constructor
1481
+ // the router next, as express hands over: inside a route with a four argument handler of
1482
+ // its own, a 406 leaves the route rather than being caught by that handler. Where the two
1483
+ // are the same step, _leaveRoute is the very object the surrounding layer received, which
1484
+ // is what express's own test asserts, see Walk#runRoute
1485
+ const next = this.req._leaveRoute ?? this.req.next;
1371
1486
  if (key) {
1372
1487
  this.set("Content-Type", normalizeType(key).value);
1373
- object[key](this.req, this, this.req.next);
1488
+ object[key](this.req, this, next);
1374
1489
  } else if (object.default) {
1375
- object.default(this.req, this, this.req.next);
1490
+ object.default(this.req, this, next);
1376
1491
  } else {
1377
1492
  // an error and not an answer: express hands the error handler the types it could have
1378
1493
  // sent, which is how an application says what it supports
1379
1494
  const err = httpError(406);
1380
1495
  err.types = keys.map((type) => normalizeType(type).value);
1381
- this.req.next(err);
1496
+ next(err);
1382
1497
  }
1383
1498
 
1384
1499
  return this;
package/src/router.js CHANGED
@@ -333,7 +333,11 @@ class Walk {
333
333
  }
334
334
  }
335
335
  req.next = this.next;
336
- req._leaveRoute = this.leaveRoute;
336
+ // the same step when the route has one callback, and then it has to be the same object:
337
+ // express hands res.format's handlers the next its own layer received, and its test asserts
338
+ // that identity. With more than one callback the two differ for real, and what express
339
+ // hands over is the one that leaves the route
340
+ req._leaveRoute = route.callbacks.length > 1 ? this.leaveRoute : this.next;
337
341
  if (continueRoute === "route") {
338
342
  this.step("route");
339
343
  } else if (continueRoute) {
@@ -920,6 +924,16 @@ const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "QUERY"]);
920
924
  * written on, and the parser prologue it reaches measured 38. Ten to one, for a layer that had
921
925
  * nothing to do.
922
926
  *
927
+ * That number is also why fusing consecutive layers into one generated function keeps coming up,
928
+ * and why it is not here. Counted over a real front, morgan, helmet, compression, cors, the two body
929
+ * parsers, express-session, a middleware of one's own and express.static: three of the nine can be
930
+ * fused, and the longest run of fusable ones in a row is one. Fusing needs two. The rule was relaxed
931
+ * from "calls next once, unconditionally" to merely "calls next synchronously" and the answer did
932
+ * not move, because the six that fail all call next from inside a callback: they are asynchronous by
933
+ * nature, reading a body, stat-ing a file, loading a session. A layer that has not decided by the
934
+ * time it returns cannot be fused by any design that keeps the semantics. What fuses is a run of
935
+ * trivial middlewares, which is a benchmark shape rather than an application's.
936
+ *
923
937
  * @param {any} route
924
938
  * @param {any} req
925
939
  * @returns {boolean}
@@ -956,6 +970,68 @@ function couldAnswer(route, path) {
956
970
  return route.pattern === path;
957
971
  }
958
972
 
973
+ /**
974
+ * Whether a layer written before a mount could answer a request for one of the paths inside it.
975
+ *
976
+ * A mount covers everything under its path, so this is a question about a subtree rather than about
977
+ * the mount point, and the two answers differ: `/a` and `/:p0/:p1/:p2` match none of each other's
978
+ * text, and both answer `/a/x/y`. µWS jumps straight to whichever leaf it registered, so a leaf a
979
+ * layer like this could have answered has to stay on the generic path, which is the only place
980
+ * express's registration order decides.
981
+ *
982
+ * Only layers with more segments than the mount path reach this: one with as few already matches
983
+ * the mount point itself, and _optimizeRoute has refused the mount before the walk gets here.
984
+ *
985
+ * Compared folded whichever way the routers are set. A wrong yes costs a leaf its native
986
+ * registration and nothing else.
987
+ *
988
+ * @param {{path: string, use: boolean, method: string, all: boolean}} guard
989
+ * @param {string} leafPath the leaf's absolute path, parameters and all
990
+ * @param {any} leaf
991
+ * @returns {boolean}
992
+ */
993
+ function shadowsLeaf(guard, leafPath, leaf) {
994
+ if (!guard.all && guard.method !== leaf.method && !(guard.method === "HEAD" && leaf.method === "GET")) {
995
+ return false;
996
+ }
997
+ return pathsCanOverlap(guard.path.toLowerCase(), leafPath.toLowerCase(), guard.use);
998
+ }
999
+
1000
+ /**
1001
+ * The layers before a mount that answer some of what is inside it and not all of it, which is the
1002
+ * one thing neither the chain nor µWS's own choice can say: the chain runs what is in it without
1003
+ * matching again, and µWS picks by specificity. They are carried down the walk instead and asked
1004
+ * about every leaf, see shadowsLeaf.
1005
+ *
1006
+ * @param {any} router the router the mount belongs to
1007
+ * @param {any} mount
1008
+ * @param {string} pathPrefix what the mounts above this one consumed
1009
+ * @param {any[]} chain the layers that always run before the mount, which need no guard
1010
+ * @param {any[]} inherited the guards from further out, since a mount two levels down is under
1011
+ * everything written before either of them
1012
+ * @returns {any[]|null} null when a path cannot be read segment by segment, which leaves the mount
1013
+ * to ordinary dispatch rather than guessing about it
1014
+ */
1015
+ function guardsInside(router, mount, pathPrefix, chain, inherited) {
1016
+ let guards = inherited;
1017
+ for (const r of router._routes) {
1018
+ if (r.routeKey > mount.routeKey) {
1019
+ break;
1020
+ }
1021
+ if (r === mount || chain.includes(r)) {
1022
+ continue;
1023
+ }
1024
+ if (typeof r.path !== "string") {
1025
+ return null;
1026
+ }
1027
+ if (guards === inherited) {
1028
+ guards = [...inherited];
1029
+ }
1030
+ guards.push({ path: pathPrefix + r.path, use: r.use === true, method: r.method, all: r.all === true });
1031
+ }
1032
+ return guards;
1033
+ }
1034
+
959
1035
  /**
960
1036
  * Notes which application is current before a mounted one is entered, so that exact one comes back
961
1037
  * when it hands over.
@@ -1146,6 +1222,26 @@ module.exports = class Router extends EventEmitter {
1146
1222
  */
1147
1223
  _isApplication = false;
1148
1224
 
1225
+ /**
1226
+ * Whether anything served from here has been seen reading req.ip after the response, by which
1227
+ * point µWS has freed the address. Set once, from Request#parsedIp, and read on every request
1228
+ * after that. Here rather than on Application because a plain Router serves requests of its own
1229
+ * through the node shim.
1230
+ *
1231
+ * @type {boolean}
1232
+ */
1233
+ needsIpAfterResponse = false;
1234
+
1235
+ /**
1236
+ * How many requests still read the peer address up front whether or not anyone asks, so that
1237
+ * one of them can be the one that finds out. Counts to a hundred and stops: it used to be read
1238
+ * off a module-wide counter that wrapped at 100000, so the window reopened every time it did
1239
+ * and a hundred requests paid again for a discovery made long before.
1240
+ *
1241
+ * @type {number}
1242
+ */
1243
+ _ipProbes = 0;
1244
+
1149
1245
  /**
1150
1246
  * The two routing flags once read, undefined until then. Express passes caseSensitive and
1151
1247
  * strict in when it builds a router and never looks at them again, so they are frozen here at
@@ -1701,8 +1797,9 @@ module.exports = class Router extends EventEmitter {
1701
1797
  return;
1702
1798
  }
1703
1799
 
1704
- // pathPrefix/chainPrefix accumulate across nested sole-callback mounts
1705
- const walk = (router, pathPrefix, chainPrefix) => {
1800
+ // pathPrefix/chainPrefix accumulate across nested sole-callback mounts, and outerGuards
1801
+ // carries what was written before them and answers only part of what is under them
1802
+ const walk = (router, pathPrefix, chainPrefix, outerGuards) => {
1706
1803
  for (const route of router._routes) {
1707
1804
  if (route.use) {
1708
1805
  // only sole-callback mounts. Case rules do not gate the walk: each level's
@@ -1721,23 +1818,33 @@ module.exports = class Router extends EventEmitter {
1721
1818
  route._whyGeneric = "something before it in the same router overlaps its paths";
1722
1819
  continue;
1723
1820
  }
1724
- route._walkedInto = true;
1725
1821
  pathToMount = pathToMount.slice(0, -1);
1726
- walk(route.callbacks[0], pathPrefix + route.path, [
1727
- ...chainPrefix,
1728
- ...pathToMount,
1729
- {
1730
- ...route,
1731
- callbacks: [],
1732
- callbackKinds: [],
1733
- keepMount: true,
1734
- // mounted sub-apps become req.app during their dispatch, like express
1735
- mountApp:
1736
- route.callbacks[0].constructor.name === "Application"
1737
- ? route.callbacks[0]
1738
- : undefined
1739
- }
1740
- ]);
1822
+ const guards = guardsInside(router, route, pathPrefix, pathToMount, outerGuards);
1823
+ if (guards === null) {
1824
+ route._whyGeneric = "a path written before it cannot be read segment by segment";
1825
+ continue;
1826
+ }
1827
+ route._walkedInto = true;
1828
+ walk(
1829
+ route.callbacks[0],
1830
+ pathPrefix + route.path,
1831
+ [
1832
+ ...chainPrefix,
1833
+ ...pathToMount,
1834
+ {
1835
+ ...route,
1836
+ callbacks: [],
1837
+ callbackKinds: [],
1838
+ keepMount: true,
1839
+ // mounted sub-apps become req.app during their dispatch, like express
1840
+ mountApp:
1841
+ route.callbacks[0].constructor.name === "Application"
1842
+ ? route.callbacks[0]
1843
+ : undefined
1844
+ }
1845
+ ],
1846
+ guards
1847
+ );
1741
1848
  } else {
1742
1849
  // said once here rather than at each condition above: a mount is walked into
1743
1850
  // only when µWS can match its path on its own and it carries exactly one
@@ -1757,6 +1864,16 @@ module.exports = class Router extends EventEmitter {
1757
1864
  (!pathPrefix || !router._isFollowedByAnOverlap(route, router._routes)))) &&
1758
1865
  supportedUwsMethods.has(route.method)
1759
1866
  ) {
1867
+ // something outside this router, written before the mount it is in, that could
1868
+ // answer this exact path. µWS would jump here and never give it its turn
1869
+ if (outerGuards.length > 0 && typeof route.path === "string") {
1870
+ const absolute = pathPrefix + route.path;
1871
+ const guard = outerGuards.find((g) => shadowsLeaf(g, absolute, route));
1872
+ if (guard) {
1873
+ route._whyGeneric = `${guard.path} is written before the mount it is in and answers the same paths`;
1874
+ continue;
1875
+ }
1876
+ }
1760
1877
  const leafPath = router._optimizeRoute(route, router._routes);
1761
1878
  if (!leafPath) {
1762
1879
  route._whyGeneric = "something before it in the same router overlaps its paths";
@@ -1816,7 +1933,7 @@ module.exports = class Router extends EventEmitter {
1816
1933
  }
1817
1934
  };
1818
1935
 
1819
- walk(this, "", []);
1936
+ walk(this, "", [], []);
1820
1937
  }
1821
1938
 
1822
1939
  /**
package/src/utils.js CHANGED
@@ -809,7 +809,11 @@ const defaultSettings = {
809
809
  // "case sensitive routing" is deliberately absent: unset means insensitive, as in Express 5.
810
810
  // The native µWS router matches bytes, so the compiler in _compileOptimizedRoutes only hands
811
811
  // it routes whose earlier siblings it can prove agree under either case rule.
812
- "declarative responses": true
812
+ "declarative responses": true,
813
+ // off, and it is a security setting rather than a compatibility one: with it on, req.ip is the
814
+ // address a PROXY protocol preamble declared. µWS reads that preamble from any client, so this
815
+ // belongs only to a server nothing can reach except the proxy in front of it. See Request#_readRawIp
816
+ "trust proxy protocol": false
813
817
  };
814
818
 
815
819
  /**