fulmine.js 5.1.8 → 5.2.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 +64 -5
- package/package.json +5 -3
- package/src/application.js +38 -7
- package/src/declarative.js +7 -0
- package/src/middlewares.js +30 -19
- package/src/options.d.ts +94 -0
- package/src/request.js +82 -7
- package/src/response.js +27 -8
- package/src/router.js +54 -1
- package/src/types.d.ts +36 -3
- package/src/usage.js +7 -1
- package/src/websocket.js +223 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
<img src="./assets/logo-mark.svg" alt="" width="88" align="right">
|
|
2
|
+
|
|
1
3
|
# Fulmine
|
|
2
4
|
|
|
3
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.
|
|
@@ -25,8 +27,37 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
|
|
|
25
27
|
|
|
26
28
|
[](https://www.npmjs.com/package/fulmine.js)
|
|
27
29
|
[](https://nodejs.org)
|
|
30
|
+
[](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
|
|
31
|
+
[](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml)
|
|
28
32
|
[](./LICENSE)
|
|
29
33
|
|
|
34
|
+
## Table of contents
|
|
35
|
+
|
|
36
|
+
- [Why this exists](#why-this-exists)
|
|
37
|
+
- [Performance](#performance)
|
|
38
|
+
- [Public benchmarks](#public-benchmarks)
|
|
39
|
+
- [Attribution](#attribution)
|
|
40
|
+
- [Difference from similar projects](#difference-from-similar-projects)
|
|
41
|
+
- [Migrating](#migrating)
|
|
42
|
+
- [Docker](#docker)
|
|
43
|
+
- [Differences from Express](#differences-from-express)
|
|
44
|
+
- [Performance tips](#performance-tips)
|
|
45
|
+
- [WebSockets](#websockets)
|
|
46
|
+
- [socket.io](#socketio)
|
|
47
|
+
- [HTTP/3](#http3)
|
|
48
|
+
- [Versioning](#versioning)
|
|
49
|
+
- [Compatibility](#compatibility)
|
|
50
|
+
- [express](#express)
|
|
51
|
+
- [Application](#application)
|
|
52
|
+
- [Application settings](#application-settings)
|
|
53
|
+
- [Request](#request)
|
|
54
|
+
- [Response](#response)
|
|
55
|
+
- [Router](#router)
|
|
56
|
+
- [Tested middlewares](#tested-middlewares)
|
|
57
|
+
- [Tested view engines](#tested-view-engines)
|
|
58
|
+
- [Working on Fulmine](#working-on-fulmine)
|
|
59
|
+
- [Writing a comparison test](#writing-a-comparison-test)
|
|
60
|
+
|
|
30
61
|
## Why this exists
|
|
31
62
|
|
|
32
63
|
There are several fast HTTP servers for Node built on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js). What is scarce is one you can actually drop into an existing Express application without rewriting it.
|
|
@@ -55,7 +86,7 @@ to run it yourself.
|
|
|
55
86
|
|
|
56
87
|
Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
|
|
57
88
|
|
|
58
|
-
- **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries
|
|
89
|
+
- **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries, across fifteen subscribed profiles. The saved runs measure 24.3 million WebSocket echoes per second pipelined and 3.77 million one-at-a-time (past Bun's own dedicated WebSocket entry), 7.3 million pipelined HTTP requests per second, 1.12 million on the json profile with 1.04 million of that surviving TLS, 457 thousand on compressed json, and 359 thousand on the Postgres CRUD profile, within ten percent of the leading Rust and C# entries there.
|
|
59
90
|
- **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
|
|
60
91
|
|
|
61
92
|
More to come as their maintainers take the entries in.
|
|
@@ -194,10 +225,37 @@ On top of that, a handler simple enough to be read at registration time is compi
|
|
|
194
225
|
|
|
195
226
|
## WebSockets
|
|
196
227
|
|
|
197
|
-
|
|
228
|
+
`app.ws()` registers a WebSocket route, served by µWS itself. There is no `http.Server` underneath, so `http.on("upgrade")` and the libraries built on it do not apply; this is the replacement.
|
|
229
|
+
|
|
230
|
+
```js
|
|
231
|
+
app.ws("/room/:id", {
|
|
232
|
+
upgrade(req, res) {
|
|
233
|
+
// runs before the handshake, with a real request and response.
|
|
234
|
+
// Answering the response declines the socket:
|
|
235
|
+
if (!req.query.token) return res.sendStatus(401);
|
|
236
|
+
// and anything left on the request is there for the socket's whole life:
|
|
237
|
+
req.room = req.params.id;
|
|
238
|
+
},
|
|
239
|
+
open(ws) {
|
|
240
|
+
ws.subscribe(ws.req.room);
|
|
241
|
+
},
|
|
242
|
+
message(ws, message, isBinary) {
|
|
243
|
+
ws.publish(ws.req.room, message, isBinary);
|
|
244
|
+
},
|
|
245
|
+
close(ws, code, message) {}
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **The behavior object is µWS's**, settings included: `maxPayloadLength`, `idleTimeout`, `compression`, `maxBackpressure`, `sendPingsAutomatically` and the rest are passed through untouched, as are the `open`, `message`, `drain`, `close`, `ping`, `pong`, `dropped` and `subscription` handlers. The socket is µWS's too, so `send`, `subscribe`, `publish`, `cork` and `getBufferedAmount` behave exactly as its documentation describes.
|
|
250
|
+
- **`upgrade(req, res)` is this project's addition.** It runs before the handshake with the same `Request` and `Response` your routes get, so a session, a token or a header decides whether the socket opens. Answering the response, with `res.sendStatus(401)` or any other write, declines the upgrade. Returning a promise holds the handshake until it settles, which is what an authentication lookup needs.
|
|
251
|
+
- **`ws.req` is that request**, and it outlives the response: the client's address, headers, query and params are readable from any handler for as long as the socket is open. Hanging your own values on it in `upgrade` is how per-connection state gets to `message`.
|
|
252
|
+
- **Routers work.** `router.ws("/lobby", …)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
|
|
253
|
+
- **Paths are the ones µWS matches**: literal, or with parameters that are a whole segment such as `/room/:id`. Anything else throws where it is written rather than failing to match later.
|
|
254
|
+
- **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
|
|
255
|
+
|
|
256
|
+
A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing.
|
|
198
257
|
|
|
199
|
-
|
|
200
|
-
- You can simply use `app.uwsApp` to access uWebSockets.js `App` instance and call its `ws()` method directly.
|
|
258
|
+
If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/dimdenGD/ultimate-ws) is a drop-in replacement for it written against Ultimate Express, and Fulmine still exposes the mechanism it hooks into, but that combination is not covered by this project's tests. `app.uwsApp` also remains available for anything µWS offers that this does not.
|
|
201
259
|
|
|
202
260
|
### socket.io
|
|
203
261
|
|
|
@@ -226,9 +284,10 @@ which runs the same file against Express and against Fulmine and compares the ou
|
|
|
226
284
|
|
|
227
285
|
## HTTP/3
|
|
228
286
|
|
|
229
|
-
HTTP/3 is
|
|
287
|
+
There is an `http3: true` option, inherited from Ultimate Express, that asks µWebSockets.js for its experimental HTTP/3 app. **It is guarded off with the currently pinned µWS build**: asking for it throws a clear error, because the underlying `H3App` segfaults during construction on Linux, verified with µWS alone before a single request is served. On Windows the listener does come up, but nothing answers over QUIC that we could verify, and shipping an option that works on no deployable platform helps nobody. A skipped canary test probes `H3App` on every CI run and will turn red the day µWS ships working QUIC in its prebuilt binaries, which is when the guard goes and this section changes.
|
|
230
288
|
|
|
231
289
|
```js
|
|
290
|
+
// what it would look like, once µWS's H3 support actually works
|
|
232
291
|
const app = express({
|
|
233
292
|
http3: true,
|
|
234
293
|
uwsOptions: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fulmine.js",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.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": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"test:types": "tsd --files tests/types/*.test-d.ts",
|
|
14
14
|
"test:express": "node tools/express-suite.js",
|
|
15
15
|
"benchmark:compare": "node benchmark/run.js",
|
|
16
|
-
"cover": "npm run cover:
|
|
16
|
+
"cover": "npm run cover:full && npm run cover:report",
|
|
17
17
|
"cover:unit": "nyc --silent npm run test",
|
|
18
18
|
"cover:report": "nyc report --reporter=html",
|
|
19
19
|
"lint": "eslint .",
|
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
26
26
|
"benchmark:ab": "node benchmark/ab.js",
|
|
27
27
|
"benchmark:profile": "node benchmark/profile.js",
|
|
28
|
-
"release:local": "node tools/release-local.js"
|
|
28
|
+
"release:local": "node tools/release-local.js",
|
|
29
|
+
"cover:full": "nyc --silent npm run test && nyc --silent --no-clean npm run test:unit && nyc --silent --no-clean npm run test:express && nyc report",
|
|
30
|
+
"cover:check": "nyc check-coverage --statements 93 --branches 88 --functions 92 --lines 93"
|
|
29
31
|
},
|
|
30
32
|
"engines": {
|
|
31
33
|
"node": ">=22"
|
package/src/application.js
CHANGED
|
@@ -15,10 +15,7 @@ See the License for the specific language governing permissions and
|
|
|
15
15
|
limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
// H3App, DeclarativeResponse and _cfg all exist at runtime but are missing from the
|
|
19
|
-
// declaration file the package ships, so the module is read through a loose alias
|
|
20
18
|
const uWS = require("uWebSockets.js");
|
|
21
|
-
const uWSAny = /** @type {any} */ (uWS);
|
|
22
19
|
const Router = require("./router.js");
|
|
23
20
|
const {
|
|
24
21
|
removeDuplicateSlashes,
|
|
@@ -36,6 +33,7 @@ const path = require("path");
|
|
|
36
33
|
const os = require("os");
|
|
37
34
|
const { Worker } = require("worker_threads");
|
|
38
35
|
const cluster = require("cluster");
|
|
36
|
+
const { registerWebSocketRoutes } = require("./websocket.js");
|
|
39
37
|
|
|
40
38
|
const cpuCount = os.cpus().length;
|
|
41
39
|
|
|
@@ -102,10 +100,14 @@ class Application extends Router {
|
|
|
102
100
|
if (settings.uwsApp) {
|
|
103
101
|
this.uwsApp = settings.uwsApp;
|
|
104
102
|
} else if (settings.http3) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
// uWS.H3App exists in the pinned build but its QUIC stack does not: the constructor
|
|
104
|
+
// segfaults on Linux and hangs forever on Windows before serving a single request,
|
|
105
|
+
// verified 2026-08-05 with uWS alone. A clear throw beats a native crash; this
|
|
106
|
+
// branch goes back to H3App once uNetworking ships working QUIC in the prebuilts.
|
|
107
|
+
throw new Error(
|
|
108
|
+
"http3 is not usable with the pinned uWebSockets.js build: its H3App crashes " +
|
|
109
|
+
"during construction. Track uNetworking/uWebSockets.js for working QUIC support."
|
|
110
|
+
);
|
|
109
111
|
} else if (settings.uwsOptions.key_file_name && settings.uwsOptions.cert_file_name) {
|
|
110
112
|
this.uwsApp = uWS.SSLApp(settings.uwsOptions);
|
|
111
113
|
} else {
|
|
@@ -514,6 +516,9 @@ class Application extends Router {
|
|
|
514
516
|
*/
|
|
515
517
|
listen(port, host, backlog, callback) {
|
|
516
518
|
this._compileOptimizedRoutes();
|
|
519
|
+
// before the catch-all: µWS sends an upgrade to the websocket route even when a
|
|
520
|
+
// catch-all covers the same path, so the two coexist and the order is only tidiness
|
|
521
|
+
registerWebSocketRoutes(this);
|
|
517
522
|
this._createRequestHandler();
|
|
518
523
|
// node's shapes: (cb), (port, cb), (port, host, cb) and (port, host, backlog, cb)
|
|
519
524
|
if (typeof port === "function") {
|
|
@@ -594,6 +599,32 @@ class Application extends Router {
|
|
|
594
599
|
return this;
|
|
595
600
|
}
|
|
596
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Publishes a message to every socket subscribed to a topic, from outside any of them.
|
|
604
|
+
*
|
|
605
|
+
* The socket's own `publish` reaches the same topics; this one is for the sender that is
|
|
606
|
+
* not a socket, a timer or a route handler broadcasting to a room.
|
|
607
|
+
*
|
|
608
|
+
* @param {string} topic
|
|
609
|
+
* @param {string|ArrayBuffer|Buffer} message
|
|
610
|
+
* @param {boolean} [isBinary]
|
|
611
|
+
* @param {boolean} [compress]
|
|
612
|
+
* @returns {boolean} whether the topic had anyone listening
|
|
613
|
+
*/
|
|
614
|
+
publish(topic, message, isBinary, compress) {
|
|
615
|
+
return this.uwsApp.publish(topic, message, isBinary, compress);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* How many sockets are subscribed to a topic.
|
|
620
|
+
*
|
|
621
|
+
* @param {string} topic
|
|
622
|
+
* @returns {number}
|
|
623
|
+
*/
|
|
624
|
+
numSubscribers(topic) {
|
|
625
|
+
return this.uwsApp.numSubscribers(topic);
|
|
626
|
+
}
|
|
627
|
+
|
|
597
628
|
/**
|
|
598
629
|
* The bound address, or null when not listening.
|
|
599
630
|
* @returns {{address: string, family: string, port: number}|null}
|
package/src/declarative.js
CHANGED
|
@@ -606,6 +606,13 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
// a handler that never sends is not a response: Express leaves the request waiting, so
|
|
610
|
+
// compiling the empty shape would answer a bare 200 where the ordinary path answers
|
|
611
|
+
// nothing at all. It has to fall back instead.
|
|
612
|
+
if (!sendUsed && !sendStatusUsed) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
|
|
609
616
|
let decRes = new uWSAny.DeclarativeResponse();
|
|
610
617
|
|
|
611
618
|
if (statusCode !== 200) {
|
package/src/middlewares.js
CHANGED
|
@@ -251,7 +251,7 @@ function bodyError(message, status, type, extra) {
|
|
|
251
251
|
* that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
|
|
252
252
|
*
|
|
253
253
|
* @param {string} root directory to serve from
|
|
254
|
-
* @param {
|
|
254
|
+
* @param {import("./options").StaticOptions} [options]
|
|
255
255
|
* @returns {(req: any, res: any, next: (err?: any) => void) => any}
|
|
256
256
|
*/
|
|
257
257
|
function serveStatic(root, options) {
|
|
@@ -321,8 +321,8 @@ function serveStatic(root, options) {
|
|
|
321
321
|
} else return next();
|
|
322
322
|
}
|
|
323
323
|
let _path = url;
|
|
324
|
-
const fullpath = path.resolve(path.join(
|
|
325
|
-
if (
|
|
324
|
+
const fullpath = path.resolve(path.join(root, url));
|
|
325
|
+
if (root && !fullpath.startsWith(path.resolve(root))) {
|
|
326
326
|
if (!options.fallthrough) {
|
|
327
327
|
res.status(403);
|
|
328
328
|
return next(httpError(403));
|
|
@@ -472,13 +472,16 @@ function createInflate(contentEncoding) {
|
|
|
472
472
|
* knows), or undefined for a parser that never decodes (raw)
|
|
473
473
|
* @param {boolean} [keepsBuffer] whether the collected buffer itself escapes to the application,
|
|
474
474
|
* which rules out handing it a view over uWS memory
|
|
475
|
-
* @returns {(options?:
|
|
475
|
+
* @returns {(options?: import("./options").BodyParserOptions) => Function} the middleware factory
|
|
476
476
|
*/
|
|
477
477
|
function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy, keepsBuffer) {
|
|
478
|
-
return function (
|
|
478
|
+
return function (userOptions) {
|
|
479
479
|
// a copy, because everything below writes the parsed values back: with the caller's own
|
|
480
|
-
// object, altering it after the parser was built would alter the parser
|
|
481
|
-
|
|
480
|
+
// object, altering it after the parser was built would alter the parser. The type says
|
|
481
|
+
// settled because the block below fills in every default, which is what the middleware
|
|
482
|
+
// and its closures then rely on
|
|
483
|
+
/** @type {import("./options").BodyParserOptions} */
|
|
484
|
+
const options = userOptions && typeof userOptions === "object" ? { ...userOptions } : new NullObject();
|
|
482
485
|
// refused where it is written, not where it is used: an option nobody can honour is a
|
|
483
486
|
// mistake in the application, and body-parser throws for it at the same point
|
|
484
487
|
if (options.verify !== undefined && options.verify !== false && typeof options.verify !== "function") {
|
|
@@ -491,11 +494,17 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
491
494
|
// and every comparison against it is false. express.json({ limit: 5 * 1024 * 1024 }) had no
|
|
492
495
|
// limit at all. parse, and only what needs parsing
|
|
493
496
|
if (typeof options.limit === "undefined") {
|
|
494
|
-
options.limit = bytes.parse("100kb");
|
|
497
|
+
options.limit = /** @type {number} */ (bytes.parse("100kb"));
|
|
495
498
|
} else if (typeof options.limit !== "number") {
|
|
496
|
-
|
|
499
|
+
// bytes.parse answers null for a size it cannot read, and body-parser passes that
|
|
500
|
+
// along untouched too: matching it matters more than improving on it here
|
|
501
|
+
options.limit = /** @type {number} */ (bytes.parse(options.limit));
|
|
497
502
|
}
|
|
498
503
|
|
|
504
|
+
// settled above, and read once: every check below wants the value, not the bag
|
|
505
|
+
const limit = /** @type {number} */ (options.limit);
|
|
506
|
+
const defaultCharset = /** @type {string} */ (options.defaultCharset ?? "utf-8");
|
|
507
|
+
|
|
499
508
|
if (typeof options.inflate === "undefined") options.inflate = true;
|
|
500
509
|
if (typeof options.type === "undefined") options.type = defaultType;
|
|
501
510
|
if (typeof options.type === "string") {
|
|
@@ -523,7 +532,9 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
523
532
|
//
|
|
524
533
|
// typeis.is and not typeis(req, ...): the request form first checks that there is a body,
|
|
525
534
|
// and the caller below has established that already.
|
|
526
|
-
const claimsType = memoizeByString(
|
|
535
|
+
const claimsType = memoizeByString(
|
|
536
|
+
(contentType) => !!typeis.is(contentType, /** @type {string[]} */ (options.type))
|
|
537
|
+
);
|
|
527
538
|
|
|
528
539
|
let additionalMethods;
|
|
529
540
|
|
|
@@ -585,7 +596,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
585
596
|
// answers 415 even for an empty body, and before the verify hook can run
|
|
586
597
|
let encoding;
|
|
587
598
|
if (charsetPolicy) {
|
|
588
|
-
encoding = charsetOf(type) ??
|
|
599
|
+
encoding = charsetOf(type) ?? defaultCharset;
|
|
589
600
|
if (
|
|
590
601
|
(charsetPolicy === "utf" && encoding.slice(0, 4) !== "utf-") ||
|
|
591
602
|
(charsetPolicy === "urlencoded" && encoding !== "utf-8" && encoding !== "iso-8859-1")
|
|
@@ -611,12 +622,12 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
611
622
|
}
|
|
612
623
|
|
|
613
624
|
// skip reading too large body
|
|
614
|
-
if (length && +length >
|
|
625
|
+
if (length && +length > limit) {
|
|
615
626
|
return next(
|
|
616
627
|
bodyError("request entity too large", 413, "entity.too.large", {
|
|
617
628
|
expected: +length,
|
|
618
629
|
length: +length,
|
|
619
|
-
limit:
|
|
630
|
+
limit: limit
|
|
620
631
|
})
|
|
621
632
|
);
|
|
622
633
|
}
|
|
@@ -674,13 +685,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
674
685
|
if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
|
|
675
686
|
req.bodyRead = true;
|
|
676
687
|
const declared = Number(length);
|
|
677
|
-
req._res.collectBody(
|
|
688
|
+
req._res.collectBody(limit, (body) => {
|
|
678
689
|
if (body === null) {
|
|
679
690
|
// over maxSize: uWS refused it natively
|
|
680
691
|
return next(
|
|
681
692
|
bodyError("request entity too large", 413, "entity.too.large", {
|
|
682
|
-
limit:
|
|
683
|
-
received:
|
|
693
|
+
limit: limit,
|
|
694
|
+
received: limit
|
|
684
695
|
})
|
|
685
696
|
);
|
|
686
697
|
}
|
|
@@ -710,7 +721,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
710
721
|
// known and we aren't inflating, the final size is known up front, so chunks can go
|
|
711
722
|
// straight into one buffer and the body is copied once.
|
|
712
723
|
// the cap means a client that declares a body and never sends it costs no more than one
|
|
713
|
-
// that actually sends a body that size, and content-length above
|
|
724
|
+
// that actually sends a body that size, and content-length above limit was
|
|
714
725
|
// already rejected above
|
|
715
726
|
const declaredLength = inflate ? -1 : Number(length);
|
|
716
727
|
let target =
|
|
@@ -757,13 +768,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
757
768
|
*/
|
|
758
769
|
function keepChunk(buf) {
|
|
759
770
|
totalSize += buf.length;
|
|
760
|
-
if (totalSize >
|
|
771
|
+
if (totalSize > limit) {
|
|
761
772
|
finished = true;
|
|
762
773
|
abs.length = 0;
|
|
763
774
|
target = null;
|
|
764
775
|
next(
|
|
765
776
|
bodyError("request entity too large", 413, "entity.too.large", {
|
|
766
|
-
limit:
|
|
777
|
+
limit: limit,
|
|
767
778
|
received: totalSize
|
|
768
779
|
})
|
|
769
780
|
);
|
package/src/options.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The option bags the file-serving and body-parsing paths take, written once and referenced from
|
|
2
|
+
// the JSDoc of the functions that read them. They live in a declaration file rather than as
|
|
3
|
+
// @typedef blocks in the sources because those sources export classes, and a typedef hanging off
|
|
4
|
+
// a `module.exports = class` module makes TypeScript see two unrelated copies of the class.
|
|
5
|
+
|
|
6
|
+
/** What res.sendFile, express.static and res.download all read. The names and defaults are send's. */
|
|
7
|
+
export interface SendFileOptions {
|
|
8
|
+
/** The directory a relative path resolves against, and the boundary nothing may climb out of. */
|
|
9
|
+
root?: string;
|
|
10
|
+
/** Cache-Control's max-age, in milliseconds or as a duration such as "1d". */
|
|
11
|
+
maxAge?: number | string;
|
|
12
|
+
/** Adds Cache-Control: immutable, which is only meaningful next to a long maxAge. */
|
|
13
|
+
immutable?: boolean;
|
|
14
|
+
/** Whether Last-Modified is sent, from the file's mtime. */
|
|
15
|
+
lastModified?: boolean;
|
|
16
|
+
/** Whether an ETag is sent. */
|
|
17
|
+
etag?: boolean;
|
|
18
|
+
/** Whether a Range request is honoured. */
|
|
19
|
+
acceptRanges?: boolean;
|
|
20
|
+
/** Whether Cache-Control is sent at all. */
|
|
21
|
+
cacheControl?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* What to do with a path holding a dotfile. "ignore_files" is one more than send offers:
|
|
24
|
+
* it hides a dotfile that is the last segment while letting a dotted directory through.
|
|
25
|
+
*/
|
|
26
|
+
dotfiles?: "allow" | "deny" | "ignore" | "ignore_files";
|
|
27
|
+
/** Extra headers for the response. */
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
/** Called before the file goes out, to set headers from the path or its stat. */
|
|
30
|
+
setHeaders?: (res: any, path: string, stat: any) => void;
|
|
31
|
+
/** First byte of the window to send. */
|
|
32
|
+
start?: number;
|
|
33
|
+
/** Last byte of the window to send. */
|
|
34
|
+
end?: number;
|
|
35
|
+
/** The path is already encoded, so leave it alone. */
|
|
36
|
+
skipEncodePath?: boolean;
|
|
37
|
+
/** Internal: locals carried through to a view render. */
|
|
38
|
+
_locals?: Record<string, any>;
|
|
39
|
+
/** Internal: the stat the caller already took, so it is not taken twice. */
|
|
40
|
+
_stat?: any;
|
|
41
|
+
/** Internal: the caller computed the ETag itself. */
|
|
42
|
+
_ownEtag?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What express.static reads on top of everything res.sendFile takes. */
|
|
46
|
+
export interface StaticOptions extends SendFileOptions {
|
|
47
|
+
/** The file served for a directory, or false to serve none. */
|
|
48
|
+
index?: string | false;
|
|
49
|
+
/** Whether a directory without a trailing slash is redirected to one. */
|
|
50
|
+
redirect?: boolean;
|
|
51
|
+
/** Whether a request this middleware cannot serve moves on instead of being answered. */
|
|
52
|
+
fallthrough?: boolean;
|
|
53
|
+
/** Extensions tried when the path names no file, or false to try none. */
|
|
54
|
+
extensions?: string[] | false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A body parser's options once its factory has filled in every default it needs. */
|
|
58
|
+
export interface SettledBodyParserOptions extends BodyParserOptions {
|
|
59
|
+
limit: number;
|
|
60
|
+
inflate: boolean;
|
|
61
|
+
/** the string form is turned into a one-element list by the factory */
|
|
62
|
+
type: string[] | ((req: any) => boolean);
|
|
63
|
+
defaultCharset: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What the body parsers take. The four share these; each names below the ones it alone reads. */
|
|
67
|
+
export interface BodyParserOptions {
|
|
68
|
+
/** The largest body to accept, as bytes or as "100kb". */
|
|
69
|
+
limit?: number | string;
|
|
70
|
+
/** Which content types this parser claims. */
|
|
71
|
+
type?: string | string[] | ((req: any) => boolean);
|
|
72
|
+
/** Runs on the raw bytes before parsing, which is where a signature check belongs. */
|
|
73
|
+
verify?: false | ((req: any, res: any, buf: Buffer, encoding: string) => void);
|
|
74
|
+
/** Whether a compressed body is decompressed rather than refused. */
|
|
75
|
+
inflate?: boolean;
|
|
76
|
+
/** The charset assumed when the request names none. */
|
|
77
|
+
defaultCharset?: string;
|
|
78
|
+
/** json only: refuse a body that is not an object or an array. */
|
|
79
|
+
strict?: boolean;
|
|
80
|
+
/** json only, passed to JSON.parse. */
|
|
81
|
+
reviver?: (key: string, value: any) => any;
|
|
82
|
+
/** urlencoded only: parse with qs rather than the plain parser. */
|
|
83
|
+
extended?: boolean;
|
|
84
|
+
/** urlencoded only: how many parameters to accept. */
|
|
85
|
+
parameterLimit?: number;
|
|
86
|
+
/** urlencoded only, extended only: how deep a nested object may go. */
|
|
87
|
+
depth?: number;
|
|
88
|
+
/** urlencoded only, passed to qs. */
|
|
89
|
+
charsetSentinel?: boolean;
|
|
90
|
+
/** urlencoded only, passed to qs. */
|
|
91
|
+
interpretNumericEntities?: boolean;
|
|
92
|
+
/** Internal: the single type this parser claims, which lets the prologue compare strings. */
|
|
93
|
+
simpleType?: string;
|
|
94
|
+
}
|
package/src/request.js
CHANGED
|
@@ -139,26 +139,53 @@ module.exports = class Request extends Readable {
|
|
|
139
139
|
/** @type {Record<string, string[]>|null} */
|
|
140
140
|
#cachedDistinctHeaders = null;
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Every header, flat: name then value, name then value.
|
|
144
|
+
*
|
|
145
|
+
* An array of pairs meant one array allocated per header on every request, and a request
|
|
146
|
+
* carries eight or ten of them, so everything that reads this walks it two at a time. The
|
|
147
|
+
* names are lowercase by contract: uWS lowers them on the wire and the node shim lowers
|
|
148
|
+
* them in its forEach, so readers compare without lowering again.
|
|
149
|
+
*
|
|
150
|
+
* @type {string[]}
|
|
151
|
+
*/
|
|
146
152
|
#rawHeadersEntries = [];
|
|
147
153
|
|
|
148
154
|
/** @type {string|undefined|null} */
|
|
149
155
|
#cachedParsedIp = null;
|
|
150
156
|
|
|
157
|
+
/** Whether backpressure has asked uWS to stop delivering the body for now. */
|
|
151
158
|
#paused = false;
|
|
152
159
|
|
|
153
|
-
|
|
160
|
+
/** A bodyless request whose empty end has not been delivered yet, see the constructor. */
|
|
154
161
|
#emptyBody = false;
|
|
155
162
|
|
|
163
|
+
/**
|
|
164
|
+
* What a body parser left behind, and undefined until one claims the request.
|
|
165
|
+
* @type {any}
|
|
166
|
+
*/
|
|
156
167
|
body;
|
|
157
168
|
|
|
169
|
+
/**
|
|
170
|
+
* The response this request arrived with, linked so either reaches the other.
|
|
171
|
+
*
|
|
172
|
+
* Typed loosely on purpose: it is linked right after construction rather than in the
|
|
173
|
+
* constructor, and the honest `Response|undefined` would put a check in front of every
|
|
174
|
+
* use of a field that is never observed unset.
|
|
175
|
+
*
|
|
176
|
+
* @type {any}
|
|
177
|
+
*/
|
|
158
178
|
res;
|
|
159
179
|
|
|
160
|
-
|
|
161
|
-
|
|
180
|
+
/**
|
|
181
|
+
* Copies one header out of uWS and notices the two things the constructor decides by.
|
|
182
|
+
*
|
|
183
|
+
* One function for every request, fed through currentRequest: an arrow in the constructor
|
|
184
|
+
* captured `this`, which cost a context and a function allocation per request.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} headerKey lowercase, as uWS hands it over
|
|
187
|
+
* @param {string} value
|
|
188
|
+
*/
|
|
162
189
|
static #collectHeader = (headerKey, value) => {
|
|
163
190
|
const r = currentRequest;
|
|
164
191
|
r.#rawHeadersEntries.push(headerKey, value);
|
|
@@ -183,10 +210,31 @@ module.exports = class Request extends Readable {
|
|
|
183
210
|
}
|
|
184
211
|
};
|
|
185
212
|
|
|
213
|
+
/**
|
|
214
|
+
* The parameters a native uWS route matched, by name, or undefined off that path.
|
|
215
|
+
* @type {Record<string, string>|undefined}
|
|
216
|
+
*/
|
|
186
217
|
optimizedParams;
|
|
187
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The continuation of the chain currently running, which express also hands to a handler
|
|
221
|
+
* through the request. Declared rather than left to appear on assignment: runRoute sets it
|
|
222
|
+
* on every request, and an undeclared property is a shape change on each one.
|
|
223
|
+
*
|
|
224
|
+
* @type {any}
|
|
225
|
+
*/
|
|
226
|
+
next;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* What the chain threw or passed to next(err), waiting for an error handler.
|
|
230
|
+
* @type {any}
|
|
231
|
+
*/
|
|
188
232
|
_error;
|
|
189
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Set by the paths that must not earn an ETag, res.sendFile's stream among them.
|
|
236
|
+
* @type {boolean|undefined}
|
|
237
|
+
*/
|
|
190
238
|
noEtag;
|
|
191
239
|
|
|
192
240
|
/**
|
|
@@ -749,6 +797,33 @@ module.exports = class Request extends Readable {
|
|
|
749
797
|
return this.connection;
|
|
750
798
|
}
|
|
751
799
|
|
|
800
|
+
/**
|
|
801
|
+
* Cuts this request loose from the µWS response it arrived on, keeping the two things only
|
|
802
|
+
* that response could answer.
|
|
803
|
+
*
|
|
804
|
+
* A websocket upgrade hands the request to the socket, which outlives the response by the
|
|
805
|
+
* whole life of the connection. Reading the peer address through the freed response is not
|
|
806
|
+
* an error but a use after free, so the values are taken while it is still alive and an
|
|
807
|
+
* inert stand-in answers anything that asks later.
|
|
808
|
+
*/
|
|
809
|
+
_detachFromResponse() {
|
|
810
|
+
const uwsRes = this._res;
|
|
811
|
+
if (!this.rawIp) {
|
|
812
|
+
this.rawIp = uwsRes.getRemoteAddress();
|
|
813
|
+
}
|
|
814
|
+
const remotePort = uwsRes.getRemotePort();
|
|
815
|
+
const rawIp = this.rawIp;
|
|
816
|
+
this._res = {
|
|
817
|
+
getRemoteAddress: () => rawIp,
|
|
818
|
+
getRemotePort: () => remotePort,
|
|
819
|
+
// a body cannot arrive on an upgraded socket, and a stray reader must not reach µWS
|
|
820
|
+
onData() {},
|
|
821
|
+
pause() {},
|
|
822
|
+
resume() {},
|
|
823
|
+
close() {}
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
752
827
|
/**
|
|
753
828
|
* Whether the client's cached copy is still good, from If-None-Match and If-Modified-Since
|
|
754
829
|
* against the response headers set so far. Only GET and HEAD can be fresh.
|
package/src/response.js
CHANGED
|
@@ -123,6 +123,7 @@ module.exports = class Response extends Writable {
|
|
|
123
123
|
/** @type {Socket|null} */
|
|
124
124
|
#socket = null;
|
|
125
125
|
|
|
126
|
+
/** Whether end() has run, which is what makes a second one a no-op rather than a throw. */
|
|
126
127
|
#ended = false;
|
|
127
128
|
|
|
128
129
|
/** @type {((err?: Error|null) => void)|null} */
|
|
@@ -131,6 +132,10 @@ module.exports = class Response extends Writable {
|
|
|
131
132
|
/** @type {any} */
|
|
132
133
|
#outHeaders = null;
|
|
133
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The request this response answers, linked so either reaches the other.
|
|
137
|
+
* @type {InstanceType<typeof import("./request.js")>}
|
|
138
|
+
*/
|
|
134
139
|
req;
|
|
135
140
|
|
|
136
141
|
/**
|
|
@@ -145,6 +150,9 @@ module.exports = class Response extends Writable {
|
|
|
145
150
|
constructor(res, req, app) {
|
|
146
151
|
super();
|
|
147
152
|
this._req = req;
|
|
153
|
+
// linked here rather than by the caller: the pair is built together, and a field the
|
|
154
|
+
// constructor leaves unset is a shape change on whoever assigns it first
|
|
155
|
+
this.req = req;
|
|
148
156
|
this._res = res;
|
|
149
157
|
this.headersSent = false;
|
|
150
158
|
this.app = app;
|
|
@@ -673,7 +681,7 @@ module.exports = class Response extends Writable {
|
|
|
673
681
|
* "ignore"), `acceptRanges`, `cacheControl`, `immutable`, `etag` and `setHeaders`.
|
|
674
682
|
*
|
|
675
683
|
* @param {string} path
|
|
676
|
-
* @param {
|
|
684
|
+
* @param {import("./options").SendFileOptions} [options]
|
|
677
685
|
* @param {(err?: Error) => void} [callback] called once sent, or with the error
|
|
678
686
|
*/
|
|
679
687
|
sendFile(path, options = new NullObject(), callback) {
|
|
@@ -893,9 +901,11 @@ module.exports = class Response extends Writable {
|
|
|
893
901
|
// range requests
|
|
894
902
|
if (options.acceptRanges) {
|
|
895
903
|
if (this.req.headers.range) {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
}
|
|
904
|
+
// the branch above established the header is there, so range() cannot answer
|
|
905
|
+
// the undefined it uses to mean "no Range header"
|
|
906
|
+
let ranges = /** @type {ReturnType<typeof import("range-parser")>} */ (
|
|
907
|
+
this.req.range(len, { combine: true })
|
|
908
|
+
);
|
|
899
909
|
|
|
900
910
|
// if-range
|
|
901
911
|
if (!isRangeFresh(this.req, this)) {
|
|
@@ -1003,7 +1013,7 @@ module.exports = class Response extends Writable {
|
|
|
1003
1013
|
*
|
|
1004
1014
|
* @param {string} path
|
|
1005
1015
|
* @param {string} [filename] name offered to the user, defaults to the basename of the path
|
|
1006
|
-
* @param {
|
|
1016
|
+
* @param {import("./options").SendFileOptions} [options] passed through to sendFile
|
|
1007
1017
|
* @param {(err?: Error) => void} [callback]
|
|
1008
1018
|
*/
|
|
1009
1019
|
download(path, filename, options, callback) {
|
|
@@ -1236,7 +1246,10 @@ module.exports = class Response extends Writable {
|
|
|
1236
1246
|
*/
|
|
1237
1247
|
cookie(name, value, options) {
|
|
1238
1248
|
const opt = { ...(options ?? {}) }; // create a new ref because we change original object (https://github.com/dimdenGD/ultimate-express/issues/68)
|
|
1239
|
-
|
|
1249
|
+
// cookie-parser hangs the secret on the request, so it is read off it rather than
|
|
1250
|
+
// declared here: without that middleware there is none, which is what this checks
|
|
1251
|
+
const req = /** @type {any} */ (this.req);
|
|
1252
|
+
if (opt.signed && !req.secret) {
|
|
1240
1253
|
// the message has to read like this: it is the one Express throws, and it names the
|
|
1241
1254
|
// thing that is actually missing rather than the library that noticed
|
|
1242
1255
|
throw new Error('cookieParser("secret") required for signed cookies');
|
|
@@ -1254,7 +1267,7 @@ module.exports = class Response extends Writable {
|
|
|
1254
1267
|
delete opt.maxAge;
|
|
1255
1268
|
}
|
|
1256
1269
|
if (opt.signed) {
|
|
1257
|
-
val = "s:" + sign(val,
|
|
1270
|
+
val = "s:" + sign(val, req.secret);
|
|
1258
1271
|
}
|
|
1259
1272
|
|
|
1260
1273
|
if (opt.path == null) {
|
|
@@ -1304,7 +1317,9 @@ module.exports = class Response extends Writable {
|
|
|
1304
1317
|
*/
|
|
1305
1318
|
format(object) {
|
|
1306
1319
|
const keys = Object.keys(object).filter((v) => v !== "default");
|
|
1307
|
-
|
|
1320
|
+
// accepts answers the whole list only when asked with no arguments; given types it
|
|
1321
|
+
// answers the best of them, or false
|
|
1322
|
+
const key = keys.length > 0 ? /** @type {string|false} */ (this.req.accepts(keys)) : false;
|
|
1308
1323
|
|
|
1309
1324
|
this.vary("Accept");
|
|
1310
1325
|
|
|
@@ -1501,6 +1516,10 @@ module.exports = class Response extends Writable {
|
|
|
1501
1516
|
return this.set("content-type", ct);
|
|
1502
1517
|
}
|
|
1503
1518
|
|
|
1519
|
+
/**
|
|
1520
|
+
* express carries both names for the same method, and middleware reaches for either.
|
|
1521
|
+
* @type {(type: string) => any}
|
|
1522
|
+
*/
|
|
1504
1523
|
contentType = this.type;
|
|
1505
1524
|
|
|
1506
1525
|
/**
|
package/src/router.js
CHANGED
|
@@ -37,6 +37,7 @@ const statuses = require("statuses");
|
|
|
37
37
|
const { METHODS } = require("http");
|
|
38
38
|
const { isNodeRequest, serveNodeRequest } = require("./node-shim.js");
|
|
39
39
|
const { chainUsage } = require("./usage.js");
|
|
40
|
+
const { checkBehavior } = require("./websocket.js");
|
|
40
41
|
|
|
41
42
|
// every method the declarative compiler can emit: a patched one must disable compilation, or the
|
|
42
43
|
// patch would be honoured everywhere but on compiled routes
|
|
@@ -836,10 +837,27 @@ function generateErrorPageHtml(err) {
|
|
|
836
837
|
}
|
|
837
838
|
|
|
838
839
|
module.exports = class Router extends EventEmitter {
|
|
840
|
+
/**
|
|
841
|
+
* The router or application this one is mounted on, undefined until it is.
|
|
842
|
+
* @type {any}
|
|
843
|
+
*/
|
|
839
844
|
parent;
|
|
840
845
|
|
|
846
|
+
/**
|
|
847
|
+
* Whether listen() has run, after which a new route can no longer reach uWS.
|
|
848
|
+
* @type {boolean|undefined}
|
|
849
|
+
*/
|
|
841
850
|
listenCalled;
|
|
842
851
|
|
|
852
|
+
/**
|
|
853
|
+
* The uWS app routes are registered on.
|
|
854
|
+
*
|
|
855
|
+
* Typed loosely on purpose: only an Application owns one, and the callers that reach for
|
|
856
|
+
* it have already established that, so the honest `TemplatedApp|undefined` would only add
|
|
857
|
+
* casts where the guard already is.
|
|
858
|
+
*
|
|
859
|
+
* @type {any}
|
|
860
|
+
*/
|
|
843
861
|
uwsApp;
|
|
844
862
|
|
|
845
863
|
/**
|
|
@@ -852,6 +870,10 @@ module.exports = class Router extends EventEmitter {
|
|
|
852
870
|
this._paramCallbacks = new Map();
|
|
853
871
|
this._mountpathCache = new Map();
|
|
854
872
|
this._routes = [];
|
|
873
|
+
// websocket routes, kept apart from the HTTP ones: µWS serves them itself and listen()
|
|
874
|
+
// hands them over whole, mount paths and all
|
|
875
|
+
/** @type {any[]|null} */
|
|
876
|
+
this._wsRoutes = null;
|
|
855
877
|
// the native presets allowed to skip the header copy, so a late middleware or an etag
|
|
856
878
|
// arriving after listen can take the permission back; null until one is granted
|
|
857
879
|
/** @type {Set<any>|null} */
|
|
@@ -1393,7 +1415,6 @@ module.exports = class Router extends EventEmitter {
|
|
|
1393
1415
|
const request = new this._request(req, res, this, preset, skipHolder);
|
|
1394
1416
|
const response = new this._response(res, request, this);
|
|
1395
1417
|
request.res = response;
|
|
1396
|
-
response.req = request;
|
|
1397
1418
|
|
|
1398
1419
|
return request;
|
|
1399
1420
|
}
|
|
@@ -1974,6 +1995,38 @@ module.exports = class Router extends EventEmitter {
|
|
|
1974
1995
|
return this;
|
|
1975
1996
|
}
|
|
1976
1997
|
|
|
1998
|
+
/**
|
|
1999
|
+
* Registers a websocket route, which µWS serves itself.
|
|
2000
|
+
*
|
|
2001
|
+
* The behavior is µWS's, settings and socket handlers alike, plus one addition: an
|
|
2002
|
+
* `upgrade(req, res)` of this project's own shape, which runs before the handshake with a
|
|
2003
|
+
* real request and response. Answering with the response declines the socket, which is how
|
|
2004
|
+
* a check refuses one; returning a promise holds the handshake until it settles.
|
|
2005
|
+
*
|
|
2006
|
+
* The request lives as long as the socket and reaches every handler as `ws.req`, so what
|
|
2007
|
+
* the upgrade learned about the client, and anything it hangs on the request, is there when
|
|
2008
|
+
* a message arrives.
|
|
2009
|
+
*
|
|
2010
|
+
* @example
|
|
2011
|
+
* app.ws("/room/:id", {
|
|
2012
|
+
* upgrade(req, res) {
|
|
2013
|
+
* if (!req.query.token) return res.sendStatus(401);
|
|
2014
|
+
* req.room = req.params.id;
|
|
2015
|
+
* },
|
|
2016
|
+
* open(ws) { ws.subscribe(ws.req.room); },
|
|
2017
|
+
* message(ws, message, isBinary) { ws.publish(ws.req.room, message, isBinary); }
|
|
2018
|
+
* });
|
|
2019
|
+
*
|
|
2020
|
+
* @param {string} path a literal path, or one whose parameters are whole segments
|
|
2021
|
+
* @param {object} behavior µWS's WebSocketBehavior, plus the optional `upgrade` above
|
|
2022
|
+
* @returns {this}
|
|
2023
|
+
*/
|
|
2024
|
+
ws(path, behavior) {
|
|
2025
|
+
checkBehavior(path, behavior);
|
|
2026
|
+
(this._wsRoutes ??= []).push({ path, behavior, owner: this });
|
|
2027
|
+
return this;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1977
2030
|
/**
|
|
1978
2031
|
* A builder for one path, so the path is written once and the verbs chain off it.
|
|
1979
2032
|
*
|
package/src/types.d.ts
CHANGED
|
@@ -24,6 +24,9 @@ declare module "fulmine.js" {
|
|
|
24
24
|
export import urlencoded = e.urlencoded;
|
|
25
25
|
|
|
26
26
|
export import RouterOptions = e.RouterOptions;
|
|
27
|
+
// Router is declared rather than re-exported, because this project's routers carry ws()
|
|
28
|
+
export function Router(options?: e.RouterOptions): FulmineRouter;
|
|
29
|
+
export type Router = FulmineRouter;
|
|
27
30
|
export import Application = e.Application;
|
|
28
31
|
export import CookieOptions = e.CookieOptions;
|
|
29
32
|
export import Errback = e.Errback;
|
|
@@ -41,7 +44,6 @@ declare module "fulmine.js" {
|
|
|
41
44
|
export import RequestHandler = e.RequestHandler;
|
|
42
45
|
export import RequestParamHandler = e.RequestParamHandler;
|
|
43
46
|
export import Response = e.Response;
|
|
44
|
-
export import Router = e.Router;
|
|
45
47
|
export import Send = e.Send;
|
|
46
48
|
}
|
|
47
49
|
|
|
@@ -49,12 +51,43 @@ declare module "fulmine.js" {
|
|
|
49
51
|
uwsApp: uWS.TemplatedApp;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
// what app.ws() hands the socket, and what µWS merges onto it: the request is reachable as
|
|
55
|
+
// ws.req for as long as the socket is open
|
|
56
|
+
type SocketData = { req: e.Request };
|
|
57
|
+
type FulmineWebSocket = uWS.WebSocket<SocketData> & SocketData;
|
|
58
|
+
|
|
59
|
+
// µWS's behavior, with its socket handlers retyped around that request and its own upgrade
|
|
60
|
+
// replaced by this project's, which takes a request and a response
|
|
61
|
+
type WebSocketBehavior = Omit<
|
|
62
|
+
uWS.WebSocketBehavior<SocketData>,
|
|
63
|
+
"upgrade" | "open" | "message" | "dropped" | "drain" | "close" | "ping" | "pong" | "subscription"
|
|
64
|
+
> & {
|
|
65
|
+
upgrade?: (req: e.Request, res: e.Response) => void | Promise<void>;
|
|
66
|
+
open?: (ws: FulmineWebSocket) => void;
|
|
67
|
+
message?: (ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean) => void;
|
|
68
|
+
dropped?: (ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean) => void;
|
|
69
|
+
drain?: (ws: FulmineWebSocket) => void;
|
|
70
|
+
close?: (ws: FulmineWebSocket, code: number, message: ArrayBuffer) => void;
|
|
71
|
+
ping?: (ws: FulmineWebSocket, message: ArrayBuffer) => void;
|
|
72
|
+
pong?: (ws: FulmineWebSocket, message: ArrayBuffer) => void;
|
|
73
|
+
subscription?: (ws: FulmineWebSocket, topic: ArrayBuffer, newCount: number, oldCount: number) => void;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// interfaces rather than aliases: `this` is how ws() answers the router or the app it was
|
|
77
|
+
// called on, and an alias naming itself in an intersection is circular
|
|
78
|
+
interface FulmineRouter extends e.Router {
|
|
79
|
+
ws(path: string, behavior: WebSocketBehavior): this;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface Fulmine extends Omit<e.Express, "listen"> {
|
|
53
83
|
readonly uwsApp: uWS.TemplatedApp;
|
|
54
84
|
listen(port: number, callback?: (token: any) => void): FulmineServer;
|
|
55
85
|
listen(port: number, host: string, callback?: (token: any) => void): FulmineServer;
|
|
56
86
|
listen(callback: (token: any) => void): FulmineServer;
|
|
57
|
-
|
|
87
|
+
ws(path: string, behavior: WebSocketBehavior): this;
|
|
88
|
+
publish(topic: string, message: string | ArrayBuffer | Buffer, isBinary?: boolean, compress?: boolean): boolean;
|
|
89
|
+
numSubscribers(topic: string): number;
|
|
90
|
+
}
|
|
58
91
|
|
|
59
92
|
function express(settings?: Settings): Fulmine;
|
|
60
93
|
|
package/src/usage.js
CHANGED
|
@@ -76,7 +76,13 @@ function callbackUsage(fn) {
|
|
|
76
76
|
|
|
77
77
|
/** @param {Function} fn @returns {number} */
|
|
78
78
|
function analyze(fn) {
|
|
79
|
-
|
|
79
|
+
// toString is application-controlled and may throw or answer anything: unreadable is unknown
|
|
80
|
+
let code;
|
|
81
|
+
try {
|
|
82
|
+
code = String(fn.toString());
|
|
83
|
+
} catch {
|
|
84
|
+
return UNKNOWN;
|
|
85
|
+
}
|
|
80
86
|
if (code.startsWith("function") || code.startsWith("async function")) {
|
|
81
87
|
code = code.replace(/function *\(/, "function __cb(");
|
|
82
88
|
}
|
package/src/websocket.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { canBeOptimizedWithParams, decodeParam, NullObject } = require("./utils.js");
|
|
4
|
+
|
|
5
|
+
// the parameter names in a path, in the order µWS numbers them
|
|
6
|
+
const PARAM = /:(\w+)/g;
|
|
7
|
+
|
|
8
|
+
// Handlers µWS calls with the socket. Everything else in a behavior object is a µWS setting
|
|
9
|
+
// (maxPayloadLength, idleTimeout, compression, ...) and rides through untouched.
|
|
10
|
+
const SOCKET_HANDLERS = ["open", "message", "dropped", "drain", "close", "ping", "pong", "subscription"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Joins a mount path and a route path the way the router does, without the empty-string edges
|
|
14
|
+
* that would leave a double slash.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} prefix
|
|
17
|
+
* @param {string} path
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function joinPaths(prefix, path) {
|
|
21
|
+
if (!prefix || prefix === "/") {
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
if (!path || path === "/") {
|
|
25
|
+
return prefix;
|
|
26
|
+
}
|
|
27
|
+
return prefix + path;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Every websocket route reachable from this router, with the mount paths already applied.
|
|
32
|
+
*
|
|
33
|
+
* Walked separately from the HTTP routes: those fall back to ordinary routing when µWS cannot
|
|
34
|
+
* match them, and a websocket has no fallback to fall back to, so an unmountable one has to be
|
|
35
|
+
* refused out loud instead.
|
|
36
|
+
*
|
|
37
|
+
* @param {any} router
|
|
38
|
+
* @param {string|null} prefix the mount path accumulated so far, or null once a mount was a
|
|
39
|
+
* shape µWS cannot match, which makes everything below it unreachable
|
|
40
|
+
* @param {any[]} out
|
|
41
|
+
* @param {Set<any>} seen routers already walked, since a router may be mounted twice
|
|
42
|
+
*/
|
|
43
|
+
function collectRoutes(router, prefix, out, seen) {
|
|
44
|
+
if (seen.has(router)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
seen.add(router);
|
|
48
|
+
|
|
49
|
+
for (const entry of router._wsRoutes ?? []) {
|
|
50
|
+
if (prefix === null) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`websocket route "${entry.path}" sits under a mount µWS cannot match. ` +
|
|
53
|
+
"Mount the router on a literal path, or on one whose parameters are whole segments."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const path = joinPaths(prefix, entry.path);
|
|
57
|
+
if (!canBeOptimizedWithParams(path)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`websocket path "${path}" is not one µWS can match. Use a literal path, or ` +
|
|
60
|
+
"parameters that are a whole segment, as in /room/:id."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
out.push({ path, behavior: entry.behavior, owner: entry.owner });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const route of router._routes) {
|
|
67
|
+
if (!route.use) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const callback of route.callbacks) {
|
|
71
|
+
// a mounted Router, or a callable sub-app, which is a function carrying routes
|
|
72
|
+
if (callback && callback._routes) {
|
|
73
|
+
const mount =
|
|
74
|
+
prefix === null || typeof route.path !== "string" || !canBeOptimizedWithParams(route.path)
|
|
75
|
+
? null
|
|
76
|
+
: joinPaths(prefix, route.path);
|
|
77
|
+
collectRoutes(callback, mount, out, seen);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The µWS upgrade handler for one route: it builds this project's request and response, offers
|
|
85
|
+
* them to the application's own `upgrade` hook, and completes the handshake unless that hook
|
|
86
|
+
* answered the request itself.
|
|
87
|
+
*
|
|
88
|
+
* @param {any} app the application whose request and response classes serve this route
|
|
89
|
+
* @param {string} path the composed path, whose parameters are read back by index
|
|
90
|
+
* @param {any} behavior what the caller registered
|
|
91
|
+
* @returns {(res: any, req: any, context: any) => void}
|
|
92
|
+
*/
|
|
93
|
+
function makeUpgradeHandler(app, path, behavior) {
|
|
94
|
+
const paramNames = [...path.matchAll(PARAM)].map((match) => match[1]);
|
|
95
|
+
const userUpgrade = behavior.upgrade;
|
|
96
|
+
|
|
97
|
+
return (res, req, context) => {
|
|
98
|
+
// read off the µWS request before anything can await: it is neutered on return, and the
|
|
99
|
+
// handshake needs these three even when the upgrade is decided asynchronously
|
|
100
|
+
const key = req.getHeader("sec-websocket-key");
|
|
101
|
+
const protocol = req.getHeader("sec-websocket-protocol");
|
|
102
|
+
const extensions = req.getHeader("sec-websocket-extensions");
|
|
103
|
+
|
|
104
|
+
const request = new app._request(req, res, app);
|
|
105
|
+
if (paramNames.length) {
|
|
106
|
+
const params = new NullObject();
|
|
107
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
108
|
+
params[paramNames[i]] = decodeParam(req.getParameter(i));
|
|
109
|
+
}
|
|
110
|
+
request.params = params;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let aborted = false;
|
|
114
|
+
|
|
115
|
+
/** Completes the handshake, unless the hook answered or the client already left. */
|
|
116
|
+
const accept = () => {
|
|
117
|
+
if (aborted || request.res?.finished) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// the socket outlives the response, so what only the response can answer is read
|
|
121
|
+
// while it is still alive: reading it later would be a use after free
|
|
122
|
+
request._detachFromResponse();
|
|
123
|
+
res.cork(() => {
|
|
124
|
+
res.upgrade({ req: request }, key, protocol, extensions, context);
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (!userUpgrade) {
|
|
129
|
+
accept();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const response = new app._response(res, request, app);
|
|
134
|
+
request.res = response;
|
|
135
|
+
|
|
136
|
+
let decision;
|
|
137
|
+
try {
|
|
138
|
+
decision = userUpgrade(request, response);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// an upgrade that throws refuses the socket, and says so the way an unhandled route
|
|
141
|
+
// would rather than leaving the client hanging on a half-open handshake
|
|
142
|
+
if (!response.finished) {
|
|
143
|
+
res.cork(() => {
|
|
144
|
+
response.status(500).end();
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
app.emit("error", err);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!decision || typeof decision.then !== "function") {
|
|
152
|
+
accept();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// an async hook (a session lookup, a token check) outlives this callback, so µWS has to
|
|
157
|
+
// be told who to call if the client leaves first. Registered now, still inside the
|
|
158
|
+
// handler, which is the only place µWS accepts it
|
|
159
|
+
res.onAborted(() => {
|
|
160
|
+
aborted = true;
|
|
161
|
+
});
|
|
162
|
+
// and whatever the hook writes now lands outside the cork µWS holds for this callback,
|
|
163
|
+
// so the response opens its own, exactly as a route handler answering late does
|
|
164
|
+
response._corkNeeded = true;
|
|
165
|
+
decision.then(accept, (err) => {
|
|
166
|
+
if (!aborted && !response.finished) {
|
|
167
|
+
res.cork(() => {
|
|
168
|
+
response.status(500).end();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
app.emit("error", err);
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Hands every websocket route this application can reach to µWS. Called from listen(), before
|
|
178
|
+
* the catch-all goes on: µWS routes an upgrade to the websocket route even when a catch-all
|
|
179
|
+
* covers the same path, so the two live side by side.
|
|
180
|
+
*
|
|
181
|
+
* @param {any} app
|
|
182
|
+
*/
|
|
183
|
+
function registerWebSocketRoutes(app) {
|
|
184
|
+
const routes = [];
|
|
185
|
+
collectRoutes(app, "", routes, new Set());
|
|
186
|
+
for (const route of routes) {
|
|
187
|
+
const uwsBehavior = { ...route.behavior };
|
|
188
|
+
delete uwsBehavior.upgrade;
|
|
189
|
+
// bound to the owner's classes, so a mounted sub-app's request layer is the one its own
|
|
190
|
+
// handlers expect
|
|
191
|
+
uwsBehavior.upgrade = makeUpgradeHandler(route.owner ?? app, route.path, route.behavior);
|
|
192
|
+
app.uwsApp.ws(route.path, uwsBehavior);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Whatever a caller passed as a behavior, checked where it is written rather than where it is
|
|
198
|
+
* used: a handler under a misspelled name would otherwise never run and never say why.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} path
|
|
201
|
+
* @param {any} behavior
|
|
202
|
+
*/
|
|
203
|
+
function checkBehavior(path, behavior) {
|
|
204
|
+
if (typeof path !== "string") {
|
|
205
|
+
throw new TypeError("app.ws() requires a path string");
|
|
206
|
+
}
|
|
207
|
+
if (!behavior || typeof behavior !== "object") {
|
|
208
|
+
throw new TypeError("app.ws() requires a behavior object, as µWS takes");
|
|
209
|
+
}
|
|
210
|
+
if (!canBeOptimizedWithParams(path)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`websocket path "${path}" is not one µWS can match. Use a literal path, or ` +
|
|
213
|
+
"parameters that are a whole segment, as in /room/:id."
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
for (const name of [...SOCKET_HANDLERS, "upgrade"]) {
|
|
217
|
+
if (behavior[name] !== undefined && typeof behavior[name] !== "function") {
|
|
218
|
+
throw new TypeError(`app.ws() behavior.${name} must be a function`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { registerWebSocketRoutes, checkBehavior };
|