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 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 nine CI runs, which landed on three different runner shapes, all on Node 26. Plain routing lands between 1.8x and 4.9x: hello-world 1.8x to 2.9x, an API endpoint with params and a query 3.1x to 4.9x, five route shapes served by one process 2.4x to 4.0x, nested routers 2.0x to 3.4x, a urlencoded body 3.3x to 4.6x, a thousand concurrent connections 2.6x to 3.7x. Route tables are where the native router shows: a thousand routes 9.7x to 17.4x, with a parameter in every one of them 10x to 21.2x, a parameterised route in a mounted router 6.8x 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.7x to 2.1x after the per-request work of August 2026.
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)**: entry merged, numbers arrive with their next published round.
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.18.1",
3
+ "version": "5.19.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
  "exports": {
@@ -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}
@@ -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
- if (req.bodyRead) {
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
- this._nodeRes.appendHeader(key, String(value));
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
- * Enough of a node socket for the middleware that reaches for one. Built on first read and
1522
- * kept, so req.socket keeps its identity across reads as node's does. remotePort hides behind
1523
- * its own getter because it is a native uWS call almost no caller makes.
1524
- * @returns {{remoteAddress: string|undefined, remotePort: number, localPort: number|undefined, encrypted: boolean, end: (body?: any) => void}}
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
- if (this.#cachedConnection) {
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(header, val);
915
+ res.writeHeader(name, HEADER_VALUE_BUF[val] || val);
720
916
  }
721
917
  } else {
722
- res.writeHeader(header, value);
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