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 +42 -3
- package/package.json +16 -18
- package/src/application.js +31 -17
- package/src/cli.js +39 -4
- package/src/declarative.js +25 -20
- package/src/index.js +9 -0
- package/src/middlewares.js +10 -7
- package/src/request.js +12 -6
- package/src/response.js +74 -30
- package/src/router.js +109 -44
- package/src/utils.js +74 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Fulmine.js
|
|
4
4
|
|
|
5
|
-
|
|
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
|
[](https://www.npmjs.com/package/fulmine.js)
|
|
32
|
-
[](https://nodejs.org)
|
|
33
|
+
[](https://www.http-arena.com/#tuned=0)
|
|
33
34
|
[](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
|
|
34
35
|
[](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml)
|
|
36
|
+
[](https://scorecard.dev/viewer/?uri=github.com/nigrosimone/fulmine.js)
|
|
37
|
+
[](https://www.bestpractices.dev/projects/14089)
|
|
35
38
|
[](./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
|
|
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.
|
|
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.
|
|
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.
|
|
87
|
-
"range-parser": "^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": "^
|
|
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": "^
|
|
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": "^
|
|
123
|
+
"eslint-plugin-jsdoc": "^64.1.0",
|
|
126
124
|
"etag": "^1.8.1",
|
|
127
|
-
"eventsource": "^
|
|
128
|
-
"exit-hook": "^
|
|
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": "^
|
|
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": "^
|
|
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": "^
|
|
151
|
+
"nyc": "^18.0.0",
|
|
154
152
|
"on-finished": "^2.4.1",
|
|
155
153
|
"on-headers": "^1.1.0",
|
|
156
|
-
"pako": "^
|
|
154
|
+
"pako": "^3.0.1",
|
|
157
155
|
"passport": "^0.7.0",
|
|
158
156
|
"passport-local": "^1.0.0",
|
|
159
|
-
"pkg-pr-new": "^0.0.
|
|
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",
|
package/src/application.js
CHANGED
|
@@ -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
|
-
//
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
|
89
|
-
"A handler simple enough to be read at registration time is answered natively
|
|
90
|
-
"
|
|
91
|
-
|
|
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
|
*
|
package/src/declarative.js
CHANGED
|
@@ -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
|
-
|
|
741
|
+
const name = header[0].toLowerCase();
|
|
742
|
+
if (name === "content-length") {
|
|
738
743
|
return false;
|
|
739
744
|
}
|
|
740
|
-
|
|
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
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
//
|
package/src/middlewares.js
CHANGED
|
@@ -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
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
885
|
-
|
|
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
|
|
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
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1647
|
-
*
|
|
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
|
|
1841
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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.
|
|
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
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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,
|
|
2112
|
-
//
|
|
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"
|
|
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
|
};
|