fulmine.js 5.11.0 → 5.12.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Fulmine.js
4
4
 
5
- A drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
5
+ Fulmine - means lightning ⚡ in Italian - is a drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
6
6
 
7
7
  ```js
8
8
  const express = require("fulmine.js"); // instead of require("express")
@@ -29,9 +29,12 @@ npx fulmine.js explain /api/items # what happens when a request for that route
29
29
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
30
30
 
31
31
  [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
32
- [![Node.js >= 22.0.0](https://img.shields.io/badge/Node.js-%3E=22.0.0-green)](https://nodejs.org)
32
+ [![Node.js 22 | 24 | 26](https://img.shields.io/badge/Node.js-22%20%7C%2024%20%7C%2026-green)](https://nodejs.org)
33
+ [![HTTP Arena](https://img.shields.io/endpoint?url=https://www.http-arena.com/badge/fulmine/h1.json)](https://www.http-arena.com/#tuned=0)
33
34
  [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/fulmine.js/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
34
35
  [![CodeQL](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml)
36
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/nigrosimone/fulmine.js/badge)](https://scorecard.dev/viewer/?uri=github.com/nigrosimone/fulmine.js)
37
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14089/badge)](https://www.bestpractices.dev/projects/14089)
35
38
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
36
39
 
37
40
  ## Table of contents
@@ -43,6 +46,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
43
46
  - [Difference from similar projects](#difference-from-similar-projects)
44
47
  - [Migrating](#migrating)
45
48
  - [Angular SSR](#angular-ssr)
49
+ - [NestJS](#nestjs)
46
50
  - [When Express is somebody else's dependency](#when-express-is-somebody-elses-dependency)
47
51
  - [Docker](#docker)
48
52
  - [Differences from Express](#differences-from-express)
@@ -172,6 +176,38 @@ that runs on Express and here alike, and the same measurement says a page costs
172
176
  stores only the body makes the server hash the whole document again on every hit, and measures level
173
177
  with no cache at all on the serving side.
174
178
 
179
+ ### NestJS
180
+
181
+ `@nestjs/platform-express` takes an Express instance, so it takes this one, and everything in a Nest
182
+ application keeps working. One line stands between that and the speed: the adapter wraps whatever
183
+ instance it is given in `http.createServer()` and listens on that, which is the shim, so every
184
+ request goes through `node:http` and the application runs at Express's pace. The app here already
185
+ answers as an `http.Server`, so it can be the server rather than being wrapped in one:
186
+
187
+ ```ts
188
+ import { NestFactory } from "@nestjs/core";
189
+ import { ExpressAdapter } from "@nestjs/platform-express";
190
+ import fulmine from "fulmine.js";
191
+
192
+ class FulmineAdapter extends ExpressAdapter {
193
+ initHttpServer() {
194
+ // instead of http.createServer(instance): listen() and close() are then µWS's
195
+ (this as any).httpServer = this.getInstance();
196
+ }
197
+ }
198
+
199
+ const app = await NestFactory.create(AppModule, new FulmineAdapter(fulmine()));
200
+ await app.listen(3000);
201
+ ```
202
+
203
+ Measured on the same Nest application, controllers, pipes and body parsing unchanged: **1.2x on a
204
+ route answering text and 1.9x on one answering JSON with a route parameter**. `app.close()` closes
205
+ the port, as it does on the shim.
206
+
207
+ Two things to know. `forceCloseConnections` has nothing to destroy, since the sockets belong to µWS
208
+ and nothing emits `connection`, and Nest looks at `app.router.stack` to decide whether it has
209
+ already added its body parsers, which is not there, so it adds them once more than it would.
210
+
175
211
  ### When Express is somebody else's dependency
176
212
 
177
213
  A framework built on Express does not `require("express")` in your code, it requires it in its own,
@@ -249,6 +285,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
249
285
  - `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
250
286
  - `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
251
287
  - request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
288
+ - **A compiled route answers `connection: keep-alive` to a client that sent `Connection: close`.** A handler simple enough to be read at registration time is answered by µWS from a response written once at `listen()`, and that response cannot read the request. The socket still closes, so what is wrong is the header and not the transport. A response that would carry a validator is never compiled, so conditional requests behave as on Express; `app.set("declarative responses", false)` turns compiling off.
252
289
  - **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
253
290
  - For HTTPS, instead of doing this:
254
291
 
@@ -292,6 +329,7 @@ app.listen(3000, () => {
292
329
  Runnable: [`examples/https.js`](./examples/https.js).
293
330
 
294
331
  - This also applies to non-SSL HTTP too. Use `app.listen()` rather than creating a server by hand. `http.createServer(app)` does work, because the app is a request listener like Express's and answers node's requests through a shim, which is what lets `supertest`, `vhost` and anything else that calls an app keep working. But it serves those requests through `node:http` rather than through µWS, so the speed is Express's. It is there for compatibility, not for production.
332
+ - **Node 22, 24 and 26, not every version above 22.** µWebSockets.js ships one prebuilt binary per Node ABI and skips the odd lines, so Node 23 and 25 have no binary to load and fail at `require`. `npx fulmine.js verify` says which binary this machine wants and whether it is there. The odd/even model ends with Node 26, so the gap closes on its own.
295
333
  - Node.JS max header size is 16384 bytes, while uWebSockets by default is 4096 bytes, so if you need longer headers set the env variable `UWS_HTTP_MAX_HEADERS_SIZE` to max byte count you need.
296
334
  - uWebSockets drops a request whose body arrives slower than 16KB/s, and the timeout is not reachable from JavaScript, while Node.JS waits as long as the client needs. Uploads over very slow connections can therefore fail here and succeed on Express. A body stalled for 5 seconds still completes; one stalled for 12 seconds gets its socket reset at around 11.8 seconds.
297
335
 
@@ -667,8 +705,9 @@ Two of these keep a compiled form alongside the value, which you can also set di
667
705
  - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
668
706
  - `query parser fn`, likewise for `query parser`.
669
707
 
670
- Fulmine adds five of its own:
708
+ Fulmine adds six of its own:
671
709
 
710
+ - `etag methods`, unset by default. Express computes the generated ETag for every method, and so does this until told otherwise. `app.set("etag methods", ["GET", "HEAD"])` skips the digest on every other method, where freshness is not defined and the validator can never match: worth 21% here on a 4KB POST answer. An ETag set by hand still goes out whatever the method.
672
711
  - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
673
712
  - `connection headers`, on by default. Express sends `Connection: keep-alive` and `Keep-Alive` on every response, and so does this. Turn it off and neither goes out, while a connection the client asked to close still answers `Connection: close`: it is the advertisement that goes, not the truth. Worth 2% to 3.5% here on a route that is not compiled, plus the bytes.
674
713
  - `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. Turn it off where every request has to reach the disk, which is what a public benchmark asks of a standard entry: it was worth about 4% on a 4KB file here, so the cost of turning it off is small.
package/package.json CHANGED
@@ -1,15 +1,14 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.11.0",
3
+ "version": "5.12.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": {
7
- "fulmine": "src/cli.js",
8
- "fulmine.js": "src/cli.js"
7
+ "fulmine": "src/cli.js"
9
8
  },
10
9
  "scripts": {
11
10
  "test": "node tests/index.js",
12
- "test:unit": "node --test \"tests/unit/*.test.js\"",
11
+ "test:unit": "node --test --test-timeout=120000 \"tests/unit/*.test.js\"",
13
12
  "test:types": "tsd --files tests/types/*.test-d.ts",
14
13
  "test:express": "node tools/express-suite.js",
15
14
  "fuzz": "node tools/fuzz.js",
@@ -69,7 +68,7 @@
69
68
  "dependencies": {
70
69
  "@types/express": "^5.0.6",
71
70
  "accepts": "^2.0.0",
72
- "acorn": "^8.16.0",
71
+ "acorn": "^8.18.0",
73
72
  "bytes": "^3.1.2",
74
73
  "compressible": "^2.0.18",
75
74
  "content-disposition": "^1.1.0",
@@ -83,8 +82,8 @@
83
82
  "mime-types": "^3.0.2",
84
83
  "ms": "^2.1.3",
85
84
  "proxy-addr": "^2.0.7",
86
- "qs": "^6.15.2",
87
- "range-parser": "^1.2.1",
85
+ "qs": "^6.15.3",
86
+ "range-parser": "^1.3.0",
88
87
  "statuses": "^2.0.2",
89
88
  "tseep": "^1.3.1",
90
89
  "type-is": "^2.1.0",
@@ -92,7 +91,6 @@
92
91
  "vary": "^1.1.2"
93
92
  },
94
93
  "devDependencies": {
95
- "@codechecks/client": "^0.1.12",
96
94
  "@commitlint/cli": "^21.2.1",
97
95
  "@commitlint/config-conventional": "^21.2.0",
98
96
  "@eslint/js": "^10.0.1",
@@ -106,7 +104,7 @@
106
104
  "@types/fresh": "^0.5.3",
107
105
  "@types/mime-types": "^3.0.1",
108
106
  "@types/ms": "^2.1.0",
109
- "@types/node": "^25.9.1",
107
+ "@types/node": "^26.2.0",
110
108
  "@types/proxy-addr": "^2.0.3",
111
109
  "@types/statuses": "^2.0.6",
112
110
  "@types/type-is": "^1.6.7",
@@ -118,21 +116,21 @@
118
116
  "cookie-parser": "^1.4.7",
119
117
  "cookie-session": "^2.1.1",
120
118
  "cors": "^2.8.6",
121
- "ejs": "^3.1.10",
119
+ "ejs": "^6.0.1",
122
120
  "errorhandler": "^1.5.2",
123
121
  "eslint": "^10.8.0",
124
122
  "eslint-config-prettier": "^10.1.8",
125
- "eslint-plugin-jsdoc": "^63.3.2",
123
+ "eslint-plugin-jsdoc": "^64.1.0",
126
124
  "etag": "^1.8.1",
127
- "eventsource": "^4.1.0",
128
- "exit-hook": "^2.2.1",
125
+ "eventsource": "^5.0.0",
126
+ "exit-hook": "^5.1.0",
129
127
  "express": "^5",
130
128
  "express-art-template": "^1.0.1",
131
129
  "express-basic-auth": "^1.2.1",
132
130
  "express-dot-engine": "^1.0.8",
133
131
  "express-fast-json-stringify": "^1.3.0",
134
132
  "express-fileupload": "^1.5.2",
135
- "express-handlebars": "^8.0.7",
133
+ "express-handlebars": "^9.0.1",
136
134
  "express-http-proxy": "^2.1.2",
137
135
  "express-mongo-sanitize": "^2.2.0",
138
136
  "express-rate-limit": "^8.5.2",
@@ -143,20 +141,20 @@
143
141
  "globals": "^17.8.0",
144
142
  "graphql-http": "^1.22.4",
145
143
  "helmet": "^8.2.0",
146
- "http-proxy-middleware": "^3.0.5",
144
+ "http-proxy-middleware": "^4.2.0",
147
145
  "husky": "^9.1.7",
148
146
  "lint-staged": "^17.3.0",
149
147
  "method-override": "^3.0.0",
150
148
  "morgan": "^1.11.0",
151
149
  "multer": "^2.1.1",
152
150
  "mustache-express": "^1.3.2",
153
- "nyc": "^17.1.0",
151
+ "nyc": "^18.0.0",
154
152
  "on-finished": "^2.4.1",
155
153
  "on-headers": "^1.1.0",
156
- "pako": "^2.1.0",
154
+ "pako": "^3.0.1",
157
155
  "passport": "^0.7.0",
158
156
  "passport-local": "^1.0.0",
159
- "pkg-pr-new": "^0.0.75",
157
+ "pkg-pr-new": "^0.0.87",
160
158
  "prettier": "^3.9.6",
161
159
  "pug": "^3.0.4",
162
160
  "release-it": "^21.0.1",
@@ -26,7 +26,8 @@ const {
26
26
  createETagGenerator,
27
27
  fastQueryParse,
28
28
  durationSetting,
29
- NullObject
29
+ NullObject,
30
+ settingsEpoch
30
31
  } = require("./utils.js");
31
32
  const parseQuery = require("./parse-query.js");
32
33
  const Request = require("./request.js");
@@ -396,16 +397,22 @@ class Application extends Router {
396
397
  // express's wording, which applications match on
397
398
  throw new TypeError("unknown value for query parser function: " + value);
398
399
  }
399
- } else if (key === "etag") {
400
- // an etag arriving after listen would make send consult freshness headers the
401
- // header-skip routes never copied, so those skips are taken back
402
- if (value !== false && this._skipPresets?.size) {
403
- for (const preset of this._skipPresets) {
404
- preset.skipHeaders = false;
405
- preset.skipQuery = false;
406
- }
407
- this._skipPresets.clear();
400
+ } else if (key === "etag methods") {
401
+ // fulmine's own: the methods whose send() computes a generated ETag. Unset means all
402
+ // of them, which is express's behaviour and what its suite asserts per method; naming
403
+ // ["GET", "HEAD"] skips the digest everywhere a validator can never match, which
404
+ // measured +21% on a 4KB POST answer. See issue #10.
405
+ if (value != null && (!Array.isArray(value) || value.some((m) => typeof m !== "string"))) {
406
+ throw new TypeError('"etag methods" wants an array of method names, or null for all of them');
408
407
  }
408
+ value = value == null ? undefined : value.map((m) => m.toUpperCase());
409
+ } else if (key === "etag") {
410
+ // The skips are not taken back here. They used to be, because send consults freshness
411
+ // and the skip branch looked like it had not copied the headers for it, but that
412
+ // branch reads if-none-match, if-modified-since and cache-control by name whatever
413
+ // this setting says, see request.js:527, and req.fresh reads nothing else off the
414
+ // request. Registering a route or a middleware after listen still takes them back,
415
+ // see router.js:1615: that is a different question, about code the analysis never saw.
409
416
  if (typeof value === "function") {
410
417
  this.settings["etag fn"] = value;
411
418
  } else {
@@ -428,6 +435,8 @@ class Application extends Router {
428
435
  }
429
436
 
430
437
  this.settings[key] = value;
438
+ // any app's hot-settings copy may resolve through this one, see Router#_hot
439
+ settingsEpoch.n++;
431
440
  return this;
432
441
  }
433
442
 
@@ -520,13 +529,18 @@ class Application extends Router {
520
529
  _serveGeneric(res, req) {
521
530
  const request = this.handleRequest(res, req);
522
531
  const response = request.res;
523
- // armed up front here: this handler can outlive the callback on every path through it
524
- this._armAbort(res, response);
525
-
526
- this._routeRequestDirect(request, response);
527
- // the synchronous stretch has run under the cork uWS holds for this callback, and
528
- // whatever comes after it is outside
529
- response._corkNeeded = true;
532
+ try {
533
+ this._routeRequestDirect(request, response);
534
+ } finally {
535
+ // the synchronous stretch has run under the cork uWS holds for this callback, and
536
+ // whatever comes after it is outside
537
+ response._corkNeeded = true;
538
+ // an abort can only arrive after this callback returns, as the native handler's
539
+ // finally says: a response that finished inside it never needs uWS told at all
540
+ if (!response.finished) {
541
+ this._armAbort(res, response);
542
+ }
543
+ }
530
544
  }
531
545
 
532
546
  /**
package/src/cli.js CHANGED
@@ -85,10 +85,11 @@ const DIFFERENCES = [
85
85
  'Express sends X-Powered-By: Express unless told not to. Set app.set("x-powered-by", true) to send it.'
86
86
  ],
87
87
  [
88
- "a compiled route is framed differently and never answers 304",
89
- "A handler simple enough to be read at registration time is answered natively, which means chunked\n" +
90
- "framing with no Content-Length, and a conditional request gets the whole body rather than a 304.\n" +
91
- 'app.set("declarative responses", false) turns that off.'
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.'
92
93
  ],
93
94
  [
94
95
  "headers are capped at 4096 bytes by default",
@@ -493,9 +494,43 @@ ${error.stack ?? error}`);
493
494
  );
494
495
  return null;
495
496
  }
497
+ stopFileWorkers(apps);
496
498
  return { apps, entry };
497
499
  }
498
500
 
501
+ /**
502
+ * Ends the file-reading threads that building an application started.
503
+ *
504
+ * An Application starts one per `threads` in its constructor, and these commands only ever read
505
+ * what compiling the routes decided: nothing here serves a file, so nothing here needs a thread.
506
+ * They are unref'd, so leaving them would not hang the process, but they are threads holding the
507
+ * library the application loaded, and this command is often not the whole process. It also stops
508
+ * them outliving the directory they were loaded from, which is how a test that profiles a copy and
509
+ * then removes it saw "Cannot find module .../src/worker.js" arrive after it had finished.
510
+ *
511
+ * Best effort throughout: a build with no workers, or a worker already gone, is not an error here.
512
+ *
513
+ * @param {any[]} apps
514
+ * @returns {void}
515
+ */
516
+ function stopFileWorkers(apps) {
517
+ const seen = new Set();
518
+ for (const app of apps) {
519
+ for (const holder of app?.workers ?? []) {
520
+ const worker = holder?.worker;
521
+ if (!worker || seen.has(worker)) {
522
+ continue;
523
+ }
524
+ seen.add(worker);
525
+ try {
526
+ worker.terminate();
527
+ } catch {
528
+ // a thread that never started, or already ended, needs nothing
529
+ }
530
+ }
531
+ }
532
+ }
533
+
499
534
  /**
500
535
  * Loads an application without letting it listen, and prints what compiling its routes decided.
501
536
  *
@@ -50,6 +50,10 @@ const typeValueOf = (type) => (type.indexOf("/") === -1 ? contentTypeFor(type) :
50
50
  const MAX_INSTRUCTION_LENGTH = 65535;
51
51
 
52
52
  // the three that write a body, of which only one may appear
53
+ // The headers a conditional request is answered from. A compiled response cannot read the request,
54
+ // so it cannot honour one, and a handler that sets one has to stay on the ordinary path.
55
+ const VALIDATOR_HEADERS = new Set(["etag", "last-modified"]);
56
+
53
57
  const bodyMethods = new Set(["send", "json", "end"]);
54
58
  // and the four that finish the response, after which nothing a handler does is observable
55
59
  const terminalMethods = new Set(["send", "json", "end", "sendStatus"]);
@@ -734,10 +738,13 @@ module.exports = function compileDeclarative(cb, app) {
734
738
  }
735
739
 
736
740
  for (const header of headers) {
737
- if (header[0].toLowerCase() === "content-length") {
741
+ const name = header[0].toLowerCase();
742
+ if (name === "content-length") {
738
743
  return false;
739
744
  }
740
- decRes = decRes.writeHeader(header[0], header[1]);
745
+ // lowercased as the ordinary path stores every name, so the two paths answer the
746
+ // same bytes whatever casing the handler wrote, see issue #7
747
+ decRes = decRes.writeHeader(name, header[1]);
741
748
  }
742
749
 
743
750
  // sendStatus sends the status message as its body. It has to join `body` here, before the
@@ -748,24 +755,22 @@ module.exports = function compileDeclarative(cb, app) {
748
755
  body.push({ type: "text", value: statuses.message[statusCode] || String(statusCode) });
749
756
  }
750
757
 
751
- // an empty body gets no ETag, which is what Express does and what the ordinary path here
752
- // already did
753
- if (
754
- body.length &&
755
- (bodyFromSend || sendStatusUsed) &&
756
- app.get("etag") &&
757
- !headers.some((header) => header[0].toLowerCase() === "etag")
758
- ) {
759
- if (body.some((part) => part.type !== "text")) {
760
- return false;
761
- } else {
762
- const etag = app.get("etag fn")(body.map((part) => part.value.toString()).join(""));
763
- // an application's own etag function is allowed to decline, and a declarative
764
- // response cannot answer with a header whose value is nothing
765
- if (etag) {
766
- decRes = decRes.writeHeader("ETag", etag);
767
- }
768
- }
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.
767
+ if (headers.some((header) => VALIDATOR_HEADERS.has(header[0].toLowerCase()))) {
768
+ return false;
769
+ }
770
+ // an empty body gets no ETag, in Express and on the ordinary path here, so it has nothing
771
+ // to lose by being compiled
772
+ if (body.length && (bodyFromSend || sendStatusUsed) && app.get("etag")) {
773
+ return false;
769
774
  }
770
775
 
771
776
  // No Content-Length header here: uWS writes the framing itself, and a response carrying
package/src/index.js CHANGED
@@ -35,6 +35,15 @@ try {
35
35
  // older uWS builds do not expose _cfg; there is nothing to fall back to
36
36
  }
37
37
 
38
+ try {
39
+ // the compile cache, in node since 22.8: the next boot of the same code skips compiling it.
40
+ // Asked for here because in practice the framework is the entry point of the application
41
+ // using it. Respects NODE_DISABLE_COMPILE_CACHE, and booting without a cache is not an error
42
+ require("node:module").enableCompileCache?.();
43
+ } catch (error) {
44
+ // node below 22.8, or a disk the cache cannot be written to
45
+ }
46
+
38
47
  // The factory doubles as a namespace, as in Express: Router, static and the body parsers hang off
39
48
  // the function that creates an app.
40
49
  //
@@ -935,13 +935,16 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
935
935
  // upstream middleware's AsyncLocalStorage must still be there when next runs
936
936
  next = AsyncResource.bind(next);
937
937
 
938
- // with a known content-length and nothing to decompress, uWS can collect the whole
939
- // body in native code: one callback instead of one per chunk, the limit enforced
940
- // before any byte reaches JS, and no copy at all - the parsers turn the bytes into
941
- // req.body before the callback returns, so a view over uWS's own memory is enough
942
- if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
938
+ // with nothing to decompress, uWS can collect the whole body in native code: one
939
+ // callback instead of one per chunk, the limit enforced before any byte reaches JS,
940
+ // and no copy at all - the parsers turn the bytes into req.body before the callback
941
+ // returns, so a view over uWS's own memory is enough. A declared length was the
942
+ // original case; a chunked body accumulates in the same native vector and only loses
943
+ // the length check, since there is no declaration to hold it to
944
+ const declared = Number(length);
945
+ const declaresLength = !isNaN(declared) && declared > 0;
946
+ if (!req.receivedData && !inflate && req._res.collectBody && (declaresLength || isNaN(declared))) {
943
947
  req.bodyRead = true;
944
- const declared = Number(length);
945
948
  req._res.collectBody(limit, (body) => {
946
949
  if (body === null) {
947
950
  // over maxSize: uWS refused it natively
@@ -952,7 +955,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
952
955
  })
953
956
  );
954
957
  }
955
- if (body.byteLength !== declared) {
958
+ if (declaresLength && body.byteLength !== declared) {
956
959
  return next(
957
960
  bodyError("request size did not match content length", 400, "request.size.invalid", {
958
961
  expected: declared,
package/src/request.js CHANGED
@@ -592,6 +592,10 @@ module.exports = class Request extends LazyReadable {
592
592
  this._isOptions = this.method === "OPTIONS";
593
593
  this._isHead = this.method === "HEAD";
594
594
  }
595
+ // the folded _opPath and the percent scan of _originalPath, built on the hop that first
596
+ // wants them and dropped by every rewrite, see _pathMatches and Walk#dispatch
597
+ this._opPathLower = null;
598
+ this._mayFailDecode = null;
595
599
  this.params = {};
596
600
 
597
601
  // Two Sets per request, for two things almost no request needs.
@@ -812,7 +816,7 @@ module.exports = class Request extends LazyReadable {
812
816
  * meant to carry one value, but nothing stops a proxy from appending.
813
817
  */
814
818
  get #authority() {
815
- const trust = this.app.get("trust proxy fn");
819
+ const trust = this.app._hot().trustProxyFn;
816
820
  // parsedIp is what connection.remoteAddress carries, without building the socket stand-in
817
821
  const isTrusted = !!(trust && trust(this.parsedIp, 0));
818
822
  const rawHeader = (isTrusted && this.headers["x-forwarded-host"]) || this.headers["host"];
@@ -886,7 +890,7 @@ module.exports = class Request extends LazyReadable {
886
890
  * @returns {string|undefined} undefined on a unix socket, which has no address
887
891
  */
888
892
  get ip() {
889
- const trust = this.app.get("trust proxy fn");
893
+ const trust = this.app._hot().trustProxyFn;
890
894
  if (!trust) {
891
895
  return this.parsedIp;
892
896
  }
@@ -899,7 +903,7 @@ module.exports = class Request extends LazyReadable {
899
903
  * @returns {string[]}
900
904
  */
901
905
  get ips() {
902
- const trust = this.app.get("trust proxy fn");
906
+ const trust = this.app._hot().trustProxyFn;
903
907
  if (!trust) {
904
908
  return [];
905
909
  }
@@ -918,7 +922,7 @@ module.exports = class Request extends LazyReadable {
918
922
  // own ssl flag answers when nothing has built the stand-in yet
919
923
  const conn = this.#cachedConnection;
920
924
  const proto = (conn ? conn.encrypted : this.app.ssl) ? "https" : "http";
921
- const trust = this.app.get("trust proxy fn");
925
+ const trust = this.app._hot().trustProxyFn;
922
926
  if (!trust) {
923
927
  return proto;
924
928
  }
@@ -956,6 +960,8 @@ module.exports = class Request extends LazyReadable {
956
960
  this.path = newPath;
957
961
  this.endsWithSlash = newPath.charCodeAt(newPath.length - 1) === 0x2f;
958
962
  this._opPath = newPath;
963
+ this._opPathLower = null;
964
+ this._mayFailDecode = null;
959
965
  this._lastUrl = newUrl;
960
966
  }
961
967
 
@@ -985,7 +991,7 @@ module.exports = class Request extends LazyReadable {
985
991
  * @returns {Record<string, any>}
986
992
  */
987
993
  get query() {
988
- const qp = this.app.get("query parser fn");
994
+ const qp = this.app._hot().queryParserFn;
989
995
  // the vendored default already answers on a bare null prototype, so it goes out as is;
990
996
  // any other parser is copied onto one, which is what kept fast-querystring's result from
991
997
  // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
@@ -1053,7 +1059,7 @@ module.exports = class Request extends LazyReadable {
1053
1059
  */
1054
1060
  _readRawIp() {
1055
1061
  const uwsRes = this._res;
1056
- if (this.app.get("trust proxy protocol")) {
1062
+ if (this.app._hot().trustProxyProtocol) {
1057
1063
  const proxied = uwsRes.getProxiedRemoteAddress();
1058
1064
  // empty unless a preamble arrived, which is the only thing that tells the two apart
1059
1065
  if (proxied.byteLength !== 0) {
package/src/response.js CHANGED
@@ -31,6 +31,9 @@ const {
31
31
  isPreconditionFailure,
32
32
  isRangeFresh,
33
33
  escapeHtml,
34
+ validateHeaderName,
35
+ validateHeaderValue,
36
+ headerIsWritable,
34
37
  withDefaultCharset,
35
38
  withUtf8Charset,
36
39
  asStatError,
@@ -305,7 +308,7 @@ module.exports = class Response extends LazyWritable {
305
308
  if (req._connectionClose) {
306
309
  this.headers.connection = "close";
307
310
  }
308
- if (this.app.get("x-powered-by")) {
311
+ if (app._hot().xPoweredBy) {
309
312
  this.headers["x-powered-by"] = "Fulmine";
310
313
  }
311
314
 
@@ -613,6 +616,10 @@ module.exports = class Response extends LazyWritable {
613
616
  * not one of them: uWS wants the length through tryEnd or endWithoutBody, so it is taken out
614
617
  * here and kept on totalSize, where it also turns chunked framing off.
615
618
  *
619
+ * One writeHeader per header on purpose. Packing the whole head into a single writeStatus
620
+ * works on the wire, and was measured slower: constant header strings cross the boundary
621
+ * already flat, while the packed head is concatenated fresh per response, see issue #11.
622
+ *
616
623
  * @param {boolean} utf8 unused, kept because node's equivalent takes it and the two callers
617
624
  * differ on what they know about the body
618
625
  */
@@ -881,8 +888,18 @@ module.exports = class Response extends LazyWritable {
881
888
  // req.fresh, which compares If-None-Match against it.
882
889
  // body is defined by the time it gets here, so an empty one still earns an ETag. Testing
883
890
  // its truthiness instead meant send("") and send(null) came back without one.
884
- const etagFn = this.app.get("etag fn");
885
- if (etagFn && !this.headers["etag"] && !this.req.noEtag) {
891
+ // Every method by default, not only GET and HEAD: gating it looked safe, freshness being
892
+ // defined over those two alone, and express's own suite failed on it, "should send ETag
893
+ // in response to <METHOD> request" exists per method. The "etag methods" setting is that
894
+ // gate as an opt-in, see issue #10.
895
+ const hot = this.app._hot();
896
+ const etagFn = hot.etagFn;
897
+ if (
898
+ etagFn &&
899
+ !this.headers["etag"] &&
900
+ !this.req.noEtag &&
901
+ (hot.etagMethods === null || hot.etagMethods.has(this.req.method))
902
+ ) {
886
903
  const etag = etagFn(body);
887
904
  // an application's own etag function is allowed to decline: returning nothing means no
888
905
  // header, rather than a header saying "undefined"
@@ -1318,27 +1335,51 @@ module.exports = class Response extends LazyWritable {
1318
1335
  * @param {any} value an array sends the header once per entry
1319
1336
  * @returns {this}
1320
1337
  * @throws {Error} once the headers have gone out
1321
- * @throws {TypeError} if the name is not a string
1338
+ * @throws {TypeError} if the name is not a token, the value is undefined, or the value holds a
1339
+ * character that cannot go on the wire
1322
1340
  */
1323
1341
  setHeader(field, value) {
1324
1342
  if (this.headersSent) {
1325
1343
  throw new Error("Cannot set headers after they are sent to the client");
1326
1344
  }
1327
- if (typeof field !== "string") {
1328
- throw new TypeError("Header name must be a valid HTTP token");
1329
- } else {
1330
- field = field.toLowerCase();
1331
- if (Array.isArray(value)) {
1332
- // each entry as text, as node serialises them: a raw number reaching uWS's
1333
- // writeHeader would throw mid-response
1334
- this.headers[field] = value.map(String);
1335
- return this;
1336
- }
1337
- this.headers[field] = String(value);
1345
+ validateHeaderName(field);
1346
+ if (value === undefined) {
1347
+ /** @type {NodeJS.ErrnoException} */
1348
+ const err = new TypeError(`Invalid value "undefined" for header "${field}"`);
1349
+ err.code = "ERR_HTTP_INVALID_HEADER_VALUE";
1350
+ throw err;
1338
1351
  }
1352
+ // each entry as text, as node serialises them: a raw number reaching uWS's writeHeader
1353
+ // would throw mid-response. Coerced before it is checked, since that is the string the
1354
+ // wire gets, and checked before it is stored: a value that got in here would be written by
1355
+ // whatever flushes next, and on the error path that is a second throw with nobody left to
1356
+ // catch it
1357
+ const out = Array.isArray(value) ? value.map(String) : String(value);
1358
+ validateHeaderValue(field, out);
1359
+ this.headers[field.toLowerCase()] = out;
1339
1360
  return this;
1340
1361
  }
1341
1362
 
1363
+ /**
1364
+ * Throws away any header that could not be written, so that flushing this response cannot fail
1365
+ * on one. setHeader refuses these on the way in, but `res.headers` is the live object, so an
1366
+ * assignment into that still gets a value in here.
1367
+ *
1368
+ * Only the error page calls it. A throw out of the flush there is not recoverable: the error
1369
+ * page is what runs after a throw, so it would be the second one, with nobody left to catch
1370
+ * it, and on the node shim that is the process. Everything writable is left alone, since a
1371
+ * middleware's own headers belong on the error response too.
1372
+ *
1373
+ * @returns {void}
1374
+ */
1375
+ _dropUnwritableHeaders() {
1376
+ for (const header in this.headers) {
1377
+ if (!headerIsWritable(header, this.headers[header])) {
1378
+ delete this.headers[header];
1379
+ }
1380
+ }
1381
+ }
1382
+
1342
1383
  /**
1343
1384
  * Hands the status line and the headers over now, without waiting for a body, which is node's
1344
1385
  * flushHeaders(). Callers use it to let the client start on the head while the body is still
@@ -1387,10 +1428,6 @@ module.exports = class Response extends LazyWritable {
1387
1428
  * @param {() => void} [callback]
1388
1429
  * @returns {void}
1389
1430
  */
1390
-
1391
- /**
1392
- *
1393
- */
1394
1431
  writeEarlyHints(hints, callback) {
1395
1432
  this.#refuseInformationAfterHead();
1396
1433
  // node writes the hints first and calls back after, so a caller that sequences work on it
@@ -1460,7 +1497,7 @@ module.exports = class Response extends LazyWritable {
1460
1497
  }
1461
1498
 
1462
1499
  /**
1463
- * node's `assignSocket` and `detachSocket`, which the http server uses when a response is
1500
+ * node's `assignSocket`, which the http server uses when a response is
1464
1501
  * handed a raw socket. There is no such socket here.
1465
1502
  *
1466
1503
  * @param {any} [socket]
@@ -1469,6 +1506,8 @@ module.exports = class Response extends LazyWritable {
1469
1506
  assignSocket(socket) {}
1470
1507
 
1471
1508
  /**
1509
+ * node's `detachSocket`, which the http server uses when a response is
1510
+ * handed a raw socket. There is no such socket here.
1472
1511
  * @param {any} [socket]
1473
1512
  * @returns {void}
1474
1513
  */
@@ -1595,11 +1634,11 @@ module.exports = class Response extends LazyWritable {
1595
1634
  this.set(header, field[header]);
1596
1635
  }
1597
1636
  } else {
1598
- field = field.toLowerCase();
1637
+ const name = field.toLowerCase();
1599
1638
  // a header is text on the wire whatever it was here, and Express coerces at this point,
1600
1639
  // so res.get answers what was sent rather than the number or object it was given
1601
1640
  let out = Array.isArray(value) ? value.map(String) : String(value);
1602
- if (field === "content-type") {
1641
+ if (name === "content-type") {
1603
1642
  if (Array.isArray(out)) {
1604
1643
  throw new TypeError("Content-Type cannot be set to an Array");
1605
1644
  }
@@ -1607,6 +1646,9 @@ module.exports = class Response extends LazyWritable {
1607
1646
  // missing application/manifest+json among others, which Express does charset.
1608
1647
  out = withDefaultCharset(out);
1609
1648
  }
1649
+ // the name as it was written, not the lowercased one: setHeader lowercases it itself,
1650
+ // and it is the name that a refused header is reported by, which Express takes from
1651
+ // what the caller passed
1610
1652
  this.setHeader(field, out);
1611
1653
  }
1612
1654
  return this;
@@ -1643,12 +1685,15 @@ module.exports = class Response extends LazyWritable {
1643
1685
  }
1644
1686
 
1645
1687
  /**
1646
- * Every header set so far, as the object they are kept in rather than a copy, so writing to
1647
- * it writes to the response.
1688
+ * Every header set so far, as a shallow copy on a null prototype, which is what node's
1689
+ * OutgoingMessage answers. It used to hand out the live object, and a write into that
1690
+ * reached the wire without setHeader's validation, see issue #6; nothing in here or in the
1691
+ * middleware that was checked relies on the live one, so the copy costs an allocation on a
1692
+ * method the framework itself never calls.
1648
1693
  * @returns {Record<string, any>}
1649
1694
  */
1650
1695
  getHeaders() {
1651
- return this.headers;
1696
+ return Object.assign({ __proto__: null }, this.headers);
1652
1697
  }
1653
1698
 
1654
1699
  /**
@@ -1837,10 +1882,8 @@ module.exports = class Response extends LazyWritable {
1837
1882
  if (!this.headers["content-type"]) {
1838
1883
  this.headers["content-type"] = "application/json; charset=utf-8";
1839
1884
  }
1840
- const escape = this.app.get("json escape");
1841
- const replacer = this.app.get("json replacer");
1842
- const spaces = this.app.get("json spaces");
1843
- return this.send(stringify(body, replacer, spaces, escape));
1885
+ const hot = this.app._hot();
1886
+ return this.send(stringify(body, hot.jsonReplacer, hot.jsonSpaces, hot.jsonEscape));
1844
1887
  }
1845
1888
 
1846
1889
  /**
@@ -2002,7 +2045,8 @@ module.exports = class Response extends LazyWritable {
2002
2045
  type(type) {
2003
2046
  const ct = type.indexOf("/") === -1 ? contentTypeFor(type) : type;
2004
2047
 
2005
- return this.set("content-type", ct);
2048
+ // the name Express passes, since a refused value is reported by the name it was set under
2049
+ return this.set("Content-Type", ct);
2006
2050
  }
2007
2051
 
2008
2052
  /**
package/src/router.js CHANGED
@@ -28,7 +28,8 @@ const {
28
28
  uwsPrefersEarlier,
29
29
  regexpGroupKeys,
30
30
  NullObject,
31
- EMPTY_REGEX
31
+ EMPTY_REGEX,
32
+ settingsEpoch
32
33
  } = require("./utils.js");
33
34
  const Response = require("./response.js");
34
35
  const Request = require("./request.js");
@@ -47,6 +48,26 @@ const HAS_LETTER = /[a-zA-Z]/;
47
48
  // hands out one number per app.route(), so the routes it creates know they belong together
48
49
  let routeGroups = 0;
49
50
 
51
+ // The settings the request and response hot paths read, resolved to plain fields: each read was a
52
+ // variadic get() whose rest array escapes into createRoute, plus a dictionary miss per mount level
53
+ // for the json keys, which have no default. One shape for every router, stale when the epoch moves.
54
+ class HotSettings {
55
+ /** Every field declared up front, one hidden class for every router's copy. */
56
+ constructor() {
57
+ this.epoch = 0;
58
+ this.xPoweredBy = false;
59
+ this.etagFn = undefined;
60
+ // null means every method, which is express's behaviour and the default
61
+ this.etagMethods = null;
62
+ this.queryParserFn = undefined;
63
+ this.trustProxyFn = undefined;
64
+ this.trustProxyProtocol = false;
65
+ this.jsonEscape = undefined;
66
+ this.jsonReplacer = undefined;
67
+ this.jsonSpaces = undefined;
68
+ }
69
+ }
70
+
50
71
  /**
51
72
  * Whether an earlier route would have answered this path had case not mattered. A guard is a
52
73
  * folded string when the earlier path is a literal, and an insensitive pattern when it has
@@ -207,8 +228,12 @@ class Walk {
207
228
  // express matches a layer's path before it looks at the method, and decodes the
208
229
  // parameters there, so a malformed escape answers 400 even when no route of this
209
230
  // method exists. Only a path carrying a percent can produce one, and that check keeps
210
- // every other request from matching routes it could never run
211
- const mayFailDecode = req._originalPath.indexOf("%") !== -1;
231
+ // every other request from matching routes it could never run. Scanned once per
232
+ // rewrite and kept on the request: a middleware-heavy chain scanned it per hop
233
+ const mayFailDecode = (req._mayFailDecode ??= req._originalPath.indexOf("%") !== -1);
234
+ // frozen here, once per scan: _pathMatches reads the two flags as bare fields, and
235
+ // calling this per route measured 0.45us of a scan of four hundred
236
+ router._freezeRoutingFlags();
212
237
  // written out rather than through a predicate handed to findIndexStartingFrom, which
213
238
  // was one closure per hop of every request not on a compiled chain
214
239
  for (; routeIndex < routes.length; routeIndex++) {
@@ -637,20 +662,12 @@ function mountPrefixLength(route, req) {
637
662
  */
638
663
  function setMountedPath(req) {
639
664
  req._opPath = req._consumed === 0 ? req._originalPath : req._originalPath.slice(req._consumed);
665
+ req._opPathLower = null;
640
666
  req.url = req._opPath === "" ? "/" + req.urlQuery : req._opPath + req.urlQuery;
641
667
  req.path = req._opPath === "" ? "/" : req._opPath;
642
668
  req._lastUrl = req.url;
643
669
  }
644
670
 
645
- /**
646
- * The route's own params merged with those of the mounts it sits under, in express's order: an
647
- * outer mount first, the route's own last. Numbered captures do not overwrite each other, they
648
- * shift, so a RegExp mount capturing one group leaves the route's own group numbered from one.
649
- *
650
- * @param {Record<string, any>} own what this route's own pattern captured
651
- * @param {Record<string, any>[]} stack the mounts, outermost first
652
- * @returns {Record<string, any>}
653
- */
654
671
  /**
655
672
  * Whether this route reads the parameters of the mounts above it, which is its own router asking
656
673
  * for them. The stack holds what a mergeParams router captured on the way in, and a plain router
@@ -763,6 +780,8 @@ function adoptPlainRequest(req, router) {
763
780
  req._originalPath = path;
764
781
  req.endsWithSlash = path.charCodeAt(path.length - 1) === 0x2f;
765
782
  req._opPath = path;
783
+ req._opPathLower = null;
784
+ req._mayFailDecode = null;
766
785
  req._lastUrl = req.url;
767
786
  req._isOptions = req.method === "OPTIONS";
768
787
  req._isHead = req.method === "HEAD";
@@ -778,13 +797,6 @@ function adoptPlainRequest(req, router) {
778
797
  req.app = req.app ?? router;
779
798
  }
780
799
 
781
- /**
782
- * Refuses a handler that could never be called, where it was written rather than on the first
783
- * request that reaches it. Express words both of these and applications match on the text.
784
- *
785
- * @param {any[]} handlers
786
- * @param {string} [emptyMessage] app.use() says it its own way
787
- */
788
800
  /**
789
801
  * The uWS onAborted handler, bound to the response: a closure here captured two locals and cost
790
802
  * a context plus a function per request, for a path that only ever runs on a client abort.
@@ -875,13 +887,6 @@ const CALLBACK_PLAIN = 0;
875
887
  const CALLBACK_ERROR = 1;
876
888
  const CALLBACK_ROUTER = 2;
877
889
 
878
- /**
879
- * Hands the request and the response to the app about to handle them, so that a mounted sub-app's
880
- * settings decide what its responses do. Express re-parents both objects for the same reason.
881
- *
882
- * @param {any} req
883
- * @param {any} app
884
- */
885
890
  /**
886
891
  * Reports a parameter that will not decode, unless something is already being reported.
887
892
  *
@@ -1083,6 +1088,7 @@ function restoreApp(route, req) {
1083
1088
  }
1084
1089
 
1085
1090
  /**
1091
+ * useApp
1086
1092
  * @param {any} req
1087
1093
  * @param {any} app
1088
1094
  */
@@ -1259,6 +1265,12 @@ module.exports = class Router extends EventEmitter {
1259
1265
  /** @type {boolean|undefined} */
1260
1266
  _caseFlag;
1261
1267
 
1268
+ /**
1269
+ * The hot-path settings resolved to fields, good while the epoch stands, see _hot().
1270
+ * @type {HotSettings}
1271
+ */
1272
+ _hotSettings = new HotSettings();
1273
+
1262
1274
  /**
1263
1275
  * @param {object} [settings] router options. caseSensitive and strict are accepted under the
1264
1276
  * names Express's Router takes, and stored under the setting names the rest of the code reads
@@ -1391,6 +1403,33 @@ module.exports = class Router extends EventEmitter {
1391
1403
  return this.createRoute("GET", path, this, ...callbacks);
1392
1404
  }
1393
1405
 
1406
+ /**
1407
+ * The settings the hot path reads, as fields on one object rather than a get() per read.
1408
+ * Refreshed through get(), parent fallback and all, when the epoch says a set() or a mount
1409
+ * happened anywhere since they were resolved; until then a read is a monomorphic field load.
1410
+ *
1411
+ * @returns {HotSettings}
1412
+ */
1413
+ _hot() {
1414
+ const hot = this._hotSettings;
1415
+ if (hot.epoch === settingsEpoch.n) {
1416
+ return hot;
1417
+ }
1418
+ hot.xPoweredBy = !!this.get("x-powered-by");
1419
+ hot.etagFn = this.get("etag fn");
1420
+ // a Set here, an array in the settings: send() asks per response, set() runs once
1421
+ const etagMethods = this.get("etag methods");
1422
+ hot.etagMethods = etagMethods == null ? null : new Set(etagMethods);
1423
+ hot.queryParserFn = this.get("query parser fn");
1424
+ hot.trustProxyFn = this.get("trust proxy fn");
1425
+ hot.trustProxyProtocol = !!this.get("trust proxy protocol");
1426
+ hot.jsonEscape = this.get("json escape");
1427
+ hot.jsonReplacer = this.get("json replacer");
1428
+ hot.jsonSpaces = this.get("json spaces");
1429
+ hot.epoch = settingsEpoch.n;
1430
+ return hot;
1431
+ }
1432
+
1394
1433
  /**
1395
1434
  * A routing flag, read once and kept. Express builds a router's matcher the first time the
1396
1435
  * router is needed and hands it caseSensitive and strict there, so a mount that happens after
@@ -1504,9 +1543,13 @@ module.exports = class Router extends EventEmitter {
1504
1543
  if (pattern === "/*") {
1505
1544
  return true;
1506
1545
  }
1507
- if (!this._caseSensitive()) {
1508
- path = path.toLowerCase();
1509
- pattern = pattern.toLowerCase();
1546
+ // bare fields, frozen by dispatch once per scan: even the freeze's own undefined
1547
+ // check measured 0.45us per request on a scan of four hundred routes
1548
+ if (!this._caseFlag) {
1549
+ // the pattern was folded at registration. The path is folded once per rewrite and
1550
+ // kept on the request, not folded again per route: every _opPath write drops it
1551
+ pattern = /** @type {string} */ (route.patternLower);
1552
+ path = req._opPathLower ??= path.toLowerCase();
1510
1553
  }
1511
1554
  if (pattern === path) {
1512
1555
  return true;
@@ -1515,7 +1558,7 @@ module.exports = class Router extends EventEmitter {
1515
1558
  // pattern would have carried as "/?" is allowed here instead. The registered path has
1516
1559
  // had its own taken off already, unless it is the root
1517
1560
  return (
1518
- !this._strictRouting() &&
1561
+ !this._strictFlag &&
1519
1562
  path.length === pattern.length + 1 &&
1520
1563
  path.charCodeAt(path.length - 1) === 0x2f &&
1521
1564
  path.startsWith(pattern)
@@ -1572,13 +1615,17 @@ module.exports = class Router extends EventEmitter {
1572
1615
  if (path === "*") {
1573
1616
  path = "/{*splat}";
1574
1617
  }
1618
+ const pattern =
1619
+ method === "USE" || needsConversionToRegex(path)
1620
+ ? patternToRegex(path, method === "USE", this._caseSensitive(), this._strictRouting())
1621
+ : path;
1575
1622
  const route = {
1576
1623
  method: method === "USE" ? "ALL" : method,
1577
1624
  path,
1578
- pattern:
1579
- method === "USE" || needsConversionToRegex(path)
1580
- ? patternToRegex(path, method === "USE", this._caseSensitive(), this._strictRouting())
1581
- : path,
1625
+ pattern,
1626
+ // folded here once: _pathMatches compares insensitively per route per hop, and
1627
+ // the registered text never changes. null for a compiled pattern
1628
+ patternLower: typeof pattern === "string" ? pattern.toLowerCase() : null,
1582
1629
  callbacks,
1583
1630
  // instanceof walks a prototype chain and length is a property load, and both used
1584
1631
  // to run for every callback of every hop
@@ -1862,11 +1909,17 @@ module.exports = class Router extends EventEmitter {
1862
1909
  // computed for whichever route µWS lands on runs everything that could have
1863
1910
  // matched before it
1864
1911
  } else if (
1865
- (canBeOptimized(route.path) ||
1866
- // parameters that are whole segments are matched by µWS the same way
1867
- (canBeOptimizedWithParams(route.path) &&
1868
- // inside a mounted router, only when nothing after it could match
1869
- (!pathPrefix || !router._isFollowedByAnOverlap(route, router._routes)))) &&
1912
+ // parameters that are whole segments are matched by µWS the same way
1913
+ (canBeOptimized(route.path) || canBeOptimizedWithParams(route.path)) &&
1914
+ // Inside a mounted router, only when nothing after it could answer the same
1915
+ // path. This used to be asked of parameter routes alone, and a literal one
1916
+ // needs it just as much: µWS picks by specificity where Express picks by
1917
+ // registration order, and a chain carries only what runs in front of its
1918
+ // route, so `router.get("/a", (req, res, next) => next())` followed by
1919
+ // `router.get("/:x", ...)` left the mount instead of reaching the second
1920
+ // route, and answered 404 where Express answers it. Found by the fuzzer,
1921
+ // replay with --seed 221940161 --rounds 1.
1922
+ (!pathPrefix || !router._isFollowedByAnOverlap(route, router._routes)) &&
1870
1923
  supportedUwsMethods.has(route.method)
1871
1924
  ) {
1872
1925
  // something outside this router, written before the mount it is in, that could
@@ -1929,7 +1982,7 @@ module.exports = class Router extends EventEmitter {
1929
1982
  }
1930
1983
  } else if (!supportedUwsMethods.has(route.method)) {
1931
1984
  route._whyGeneric = `µWS does not serve ${route.method}`;
1932
- } else if (canBeOptimizedWithParams(route.path)) {
1985
+ } else if (canBeOptimized(route.path) || canBeOptimizedWithParams(route.path)) {
1933
1986
  // eligible but for the overlap test, which only applies inside a mount
1934
1987
  route._whyGeneric = "a route after it in the same mounted router could answer the same paths";
1935
1988
  } else {
@@ -2108,14 +2161,20 @@ module.exports = class Router extends EventEmitter {
2108
2161
  const strictHere = (route.owner ?? this)._strictRouting();
2109
2162
 
2110
2163
  // Whether requests served by this registration may skip the header copy: GET and its
2111
- // HEAD twins only, the app must not compute etags (send would consult freshness
2112
- // headers), no error middleware may exist anywhere (a throw hands the request to code
2113
- // the analysis never saw), and every callback in the chain has to pass the source
2164
+ // HEAD twins only, no error middleware may exist anywhere (a throw hands the request to
2165
+ // code the analysis never saw), and every callback in the chain has to pass the source
2114
2166
  // analysis in usage.js, whose default answer is no.
2167
+ //
2168
+ // The etag setting is not one of the conditions. It used to be, on the grounds that send
2169
+ // consults freshness, but the skip branch reads if-none-match, if-modified-since and
2170
+ // cache-control by name whatever the setting, see the comment at request.js:527, and
2171
+ // req.fresh reads nothing else off the request. Requiring etag off as well cost the copy
2172
+ // to every application that left it on, which is every application that did not go
2173
+ // looking for the setting.
2115
2174
  const NO_SKIPS = { skipHeaders: false, skipQuery: false };
2116
2175
  let getSkips = NO_SKIPS;
2117
2176
  let headSkips = NO_SKIPS;
2118
- if (route.method === "GET" && this.get("etag") === false) {
2177
+ if (route.method === "GET") {
2119
2178
  let hasErr = this._hasErrMwCache;
2120
2179
  if (hasErr === undefined) {
2121
2180
  hasErr = this._hasErrMwCache = hasErrorMiddleware(this);
@@ -2594,6 +2653,9 @@ module.exports = class Router extends EventEmitter {
2594
2653
  callback.mountpath = /** @type {string|string[]} */ (path === "" ? "/" : path);
2595
2654
  callback.parent = this;
2596
2655
  callback.emit("mount", this);
2656
+ // what the child resolves through its parent just changed, so every kept
2657
+ // resolution is stale
2658
+ settingsEpoch.n++;
2597
2659
  }
2598
2660
  }
2599
2661
  this.createRoute("USE", path, this, ...callbacks);
@@ -2673,6 +2735,9 @@ module.exports = class Router extends EventEmitter {
2673
2735
  _sendErrorPage(request, response, err, checkEnv = false) {
2674
2736
  err = this._generateErrorPage(err, response.statusCode, checkEnv);
2675
2737
  request.noEtag = true;
2738
+ // a header that cannot be written is what brought the request here in the first place when
2739
+ // the throw came out of the flush, and writing it again would throw with nobody left
2740
+ response._dropUnwritableHeaders();
2676
2741
  response.setHeader("Content-Type", "text/html; charset=utf-8");
2677
2742
  response.setHeader("X-Content-Type-Options", "nosniff");
2678
2743
  response.setHeader("Content-Security-Policy", "default-src 'none'");
package/src/utils.js CHANGED
@@ -961,6 +961,11 @@ const defaultSettings = {
961
961
  "connection headers": true
962
962
  };
963
963
 
964
+ // Moved by Application#set and by a mount, whichever app they happen on: a router cannot know
965
+ // which mounted children resolve a setting through it, so instead of walking them the resolved
966
+ // copies everywhere go stale at once and are re-read on their next use, see Router#_hot
967
+ const settingsEpoch = { n: 1 };
968
+
964
969
  // What a file's stat was, for as long as "stat cache" says it stays good. Size and mtime only,
965
970
  // never a body, and only when a window was asked for: nginx's open_file_cache makes the same
966
971
  // trade, and the worst a stale entry does is answer with the file as it was a moment ago.
@@ -1424,6 +1429,70 @@ function withUtf8Charset(value) {
1424
1429
  return CHARSET_PARAM.test(value) ? value.replace(CHARSET_PARAM, UTF8_CHARSET) : `${value}${UTF8_CHARSET}`;
1425
1430
  }
1426
1431
 
1432
+ // What node lets a header name and a header value hold. Express gets these checks from node's own
1433
+ // setHeader, and uWS makes them load bearing rather than cosmetic: it writes `key: value\r\n` with
1434
+ // no validation of its own, so a CR or an LF that reaches it ends the header early and everything
1435
+ // after it is read by the client as a header of its own, or as a whole second response.
1436
+ const HEADER_TOKEN = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/;
1437
+ const HEADER_VALUE = /[^\t\x20-\x7e\x80-\xff]/;
1438
+
1439
+ /**
1440
+ * Refuses a header name that is not an HTTP token, the way node's setHeader does and with its
1441
+ * error, so an application catching ERR_INVALID_HTTP_TOKEN behind Express catches it here.
1442
+ *
1443
+ * @param {any} name
1444
+ * @returns {void}
1445
+ * @throws {TypeError} if the name is not a token, which includes not being a string
1446
+ */
1447
+ function validateHeaderName(name) {
1448
+ if (typeof name !== "string" || !HEADER_TOKEN.test(name)) {
1449
+ /** @type {NodeJS.ErrnoException} */
1450
+ const err = new TypeError(`Header name must be a valid HTTP token ["${name}"]`);
1451
+ err.code = "ERR_INVALID_HTTP_TOKEN";
1452
+ throw err;
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * Refuses a header value holding a character that cannot go on the wire, with node's error. An
1458
+ * array is sent as one header per entry, so each entry is checked on its own rather than as the
1459
+ * comma joined string node happens to test.
1460
+ *
1461
+ * @param {string} name the header being set, which is what node names in the message
1462
+ * @param {string|string[]} value already coerced to text
1463
+ * @returns {void}
1464
+ * @throws {TypeError} if a character is not allowed in a header value
1465
+ */
1466
+ function validateHeaderValue(name, value) {
1467
+ if (Array.isArray(value)) {
1468
+ for (const one of value) {
1469
+ validateHeaderValue(name, one);
1470
+ }
1471
+ return;
1472
+ }
1473
+ if (HEADER_VALUE.test(value)) {
1474
+ /** @type {NodeJS.ErrnoException} */
1475
+ const err = new TypeError(`Invalid character in header content ["${name}"]`);
1476
+ err.code = "ERR_INVALID_CHAR";
1477
+ throw err;
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * Whether this pair could be written to the wire at all. Used on the error path, where throwing
1483
+ * again is what turns one bad header into a dead process.
1484
+ *
1485
+ * @param {string} name
1486
+ * @param {any} value
1487
+ * @returns {boolean}
1488
+ */
1489
+ function headerIsWritable(name, value) {
1490
+ if (!HEADER_TOKEN.test(name)) {
1491
+ return false;
1492
+ }
1493
+ return Array.isArray(value) ? value.every((one) => !HEADER_VALUE.test(one)) : !HEADER_VALUE.test(value);
1494
+ }
1495
+
1427
1496
  // The status send picks for a failed stat. Anything else is the file being there but unreadable,
1428
1497
  // which is the server's problem and not the request's.
1429
1498
  const STAT_ERROR_STATUS = { ENAMETOOLONG: 404, ENOTDIR: 404, ENOENT: 404 };
@@ -1517,9 +1586,13 @@ module.exports = {
1517
1586
  uwsPrefersEarlier,
1518
1587
  regexpGroupKeys,
1519
1588
  escapeHtml,
1589
+ validateHeaderName,
1590
+ validateHeaderValue,
1591
+ headerIsWritable,
1520
1592
  withDefaultCharset,
1521
1593
  withUtf8Charset,
1522
1594
  asStatError,
1523
1595
  httpError,
1524
- EMPTY_REGEX
1596
+ EMPTY_REGEX,
1597
+ settingsEpoch
1525
1598
  };