fulmine.js 5.18.1 → 5.19.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 +2 -2
- package/package.json +1 -1
- package/src/application.js +11 -0
- package/src/middlewares.js +16 -2
- package/src/node-shim.js +3 -1
- package/src/request.js +12 -17
- package/src/response.js +211 -2
- 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
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/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/node-shim.js
CHANGED
|
@@ -222,7 +222,9 @@ class NodeHttpResponse {
|
|
|
222
222
|
*/
|
|
223
223
|
writeHeader(key, value) {
|
|
224
224
|
if (!this._nodeRes.headersSent) {
|
|
225
|
-
|
|
225
|
+
// String() because writeHeaders hands the recurring names and values over as
|
|
226
|
+
// Buffers for the uWS crossing, and node's appendHeader wants strings
|
|
227
|
+
this._nodeRes.appendHeader(String(key), String(value));
|
|
226
228
|
}
|
|
227
229
|
return this;
|
|
228
230
|
}
|
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
|
@@ -83,6 +83,32 @@ const kShapeMode =
|
|
|
83
83
|
// request are one Map hit. Insert-only after validation, bounded; only setHeader may insert,
|
|
84
84
|
// the never-throwing readers keep their plain toLowerCase
|
|
85
85
|
const VALIDATED_HEADER_NAMES = new Map();
|
|
86
|
+
// The names and values that recur on every response, kept as Buffers for the uWS crossing: a
|
|
87
|
+
// Buffer is memcpy'd as it is, a string pays a UTF-8 scan and copy per call. A header that is
|
|
88
|
+
// not here just misses the lookup and crosses as the string it was. Names must stay lowercase,
|
|
89
|
+
// which is how writeHeaders receives them.
|
|
90
|
+
const HEADER_NAME_BUF = { __proto__: null };
|
|
91
|
+
const HEADER_VALUE_BUF = { __proto__: null };
|
|
92
|
+
for (const s of ["connection", "keep-alive", "content-type", "vary", "x-powered-by", "content-encoding"]) {
|
|
93
|
+
HEADER_NAME_BUF[s] = Buffer.from(s);
|
|
94
|
+
}
|
|
95
|
+
for (const s of [
|
|
96
|
+
"keep-alive",
|
|
97
|
+
"timeout=10",
|
|
98
|
+
"close",
|
|
99
|
+
"Fulmine",
|
|
100
|
+
"Accept-Encoding",
|
|
101
|
+
"text/html; charset=utf-8",
|
|
102
|
+
"text/plain; charset=utf-8",
|
|
103
|
+
"application/json; charset=utf-8",
|
|
104
|
+
"application/octet-stream",
|
|
105
|
+
"gzip",
|
|
106
|
+
"br",
|
|
107
|
+
"deflate",
|
|
108
|
+
"zstd"
|
|
109
|
+
]) {
|
|
110
|
+
HEADER_VALUE_BUF[s] = Buffer.from(s);
|
|
111
|
+
}
|
|
86
112
|
const HIGH_WATERMARK = 128 * 1024;
|
|
87
113
|
// the exact string json() writes, so send() can skip recomputing the charset on it
|
|
88
114
|
const JSON_UTF8 = "application/json; charset=utf-8";
|
|
@@ -113,6 +139,13 @@ class Socket extends EventEmitter {
|
|
|
113
139
|
super();
|
|
114
140
|
this.response = response;
|
|
115
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;
|
|
116
149
|
|
|
117
150
|
// shared, not an arrow: one per process instead of one per materialized socket
|
|
118
151
|
this.on("error", Socket._onError);
|
|
@@ -123,6 +156,37 @@ class Socket extends EventEmitter {
|
|
|
123
156
|
return !this.response.finished;
|
|
124
157
|
}
|
|
125
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
|
+
|
|
126
190
|
/**
|
|
127
191
|
* Finishes the response through the socket, which is how the middleware that only knows
|
|
128
192
|
* about sockets ends one.
|
|
@@ -132,6 +196,100 @@ class Socket extends EventEmitter {
|
|
|
132
196
|
this.response.end(body);
|
|
133
197
|
}
|
|
134
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
|
+
|
|
135
293
|
/** Closes the connection outright, without finishing a response first. */
|
|
136
294
|
close() {
|
|
137
295
|
if (this.response.finished) {
|
|
@@ -395,6 +553,30 @@ module.exports = class Response extends LazyWritable {
|
|
|
395
553
|
this._unlinkPending();
|
|
396
554
|
}
|
|
397
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
|
+
|
|
398
580
|
/**
|
|
399
581
|
* on(), not once(), so this must stay idempotent: end() emits 'close' by hand and a later
|
|
400
582
|
* destroy() makes Writable emit it again.
|
|
@@ -464,6 +646,17 @@ module.exports = class Response extends LazyWritable {
|
|
|
464
646
|
*/
|
|
465
647
|
get socket() {
|
|
466
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() {
|
|
467
660
|
if (!this.#socket) {
|
|
468
661
|
this.#socket = new Socket(this);
|
|
469
662
|
}
|
|
@@ -714,12 +907,15 @@ module.exports = class Response extends LazyWritable {
|
|
|
714
907
|
this.totalSize = parseInt(value);
|
|
715
908
|
continue;
|
|
716
909
|
}
|
|
910
|
+
// the recurring names and values cross as cached Buffers, see HEADER_NAME_BUF; a
|
|
911
|
+
// miss is two failed lookups and the string goes as it came
|
|
912
|
+
const name = HEADER_NAME_BUF[header] || header;
|
|
717
913
|
if (Array.isArray(value)) {
|
|
718
914
|
for (const val of value) {
|
|
719
|
-
res.writeHeader(
|
|
915
|
+
res.writeHeader(name, HEADER_VALUE_BUF[val] || val);
|
|
720
916
|
}
|
|
721
917
|
} else {
|
|
722
|
-
res.writeHeader(
|
|
918
|
+
res.writeHeader(name, HEADER_VALUE_BUF[value] || value);
|
|
723
919
|
}
|
|
724
920
|
}
|
|
725
921
|
this.headersSent = true;
|
|
@@ -2166,6 +2362,19 @@ module.exports = class Response extends LazyWritable {
|
|
|
2166
2362
|
get writableFinished() {
|
|
2167
2363
|
return this.finished;
|
|
2168
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
|
+
}
|
|
2169
2378
|
};
|
|
2170
2379
|
|
|
2171
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
|