fulmine.js 5.18.2 → 5.19.1
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 +2 -2
- package/package.json +7 -5
- package/src/application.js +11 -0
- package/src/cli.js +79 -8
- package/src/middlewares.js +16 -2
- package/src/request.js +12 -17
- package/src/response.js +180 -0
- package/src/router.js +87 -0
- package/src/types.d.ts +9 -0
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ It started as a fork of [Ultimate Express](https://github.com/dimdenGD/ultimate-
|
|
|
84
84
|
|
|
85
85
|
Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
|
|
86
86
|
|
|
87
|
-
**Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. The spreads below are the last
|
|
87
|
+
**Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. The spreads below are the last twelve CI runs, which landed on four different runner shapes, all on Node 26. Plain routing lands between 1.3x and 4.9x: hello-world 1.3x to 3.2x, an API endpoint with params and a query 1.9x to 4.9x, five route shapes served by one process 1.6x to 4.1x, nested routers 1.5x to 3.6x, a urlencoded body 2.1x to 5.6x, a thousand concurrent connections 2.2x to 3.6x. Route tables are where the native router shows: a thousand routes 7.5x to 16.4x, with a parameter in every one of them 7.7x to 19.9x, a parameterised route in a mounted router 4.3x to 8.8x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.8x to 2.5x after the per-request work of August 2026. Those spreads are wider than they were: the newest runners are much faster for Express, which moves the ratio without either server changing, and the low end of every row now comes from one of them.
|
|
88
88
|
|
|
89
89
|
**Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere between 1.0x and 1.5x, depending on how much of the request is the shared work, and no amount of effort on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
|
|
90
90
|
|
|
@@ -103,7 +103,7 @@ to run it yourself.
|
|
|
103
103
|
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:
|
|
104
104
|
|
|
105
105
|
- **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)**: thirty profiles on 64-core dedicated hardware, same conditions for every entry, rerun whenever one of them changes. The link lands filtered on the JavaScript entries. No figures are copied here on purpose: the board is the current one and this page would not be.
|
|
106
|
-
- **[web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)**:
|
|
106
|
+
- **[web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)**: in the published round, ranked with the other sixty-odd JavaScript entries on their own hardware. Same rule as above, no figures copied here.
|
|
107
107
|
|
|
108
108
|
More to come as their maintainers take the entries in.
|
|
109
109
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fulmine.js",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.19.1",
|
|
4
4
|
"description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -119,9 +119,9 @@
|
|
|
119
119
|
"@commitlint/cli": "^21.2.1",
|
|
120
120
|
"@commitlint/config-conventional": "^21.2.0",
|
|
121
121
|
"@eslint/js": "^10.0.1",
|
|
122
|
-
"@nestjs/common": "^
|
|
123
|
-
"@nestjs/core": "^
|
|
124
|
-
"@nestjs/platform-express": "^
|
|
122
|
+
"@nestjs/common": "^12.0.1",
|
|
123
|
+
"@nestjs/core": "^12.0.1",
|
|
124
|
+
"@nestjs/platform-express": "^12.0.1",
|
|
125
125
|
"@release-it/conventional-changelog": "^12.0.0",
|
|
126
126
|
"@types/accepts": "^1.3.7",
|
|
127
127
|
"@types/bytes": "^3.1.5",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"pako": "^3.0.1",
|
|
183
183
|
"passport": "^0.7.0",
|
|
184
184
|
"passport-local": "^1.0.0",
|
|
185
|
-
"pkg-pr-new": "^0.0.
|
|
185
|
+
"pkg-pr-new": "^0.0.88",
|
|
186
186
|
"prettier": "^3.9.6",
|
|
187
187
|
"pug": "^3.0.4",
|
|
188
188
|
"reflect-metadata": "^0.2.2",
|
|
@@ -198,6 +198,8 @@
|
|
|
198
198
|
"swagger-ui-express": "^5.0.1",
|
|
199
199
|
"swig": "^1.4.2",
|
|
200
200
|
"tsd": "^0.33.0",
|
|
201
|
+
"typescript": "^6.0.3",
|
|
202
|
+
"typescript7": "npm:typescript@^7.0.2",
|
|
201
203
|
"vhost": "^3.0.2"
|
|
202
204
|
},
|
|
203
205
|
"contributors": [
|
package/src/application.js
CHANGED
|
@@ -687,6 +687,17 @@ class Application extends Router {
|
|
|
687
687
|
return this.uwsApp.numSubscribers(topic);
|
|
688
688
|
}
|
|
689
689
|
|
|
690
|
+
/**
|
|
691
|
+
* The router the application routes through, which express 5 hands out so that a caller can
|
|
692
|
+
* walk `app.router.stack`. Here the application is the router, so it hands back itself and the
|
|
693
|
+
* walk finds the same layers.
|
|
694
|
+
*
|
|
695
|
+
* @returns {this}
|
|
696
|
+
*/
|
|
697
|
+
get router() {
|
|
698
|
+
return this;
|
|
699
|
+
}
|
|
700
|
+
|
|
690
701
|
/**
|
|
691
702
|
* The bound address, or null when not listening.
|
|
692
703
|
* @returns {{address: string, family: string, port: number}|null}
|
package/src/cli.js
CHANGED
|
@@ -143,19 +143,42 @@ function collectFiles(dir) {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
*
|
|
146
|
+
* A reader for the .ts files of the project being migrated, or null when it has no TypeScript.
|
|
147
147
|
*
|
|
148
148
|
* acorn cannot read TypeScript, and shipping a parser that can would put megabytes into this
|
|
149
149
|
* package for a command most people run once. A TypeScript project already has the compiler, so
|
|
150
150
|
* it is resolved from there. A project without one is told its .ts files were left alone rather
|
|
151
151
|
* than having them quietly skipped, which is what happened before they were looked at at all.
|
|
152
152
|
*
|
|
153
|
+
* typescript 7 is the compiler rewritten in Go, and it publishes no JavaScript parser any more:
|
|
154
|
+
* require("typescript") gives back a version number and nothing else. Its scanner survives, on an
|
|
155
|
+
* ESM-only subpath that require() reads on every node this package supports, so 7 gets the token
|
|
156
|
+
* walk and 6 keeps the tree.
|
|
157
|
+
*
|
|
153
158
|
* @param {string} target directory being migrated
|
|
154
|
-
* @returns {
|
|
159
|
+
* @returns {((source: string, fileName: string, seen?: Set<string>) => {start: number, end: number}[])|null}
|
|
155
160
|
*/
|
|
156
161
|
function loadTypeScript(target) {
|
|
162
|
+
/** @param {string} name */
|
|
163
|
+
const load = (name) => require(require.resolve(name, { paths: [target, process.cwd()] }));
|
|
164
|
+
|
|
157
165
|
try {
|
|
158
|
-
|
|
166
|
+
const ts = load("typescript");
|
|
167
|
+
if (typeof ts.createSourceFile === "function") {
|
|
168
|
+
return (source, fileName, seen) => findSpecifiersTypeScript(source, fileName, ts, seen);
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const { createScanner } = load("typescript/unstable/ast/scanner");
|
|
176
|
+
const { LanguageVariant, SyntaxKind } = load("typescript/unstable/ast");
|
|
177
|
+
// an unstable subpath, so the name the token loop stops on is checked here rather than read
|
|
178
|
+
// as undefined and scanned past the end of the file forever
|
|
179
|
+
if (SyntaxKind.EndOfFile === undefined) return null;
|
|
180
|
+
const ts = { createScanner, LanguageVariant, SyntaxKind };
|
|
181
|
+
return (source, fileName, seen) => findSpecifiersScanner(source, fileName, ts, seen);
|
|
159
182
|
} catch {
|
|
160
183
|
return null;
|
|
161
184
|
}
|
|
@@ -222,6 +245,53 @@ function findSpecifiersTypeScript(source, fileName, ts, seen) {
|
|
|
222
245
|
return found;
|
|
223
246
|
}
|
|
224
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The same specifiers again, read from the token stream instead of from a tree. typescript 7 has
|
|
250
|
+
* no parser to give a tree, and its scanner already does the part that a search over the text gets
|
|
251
|
+
* wrong: comments, template literals and strings that only look like imports are not tokens here.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} source
|
|
254
|
+
* @param {string} fileName decides whether JSX is allowed, as above
|
|
255
|
+
* @param {any} ts the scanner and the two enums loadTypeScript kept
|
|
256
|
+
* @param {Set<string>} [seen] as in findSpecifiers
|
|
257
|
+
* @returns {{start: number, end: number}[]}
|
|
258
|
+
*/
|
|
259
|
+
function findSpecifiersScanner(source, fileName, ts, seen) {
|
|
260
|
+
const kind = ts.SyntaxKind;
|
|
261
|
+
const scanner = ts.createScanner(
|
|
262
|
+
true,
|
|
263
|
+
fileName.endsWith(".tsx") ? ts.LanguageVariant.JSX : ts.LanguageVariant.Standard,
|
|
264
|
+
source
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
/** @type {{start: number, end: number}[]} */
|
|
268
|
+
const found = [];
|
|
269
|
+
// the three tokens before the one in hand, newest first
|
|
270
|
+
let back1 = -1;
|
|
271
|
+
let back2 = -1;
|
|
272
|
+
let back3 = -1;
|
|
273
|
+
|
|
274
|
+
for (let token = scanner.scan(); token !== kind.EndOfFile; token = scanner.scan()) {
|
|
275
|
+
// from "express", and import("express") or require("express") as a call. The dot rules out
|
|
276
|
+
// obj.require("express"), which is somebody else's require and not a module specifier
|
|
277
|
+
const isFrom = back1 === kind.FromKeyword;
|
|
278
|
+
const isRequire = back2 === kind.RequireKeyword && back3 !== kind.DotToken && back3 !== kind.QuestionDotToken;
|
|
279
|
+
const isCall = back1 === kind.OpenParenToken && (back2 === kind.ImportKeyword || isRequire);
|
|
280
|
+
if (token === kind.StringLiteral && (isFrom || isCall)) {
|
|
281
|
+
const text = scanner.getTokenValue();
|
|
282
|
+
if (text === FROM) {
|
|
283
|
+
found.push({ start: scanner.getTokenStart(), end: scanner.getTokenEnd() });
|
|
284
|
+
} else if (seen && BUILT_IN_INSTEAD[text]) {
|
|
285
|
+
seen.add(text);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
back3 = back2;
|
|
289
|
+
back2 = back1;
|
|
290
|
+
back1 = token;
|
|
291
|
+
}
|
|
292
|
+
return found;
|
|
293
|
+
}
|
|
294
|
+
|
|
225
295
|
/**
|
|
226
296
|
* The string literals naming the module, found through the parser rather than by searching the
|
|
227
297
|
* text. "express" appears inside express-session, inside comments and inside strings that are not
|
|
@@ -874,7 +944,7 @@ Options:
|
|
|
874
944
|
|
|
875
945
|
// resolved once, and only if there is anything to use it on
|
|
876
946
|
const hasTypeScriptFiles = files.some((file) => TYPESCRIPT_EXTENSIONS.has(path.extname(file)));
|
|
877
|
-
const
|
|
947
|
+
const readTypeScript = hasTypeScriptFiles ? loadTypeScript(target) : null;
|
|
878
948
|
|
|
879
949
|
for (const file of files) {
|
|
880
950
|
const source = fs.readFileSync(file, "utf8");
|
|
@@ -883,14 +953,15 @@ Options:
|
|
|
883
953
|
if (!source.includes(FROM) && !Object.keys(BUILT_IN_INSTEAD).some((name) => source.includes(name))) continue;
|
|
884
954
|
|
|
885
955
|
const isTypeScript = TYPESCRIPT_EXTENSIONS.has(path.extname(file));
|
|
886
|
-
if (isTypeScript && !
|
|
956
|
+
if (isTypeScript && !readTypeScript) {
|
|
887
957
|
needTypeScript.push(path.relative(target, file));
|
|
888
958
|
continue;
|
|
889
959
|
}
|
|
890
960
|
|
|
891
|
-
const specifiers =
|
|
892
|
-
|
|
893
|
-
|
|
961
|
+
const specifiers =
|
|
962
|
+
isTypeScript && readTypeScript
|
|
963
|
+
? readTypeScript(source, file, builtInInstead)
|
|
964
|
+
: findSpecifiers(source, builtInInstead);
|
|
894
965
|
if (specifiers === null) {
|
|
895
966
|
unparsed.push(path.relative(target, file));
|
|
896
967
|
continue;
|
package/src/middlewares.js
CHANGED
|
@@ -898,8 +898,12 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
898
898
|
// context is still intact and an AsyncResource here would be 1.4 microseconds of
|
|
899
899
|
// nothing. The bind happens below, only once a real read is about to go async.
|
|
900
900
|
|
|
901
|
-
// skip reading body twice
|
|
902
|
-
|
|
901
|
+
// skip reading body twice. The second half is what body-parser asks on-finished
|
|
902
|
+
// before it reads, said in this project's own terms: the body has all arrived and
|
|
903
|
+
// the stream is no longer readable, so whoever read it left nothing to wait for.
|
|
904
|
+
// Not readableEnded, which is one of the wrapped Readable members and would build
|
|
905
|
+
// the stream this parser exists to avoid building
|
|
906
|
+
if (req.bodyRead || (req.complete === true && req.readable === false)) {
|
|
903
907
|
return next();
|
|
904
908
|
}
|
|
905
909
|
|
|
@@ -1052,6 +1056,11 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
1052
1056
|
const declaresLength = !Number.isNaN(declared) && declared > 0;
|
|
1053
1057
|
if (!req.receivedData && !inflate && req._res.collectBody && (declaresLength || isNaN(declared))) {
|
|
1054
1058
|
req.bodyRead = true;
|
|
1059
|
+
// µWS hands the whole body over here and the Readable never runs, so the
|
|
1060
|
+
// request has to look read anyway: a parser after this one asks the stream,
|
|
1061
|
+
// not us, and would wait for an end that is never coming
|
|
1062
|
+
req.complete = true;
|
|
1063
|
+
req.readable = false;
|
|
1055
1064
|
req._res.collectBody(limit, (body) => {
|
|
1056
1065
|
if (body === null) {
|
|
1057
1066
|
// over maxSize: uWS refused it natively
|
|
@@ -1246,6 +1255,11 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
1246
1255
|
req._res.onData((ab, isLast) => {
|
|
1247
1256
|
onData(ab);
|
|
1248
1257
|
if (isLast) {
|
|
1258
|
+
// this subscription replaced the Readable's own, so the stream will
|
|
1259
|
+
// never end by itself. What an ended one leaves behind is set here
|
|
1260
|
+
// instead, since that is what the next parser looks at
|
|
1261
|
+
req.complete = true;
|
|
1262
|
+
req.readable = false;
|
|
1249
1263
|
onEnd();
|
|
1250
1264
|
}
|
|
1251
1265
|
});
|
package/src/request.js
CHANGED
|
@@ -875,6 +875,10 @@ module.exports = class Request extends LazyReadable {
|
|
|
875
875
|
// application, so handing back puts the one that was current back, see rememberApp
|
|
876
876
|
this._appStack = undefined;
|
|
877
877
|
this.receivedData = false;
|
|
878
|
+
// node's IncomingMessage flag: false until the whole body has arrived. on-finished
|
|
879
|
+
// reads it to decide a request is done with, and body-parser asks on-finished before
|
|
880
|
+
// it reads, so a parser running after another one has to find it here
|
|
881
|
+
this.complete = false;
|
|
878
882
|
// reading ip is very slow in UWS, so its better to not do it unless truly needed
|
|
879
883
|
if (app.needsIpAfterResponse) {
|
|
880
884
|
// an app that has been seen asking after the response reads it now, because by then
|
|
@@ -896,6 +900,7 @@ module.exports = class Request extends LazyReadable {
|
|
|
896
900
|
this._subscribeBody();
|
|
897
901
|
} else {
|
|
898
902
|
this.receivedData = true;
|
|
903
|
+
this.complete = true;
|
|
899
904
|
// not pushed here: ending a Readable costs a scheduled tick and its bookkeeping,
|
|
900
905
|
// and on a bodyless request nobody may ever look. The null goes out from _read(),
|
|
901
906
|
// which is where every consumer arrives
|
|
@@ -926,6 +931,7 @@ module.exports = class Request extends LazyReadable {
|
|
|
926
931
|
this.#paused = true;
|
|
927
932
|
}
|
|
928
933
|
if (isLast) {
|
|
934
|
+
this.complete = true;
|
|
929
935
|
this.push(null);
|
|
930
936
|
}
|
|
931
937
|
});
|
|
@@ -1518,25 +1524,14 @@ module.exports = class Request extends LazyReadable {
|
|
|
1518
1524
|
#cachedConnection = null;
|
|
1519
1525
|
|
|
1520
1526
|
/**
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
* its
|
|
1524
|
-
*
|
|
1527
|
+
* The socket node would have handed over, which is the same object as `res.socket`: one
|
|
1528
|
+
* stand-in for the pair, as node has one socket for both. Built on first read and kept, so it
|
|
1529
|
+
* keeps its identity across reads, and kept here as well so that it still answers once the
|
|
1530
|
+
* response is over and `res.socket` has gone null.
|
|
1531
|
+
* @returns {any}
|
|
1525
1532
|
*/
|
|
1526
1533
|
get connection() {
|
|
1527
|
-
|
|
1528
|
-
return this.#cachedConnection;
|
|
1529
|
-
}
|
|
1530
|
-
const uwsRes = this._res;
|
|
1531
|
-
return (this.#cachedConnection = {
|
|
1532
|
-
remoteAddress: this.parsedIp,
|
|
1533
|
-
get remotePort() {
|
|
1534
|
-
return uwsRes.getRemotePort();
|
|
1535
|
-
},
|
|
1536
|
-
localPort: this.app.port,
|
|
1537
|
-
encrypted: this.app.ssl,
|
|
1538
|
-
end: (body) => this.res.end(body)
|
|
1539
|
-
});
|
|
1534
|
+
return (this.#cachedConnection ??= this.res._socketShim());
|
|
1540
1535
|
}
|
|
1541
1536
|
|
|
1542
1537
|
/**
|
package/src/response.js
CHANGED
|
@@ -139,6 +139,13 @@ class Socket extends EventEmitter {
|
|
|
139
139
|
super();
|
|
140
140
|
this.response = response;
|
|
141
141
|
this[kShapeMode] = true;
|
|
142
|
+
// middleware assigns to this one, which is why it is a field rather than a getter: express
|
|
143
|
+
// reads socket.encrypted for req.protocol and a proxy shim writes it
|
|
144
|
+
this.encrypted = response.req.app.ssl;
|
|
145
|
+
this.localPort = response.req.app.port;
|
|
146
|
+
// on-finished reads socket.readable before anything else, and a socket without one reads
|
|
147
|
+
// as a request that is already over
|
|
148
|
+
this.readable = true;
|
|
142
149
|
|
|
143
150
|
// shared, not an arrow: one per process instead of one per materialized socket
|
|
144
151
|
this.on("error", Socket._onError);
|
|
@@ -149,6 +156,37 @@ class Socket extends EventEmitter {
|
|
|
149
156
|
return !this.response.finished;
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
/** The peer, as node reports it. Reading it out of µWS is slow, so the request caches it. */
|
|
160
|
+
get remoteAddress() {
|
|
161
|
+
return this.response.req.parsedIp;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A native µWS call almost no caller makes, so it stays behind its getter. */
|
|
165
|
+
get remotePort() {
|
|
166
|
+
return this.response.req._res.getRemotePort();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* node's socket carries these three and applications call them on a request they mean to hold
|
|
171
|
+
* open, almost always to take the timeout off. µWS has no per socket timeout reachable from
|
|
172
|
+
* javascript, so they do nothing and hand the socket back the way node's do. n8n's chat trigger
|
|
173
|
+
* calls setTimeout on every webhook, and without it the workflow answered 500.
|
|
174
|
+
* @returns {this}
|
|
175
|
+
*/
|
|
176
|
+
setTimeout() {
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** @returns {this} */
|
|
181
|
+
setKeepAlive() {
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @returns {this} */
|
|
186
|
+
setNoDelay() {
|
|
187
|
+
return this;
|
|
188
|
+
}
|
|
189
|
+
|
|
152
190
|
/**
|
|
153
191
|
* Finishes the response through the socket, which is how the middleware that only knows
|
|
154
192
|
* about sockets ends one.
|
|
@@ -158,6 +196,100 @@ class Socket extends EventEmitter {
|
|
|
158
196
|
this.response.end(body);
|
|
159
197
|
}
|
|
160
198
|
|
|
199
|
+
/**
|
|
200
|
+
* What a server side socket answers about itself. µWS owns the connection, so these follow the
|
|
201
|
+
* response: it is open until the response is over, and it was never a socket being dialled.
|
|
202
|
+
*/
|
|
203
|
+
get destroyed() {
|
|
204
|
+
return this.response.finished === true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** @returns {string} "open" until the response is over, as a served socket reads. */
|
|
208
|
+
get readyState() {
|
|
209
|
+
return this.response.finished === true ? "closed" : "open";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @returns {boolean} never: this end was accepted, not dialled. */
|
|
213
|
+
get connecting() {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @returns {boolean} never, for the same reason. */
|
|
218
|
+
get pending() {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The end of the connection node reports here. There is no address to read back from µWS, so
|
|
224
|
+
* this is the port the application bound and the family the peer arrived on.
|
|
225
|
+
* @returns {{address: string|undefined, family: string, port: number|undefined}}
|
|
226
|
+
*/
|
|
227
|
+
address() {
|
|
228
|
+
const remote = this.response.req.parsedIp;
|
|
229
|
+
return {
|
|
230
|
+
address: this.response.req.app._listenHost,
|
|
231
|
+
family: remote?.includes(":") ? "IPv6" : "IPv4",
|
|
232
|
+
port: this.localPort
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Drops the connection, which is what an application does to a client it will not serve. node
|
|
238
|
+
* takes an error and re-emits it; this closes and says so through 'close', since there is no
|
|
239
|
+
* socket underneath to carry an error of its own.
|
|
240
|
+
* @returns {this}
|
|
241
|
+
*/
|
|
242
|
+
destroy() {
|
|
243
|
+
this.close();
|
|
244
|
+
return this;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @returns {this} */
|
|
248
|
+
destroySoon() {
|
|
249
|
+
this.close();
|
|
250
|
+
return this;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Holds and resumes the body arriving on this connection, which is the only half of node's
|
|
255
|
+
* pause() that means anything here: the response is written when the application writes it.
|
|
256
|
+
* @returns {this}
|
|
257
|
+
*/
|
|
258
|
+
pause() {
|
|
259
|
+
this.response.req.pause();
|
|
260
|
+
return this;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** @returns {this} */
|
|
264
|
+
resume() {
|
|
265
|
+
this.response.req.resume();
|
|
266
|
+
return this;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* node writes these bytes past the response, straight onto the connection. There is no way
|
|
271
|
+
* past µWS's framing here, so they go through the response instead, which is what the
|
|
272
|
+
* middleware writing to a socket means by it.
|
|
273
|
+
*
|
|
274
|
+
* @param {any} chunk
|
|
275
|
+
* @param {any} [encoding]
|
|
276
|
+
* @param {any} [callback]
|
|
277
|
+
* @returns {boolean}
|
|
278
|
+
*/
|
|
279
|
+
write(chunk, encoding, callback) {
|
|
280
|
+
return this.response.write(chunk, encoding, callback);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The event loop is µWS's, so there is nothing to hold open or let go. @returns {this} */
|
|
284
|
+
ref() {
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** @returns {this} */
|
|
289
|
+
unref() {
|
|
290
|
+
return this;
|
|
291
|
+
}
|
|
292
|
+
|
|
161
293
|
/** Closes the connection outright, without finishing a response first. */
|
|
162
294
|
close() {
|
|
163
295
|
if (this.response.finished) {
|
|
@@ -421,6 +553,30 @@ module.exports = class Response extends LazyWritable {
|
|
|
421
553
|
this._unlinkPending();
|
|
422
554
|
}
|
|
423
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Drops the connection, which is how an application abandons a response it cannot finish: a
|
|
558
|
+
* download whose source dies mid-transfer has to leave the client with a reset rather than a
|
|
559
|
+
* truncated body it would take for the whole file. node destroys the socket here and µWS's
|
|
560
|
+
* close() is the same thing; without it the client waited for bytes that were never coming and
|
|
561
|
+
* the request hung until its own timeout. LibreChat's download route is written exactly that
|
|
562
|
+
* way, `stream.on("error", () => res.destroy())`.
|
|
563
|
+
*
|
|
564
|
+
* A response that is over, or one whose client is already gone, only tears the stream down:
|
|
565
|
+
* there is nothing left to close, and touching an aborted µWS response is a use after free.
|
|
566
|
+
* One difference from node stays: writableEnded reads true after this, because it is answered
|
|
567
|
+
* from the same finished flag the close sets, where node leaves it false until end() is called.
|
|
568
|
+
*
|
|
569
|
+
* @param {any} [error]
|
|
570
|
+
* @returns {this}
|
|
571
|
+
*/
|
|
572
|
+
destroy(error) {
|
|
573
|
+
if (this.finished !== true && this.aborted !== true) {
|
|
574
|
+
this.finished = true;
|
|
575
|
+
this._res.close();
|
|
576
|
+
}
|
|
577
|
+
return super.destroy(error);
|
|
578
|
+
}
|
|
579
|
+
|
|
424
580
|
/**
|
|
425
581
|
* on(), not once(), so this must stay idempotent: end() emits 'close' by hand and a later
|
|
426
582
|
* destroy() makes Writable emit it again.
|
|
@@ -490,6 +646,17 @@ module.exports = class Response extends LazyWritable {
|
|
|
490
646
|
*/
|
|
491
647
|
get socket() {
|
|
492
648
|
if (this.#ended) return null;
|
|
649
|
+
return this._socketShim();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The stand-in itself, built on first ask and kept. `socket` answers null once the response is
|
|
654
|
+
* over, as node's does; the request's `socket` is the same object and stays, so it comes
|
|
655
|
+
* through here instead.
|
|
656
|
+
*
|
|
657
|
+
* @returns {any}
|
|
658
|
+
*/
|
|
659
|
+
_socketShim() {
|
|
493
660
|
if (!this.#socket) {
|
|
494
661
|
this.#socket = new Socket(this);
|
|
495
662
|
}
|
|
@@ -2195,6 +2362,19 @@ module.exports = class Response extends LazyWritable {
|
|
|
2195
2362
|
get writableFinished() {
|
|
2196
2363
|
return this.finished;
|
|
2197
2364
|
}
|
|
2365
|
+
|
|
2366
|
+
/**
|
|
2367
|
+
* Whether end() has been called. node sets this one there and writableFinished later, once the
|
|
2368
|
+
* bytes are out; here end() hands the whole response to µWS, so the two are the same moment.
|
|
2369
|
+
* Without it the base property answered false forever, and an application that asks whether it
|
|
2370
|
+
* has already answered - LibreChat's agent stream does, before it decides whether to keep a
|
|
2371
|
+
* subscription - carried on writing to a response that was over.
|
|
2372
|
+
*/
|
|
2373
|
+
// @ts-expect-error TS2611, the accessor replacing the base property is deliberate. Expect
|
|
2374
|
+
// rather than ignore, so it fails loudly if it ever stops applying.
|
|
2375
|
+
get writableEnded() {
|
|
2376
|
+
return this.finished;
|
|
2377
|
+
}
|
|
2198
2378
|
};
|
|
2199
2379
|
|
|
2200
2380
|
// res.contentType is res.type under express's other name. On the prototype rather than an instance
|
package/src/router.js
CHANGED
|
@@ -142,6 +142,66 @@ let routeKey = 0;
|
|
|
142
142
|
* A nested router gets its own walk, through its own _routeRequest, so req.next belongs to whoever
|
|
143
143
|
* is running the request at that moment.
|
|
144
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* The layer Express makes for one mounted handler. `name` is what a caller matches on: a function's
|
|
147
|
+
* own name, "router" for a mounted router, and "<anonymous>" for the rest, exactly as express reads
|
|
148
|
+
* them off the handle.
|
|
149
|
+
*
|
|
150
|
+
* @param {any} route
|
|
151
|
+
* @param {any} callback
|
|
152
|
+
* @returns {any}
|
|
153
|
+
*/
|
|
154
|
+
function layerFor(route, callback) {
|
|
155
|
+
const layer = {
|
|
156
|
+
handle: callback,
|
|
157
|
+
// express reads the name off the handle, and its own handles are named: a mounted
|
|
158
|
+
// application is "app" and a mounted router "router", whatever this project happens
|
|
159
|
+
// to call the function underneath
|
|
160
|
+
name: Array.isArray(callback._routes)
|
|
161
|
+
? callback._isApplication
|
|
162
|
+
? "app"
|
|
163
|
+
: "router"
|
|
164
|
+
: callback.name || "<anonymous>",
|
|
165
|
+
params: undefined,
|
|
166
|
+
path: undefined,
|
|
167
|
+
keys: [],
|
|
168
|
+
route: undefined
|
|
169
|
+
};
|
|
170
|
+
route._layers.set(callback, layer);
|
|
171
|
+
return layer;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The layer Express makes for a route, whose handle runs the route's own handlers one after
|
|
176
|
+
* another. Express calls that handle `handle`, and a caller that looks for a route layer looks for
|
|
177
|
+
* that name.
|
|
178
|
+
*
|
|
179
|
+
* @param {any} route
|
|
180
|
+
* @returns {any}
|
|
181
|
+
*/
|
|
182
|
+
function routeLayer(route) {
|
|
183
|
+
const handle = function handle(req, res, next) {
|
|
184
|
+
let index = 0;
|
|
185
|
+
const step = (err) => {
|
|
186
|
+
const callback = route.callbacks[index++];
|
|
187
|
+
if (callback === undefined) {
|
|
188
|
+
return next(err);
|
|
189
|
+
}
|
|
190
|
+
const isErrorHandler = callback.length === 4;
|
|
191
|
+
if ((err === undefined || err === null) === isErrorHandler) {
|
|
192
|
+
return step(err);
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return isErrorHandler ? callback(err, req, res, step) : callback(req, res, step);
|
|
196
|
+
} catch (thrown) {
|
|
197
|
+
return step(thrown);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
step();
|
|
201
|
+
};
|
|
202
|
+
return { handle, name: "handle", params: undefined, path: undefined, keys: [], route: route.exposed };
|
|
203
|
+
}
|
|
204
|
+
|
|
145
205
|
class Walk {
|
|
146
206
|
/**
|
|
147
207
|
* @param {any} router
|
|
@@ -1976,6 +2036,33 @@ module.exports = class Router extends EventEmitter {
|
|
|
1976
2036
|
return pattern.test(path);
|
|
1977
2037
|
}
|
|
1978
2038
|
|
|
2039
|
+
/**
|
|
2040
|
+
* The layers Express keeps on a router, in Express's own shape: one per middleware, one per
|
|
2041
|
+
* route, and the route's own handlers under `route.stack`. Libraries that list an
|
|
2042
|
+
* application's endpoints walk this, and so do tests that reach in for a single handler by
|
|
2043
|
+
* name, which is how LibreChat pulls one middleware out of its router to exercise it.
|
|
2044
|
+
*
|
|
2045
|
+
* A view, built from the routes this router holds and rebuilt on every read, so it follows
|
|
2046
|
+
* what has been registered. It is not the router's own storage: pushing a layer onto it, or
|
|
2047
|
+
* splicing one out, moves nothing. The layer objects themselves are kept, so a caller that
|
|
2048
|
+
* compares identities across two reads gets the same answer Express gives.
|
|
2049
|
+
*
|
|
2050
|
+
* @returns {any[]}
|
|
2051
|
+
*/
|
|
2052
|
+
get stack() {
|
|
2053
|
+
const layers = [];
|
|
2054
|
+
for (const route of this._routes) {
|
|
2055
|
+
if (route.use) {
|
|
2056
|
+
for (const callback of route.callbacks) {
|
|
2057
|
+
layers.push((route._layers ??= new Map()).get(callback) ?? layerFor(route, callback));
|
|
2058
|
+
}
|
|
2059
|
+
} else {
|
|
2060
|
+
layers.push((route._routeLayer ??= routeLayer(route)));
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return layers;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1979
2066
|
/**
|
|
1980
2067
|
* Registers a route, which every method helper and use() funnel into. Several paths at once
|
|
1981
2068
|
* become several routes sharing the callbacks, as Express allows. Paths are normalised here and
|
package/src/types.d.ts
CHANGED
|
@@ -234,6 +234,15 @@ declare module "fulmine.js" {
|
|
|
234
234
|
|
|
235
235
|
readonly uwsApp: uWS.TemplatedApp;
|
|
236
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The layers the application routes through, in express's shape: one per middleware, one
|
|
239
|
+
* per route with the route's own handlers under it. Express keeps this on the router it
|
|
240
|
+
* builds and hands out as `app.router`, which is here too and is the application itself,
|
|
241
|
+
* so `app.stack` and `app.router.stack` are the same walk. Read-only: it is rebuilt from
|
|
242
|
+
* the registered routes on every read, so putting a layer into it moves nothing.
|
|
243
|
+
*/
|
|
244
|
+
readonly stack: any[];
|
|
245
|
+
|
|
237
246
|
/**
|
|
238
247
|
* Binds, and calls back the way Express 5 does: with nothing when the socket is listening,
|
|
239
248
|
* and with the error when the bind failed, since Express registers the listen callback on
|