fulmine.js 5.1.1 → 5.1.3
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 +10 -1
- package/package.json +1 -1
- package/src/application.js +93 -15
- package/src/request.js +60 -37
- package/src/response.js +30 -8
- package/src/router.js +61 -14
- package/src/utils.js +3 -0
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Compatibility here is not a claim, it is a test suite. Every test runs against r
|
|
|
37
37
|
|
|
38
38
|
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.
|
|
39
39
|
|
|
40
|
-
**Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and
|
|
40
|
+
**Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 4.3x: hello-world 1.9x to 2.2x, an API endpoint with params and a query 3.2x to 4.3x, five route shapes served by one process 2.5x to 3.3x, nested routers 2.1x to 3.1x, a urlencoded body 3.4x to 4.1x, a thousand concurrent connections 2.7x to 3.2x. Route tables are where the native router shows: a thousand routes 9.7x to 12.9x, with a parameter in every one of them 10.4x to 14x, a parameterised route in a mounted router 7.1x to 8.3x. 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.5x to 1.7x after the per-request work of August 2026.
|
|
41
41
|
|
|
42
42
|
**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 around 1.0x to 1.2x, and no amount of work on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
|
|
43
43
|
|
|
@@ -51,6 +51,15 @@ and posts the result where it belongs: as a comment on the commit or the pull re
|
|
|
51
51
|
`benchmark-summary` artifact on the run, see [`benchmark/README.md`](./benchmark/README.md)
|
|
52
52
|
to run it yourself.
|
|
53
53
|
|
|
54
|
+
## Public benchmarks
|
|
55
|
+
|
|
56
|
+
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
|
+
|
|
58
|
+
- **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): the saved run measures 5.89 million pipelined requests per second, 1.12 million on baseline and 1.04 million on the json profile, ahead of every JavaScript entry on the board.
|
|
59
|
+
- **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
|
|
60
|
+
|
|
61
|
+
More to come as their maintainers take the entries in.
|
|
62
|
+
|
|
54
63
|
## Attribution
|
|
55
64
|
|
|
56
65
|
Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
|
package/package.json
CHANGED
package/src/application.js
CHANGED
|
@@ -77,6 +77,13 @@ class FSWorker {
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// the worker path's own bound: a file bigger than this streams instead, so the cache never
|
|
81
|
+
// holds an entry the read path would not have produced whole
|
|
82
|
+
const FILE_CACHE_MAX_ENTRY = 768 * 1024;
|
|
83
|
+
// oldest-first once the budget is spent. A static directory that beats this is being served by
|
|
84
|
+
// something other than an application server anyway
|
|
85
|
+
const FILE_CACHE_BUDGET = 64 * 1024 * 1024;
|
|
86
|
+
|
|
80
87
|
class Application extends Router {
|
|
81
88
|
/**
|
|
82
89
|
* @param {object} [settings] the options express() takes. uwsOptions goes to uWS and decides
|
|
@@ -120,9 +127,10 @@ class Application extends Router {
|
|
|
120
127
|
* @param {any} req
|
|
121
128
|
* @param {any} res
|
|
122
129
|
* @param {any} app
|
|
130
|
+
* @param {any} [preset]
|
|
123
131
|
*/
|
|
124
|
-
constructor(req, res, app) {
|
|
125
|
-
super(req, res, app);
|
|
132
|
+
constructor(req, res, app, preset) {
|
|
133
|
+
super(req, res, app, preset);
|
|
126
134
|
}
|
|
127
135
|
};
|
|
128
136
|
this._response = class extends Response {
|
|
@@ -186,10 +194,17 @@ class Application extends Router {
|
|
|
186
194
|
// the uWS listen socket, and the responses being served right now: close() stops the
|
|
187
195
|
// first and waits for the second, the way node's server.close() does
|
|
188
196
|
this._listenSocket = undefined;
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
// readSmallFile's cache and its in-flight reads, see the method
|
|
198
|
+
this._fileCache = new Map();
|
|
199
|
+
this._fileCacheBytes = 0;
|
|
200
|
+
this._fileReadsInFlight = new Map();
|
|
201
|
+
// the responses being served right now, an intrusive list: linking is three pointer
|
|
202
|
+
// stores where a Set paid identity hashing and table upkeep per request. A holder object
|
|
203
|
+
// rather than a bare field, because the callable app copies own scalars by value and two
|
|
204
|
+
// copies of a head would disagree; an object rides by reference, the way the Set did
|
|
205
|
+
this._pending = /** @type {{ head: any }} */ ({ head: null });
|
|
206
|
+
// on the per-app prototype layer, not per response, same as the Set was
|
|
207
|
+
/** @type {any} */ (this.response)._pendingIn = this._pending;
|
|
193
208
|
this._draining = false;
|
|
194
209
|
// read here, at construction, the way express does; an empty NODE_ENV means development,
|
|
195
210
|
// which the ?? in the shared default would miss
|
|
@@ -249,6 +264,56 @@ class Application extends Router {
|
|
|
249
264
|
});
|
|
250
265
|
}
|
|
251
266
|
|
|
267
|
+
/**
|
|
268
|
+
* A small file through the worker pool, with two things on top: concurrent asks for the same
|
|
269
|
+
* path share one read, and the bytes of an unchanged file come from a bounded cache,
|
|
270
|
+
* validated against the stat the caller already paid for, so a touched file is re-read.
|
|
271
|
+
* A hit completes on a macrotask, which is when a worker's answer would have arrived; code
|
|
272
|
+
* that passed the suites against worker timing keeps passing against this.
|
|
273
|
+
* `app.set("file cache", false)` turns the cache off; the shared read stays.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} fullpath
|
|
276
|
+
* @param {import("fs").Stats} stat
|
|
277
|
+
* @returns {Promise<Buffer>}
|
|
278
|
+
*/
|
|
279
|
+
readSmallFile(fullpath, stat) {
|
|
280
|
+
const caching = this.get("file cache");
|
|
281
|
+
if (caching) {
|
|
282
|
+
const cached = this._fileCache.get(fullpath);
|
|
283
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
284
|
+
return new Promise((resolve) => setImmediate(resolve, cached.data));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let pending = this._fileReadsInFlight.get(fullpath);
|
|
288
|
+
if (pending) {
|
|
289
|
+
return pending;
|
|
290
|
+
}
|
|
291
|
+
pending = this.readFileWithWorker(fullpath).then((data) => {
|
|
292
|
+
if (caching && stat.size <= FILE_CACHE_MAX_ENTRY) {
|
|
293
|
+
const existing = this._fileCache.get(fullpath);
|
|
294
|
+
if (existing) {
|
|
295
|
+
this._fileCacheBytes -= existing.size;
|
|
296
|
+
this._fileCache.delete(fullpath);
|
|
297
|
+
}
|
|
298
|
+
this._fileCache.set(fullpath, { mtimeMs: stat.mtimeMs, size: stat.size, data });
|
|
299
|
+
this._fileCacheBytes += stat.size;
|
|
300
|
+
for (const [key, entry] of this._fileCache) {
|
|
301
|
+
if (this._fileCacheBytes <= FILE_CACHE_BUDGET) {
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
this._fileCache.delete(key);
|
|
305
|
+
this._fileCacheBytes -= entry.size;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return data;
|
|
309
|
+
});
|
|
310
|
+
this._fileReadsInFlight.set(fullpath, pending);
|
|
311
|
+
// never cached past settlement: a rejection clears the slot the same way
|
|
312
|
+
const clear = () => this._fileReadsInFlight.delete(fullpath);
|
|
313
|
+
pending.then(clear, clear);
|
|
314
|
+
return pending;
|
|
315
|
+
}
|
|
316
|
+
|
|
252
317
|
/**
|
|
253
318
|
* Reads or writes an application setting. One argument is the getter, and the check is on
|
|
254
319
|
* `arguments.length`, so `set(key, undefined)` still writes. Some keys have a side effect:
|
|
@@ -365,15 +430,24 @@ class Application extends Router {
|
|
|
365
430
|
*
|
|
366
431
|
* @param {any} res uWS response
|
|
367
432
|
* @param {any} req uWS request, readable only during this call
|
|
433
|
+
* @param {any} [preset] a literal registration's constants, see nativePreset in the router
|
|
368
434
|
* @returns {any} the request, with the response reachable as request.res
|
|
369
435
|
*/
|
|
370
|
-
handleRequest(res, req) {
|
|
371
|
-
const request = super.handleRequest(res, req);
|
|
436
|
+
handleRequest(res, req, preset) {
|
|
437
|
+
const request = super.handleRequest(res, req, preset);
|
|
372
438
|
// removal rides the close listener the Response constructor already has, since a second
|
|
373
439
|
// once() per request measured a tenth of a microsecond on the hot path.
|
|
374
440
|
// An aborted response only flips its flags without emitting 'close', which is why
|
|
375
|
-
// close()'s drain also sweeps the
|
|
376
|
-
|
|
441
|
+
// close()'s drain also sweeps the list by those flags instead of trusting this alone
|
|
442
|
+
const response = request.res;
|
|
443
|
+
const pending = this._pending;
|
|
444
|
+
response._pendingLinked = true;
|
|
445
|
+
response._pendingPrev = null;
|
|
446
|
+
response._pendingNext = pending.head;
|
|
447
|
+
if (pending.head !== null) {
|
|
448
|
+
pending.head._pendingPrev = response;
|
|
449
|
+
}
|
|
450
|
+
pending.head = response;
|
|
377
451
|
return request;
|
|
378
452
|
}
|
|
379
453
|
|
|
@@ -693,19 +767,23 @@ class Application extends Router {
|
|
|
693
767
|
this.uwsApp.close();
|
|
694
768
|
this.emit("close");
|
|
695
769
|
};
|
|
696
|
-
if (this.
|
|
770
|
+
if (this._pending.head === null) {
|
|
697
771
|
process.nextTick(finish);
|
|
698
772
|
return this;
|
|
699
773
|
}
|
|
700
|
-
// a finished response emits 'close' and
|
|
774
|
+
// a finished response emits 'close' and unlinks itself; an aborted one only flips its
|
|
701
775
|
// flags, so the drain sweeps by them. The timer also keeps the loop alive until done.
|
|
702
776
|
const sweep = setInterval(() => {
|
|
703
|
-
|
|
777
|
+
let response = this._pending.head;
|
|
778
|
+
while (response !== null) {
|
|
779
|
+
// taken before the unlink, which nulls the pointers
|
|
780
|
+
const next = response._pendingNext;
|
|
704
781
|
if (response.finished || response.aborted) {
|
|
705
|
-
|
|
782
|
+
response._unlinkPending();
|
|
706
783
|
}
|
|
784
|
+
response = next;
|
|
707
785
|
}
|
|
708
|
-
if (this.
|
|
786
|
+
if (this._pending.head === null) {
|
|
709
787
|
clearInterval(sweep);
|
|
710
788
|
finish();
|
|
711
789
|
}
|
package/src/request.js
CHANGED
|
@@ -149,6 +149,9 @@ module.exports = class Request extends Readable {
|
|
|
149
149
|
|
|
150
150
|
#paused = false;
|
|
151
151
|
|
|
152
|
+
// a bodyless request whose empty end has not been delivered yet, see the constructor
|
|
153
|
+
#emptyBody = false;
|
|
154
|
+
|
|
152
155
|
body;
|
|
153
156
|
|
|
154
157
|
res;
|
|
@@ -169,7 +172,9 @@ module.exports = class Request extends Readable {
|
|
|
169
172
|
) {
|
|
170
173
|
r._connectionClose = true;
|
|
171
174
|
} else if (
|
|
172
|
-
|
|
175
|
+
// content-length: 0 declares that there is nothing, which is the same as declaring
|
|
176
|
+
// nothing: the stream ends empty either way, without the onData subscription
|
|
177
|
+
(headerKey.length === 14 && headerKey === "content-length" && value !== "0") ||
|
|
173
178
|
(headerKey.length === 17 && headerKey === "transfer-encoding")
|
|
174
179
|
) {
|
|
175
180
|
// noticed here so the body decision in the constructor does not build the headers object
|
|
@@ -191,8 +196,11 @@ module.exports = class Request extends Readable {
|
|
|
191
196
|
* @param {any} req the uWS request, readable only during this call
|
|
192
197
|
* @param {any} res the uWS response
|
|
193
198
|
* @param {any} app the application or router this request arrived at
|
|
199
|
+
* @param {any} [preset] a literal native registration's constants: µWS matched the URL byte
|
|
200
|
+
* for byte against that exact pattern and dispatched by method, so path, method and what
|
|
201
|
+
* derives from them are known without asking
|
|
194
202
|
*/
|
|
195
|
-
constructor(req, res, app) {
|
|
203
|
+
constructor(req, res, app, preset) {
|
|
196
204
|
// the same object every time: Readable reads these options and never writes to them
|
|
197
205
|
super(READABLE_OPTIONS);
|
|
198
206
|
this._res = res;
|
|
@@ -212,25 +220,39 @@ module.exports = class Request extends Readable {
|
|
|
212
220
|
// back off for every request that reads req.query.
|
|
213
221
|
this._rawQuery = req.getQuery() ?? "";
|
|
214
222
|
this.urlQuery = this._rawQuery === "" ? "" : "?" + this._rawQuery;
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
223
|
+
if (preset) {
|
|
224
|
+
// the registration's constants: two native crossings and their strings not asked for
|
|
225
|
+
this.path = preset.path;
|
|
226
|
+
this.originalUrl = preset.path + this.urlQuery;
|
|
227
|
+
this.url = this.originalUrl;
|
|
228
|
+
this._lastUrl = this.originalUrl;
|
|
229
|
+
this.endsWithSlash = preset.endsWithSlash;
|
|
230
|
+
this._opPath = preset.opPath;
|
|
231
|
+
this._originalPath = preset.path;
|
|
232
|
+
this.method = preset.method;
|
|
233
|
+
this._isOptions = preset.isOptions;
|
|
234
|
+
this._isHead = preset.isHead;
|
|
235
|
+
} else {
|
|
236
|
+
// getUrl() is the path already, so the query is joined on and then not split off
|
|
237
|
+
// again. Building originalUrl and picking the path back out of it with indexOf and
|
|
238
|
+
// substring was a search and a second string for something uWS had just handed over.
|
|
239
|
+
this.path = req.getUrl();
|
|
240
|
+
this.originalUrl = this.path + this.urlQuery;
|
|
241
|
+
this.url = this.originalUrl;
|
|
242
|
+
// what the router last wrote to req.url. A middleware assigning something else is a
|
|
243
|
+
// rewrite, which express honours, and dispatch compares against this to notice it
|
|
244
|
+
this._lastUrl = this.originalUrl;
|
|
245
|
+
// charCodeAt rather than indexing: s[i] builds a one character string to throw away
|
|
246
|
+
this.endsWithSlash = this.path.charCodeAt(this.path.length - 1) === 0x2f;
|
|
247
|
+
this._opPath = this.path;
|
|
248
|
+
this._originalPath = this.path;
|
|
249
|
+
if (this.endsWithSlash && this.path !== "/" && !this.app.get("strict routing")) {
|
|
250
|
+
this._opPath = this._opPath.slice(0, -1);
|
|
251
|
+
}
|
|
252
|
+
this.method = req.getCaseSensitiveMethod().toUpperCase();
|
|
253
|
+
this._isOptions = this.method === "OPTIONS";
|
|
254
|
+
this._isHead = this.method === "HEAD";
|
|
255
|
+
}
|
|
234
256
|
this.params = {};
|
|
235
257
|
|
|
236
258
|
// Two Sets per request, for two things almost no request needs.
|
|
@@ -259,25 +281,19 @@ module.exports = class Request extends Readable {
|
|
|
259
281
|
this.rawIp = this._res.getRemoteAddress();
|
|
260
282
|
}
|
|
261
283
|
|
|
262
|
-
|
|
263
|
-
//
|
|
264
|
-
// this
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.method === "PATCH" ||
|
|
269
|
-
this.method === "QUERY" ||
|
|
270
|
-
(additionalMethods && additionalMethods.includes(this.method)) ||
|
|
271
|
-
// any request that declares a body carries one, whatever the verb: a GET with
|
|
272
|
-
// content-length left unread would end this stream empty and poison the keep-alive
|
|
273
|
-
// connection with its unconsumed bytes. uWS itself discards GET bodies, so this is
|
|
274
|
-
// the node shim's path
|
|
275
|
-
/** @type {any} */ (this)._declaresBody
|
|
276
|
-
) {
|
|
284
|
+
// A body exists on the wire only when the request declares one, content-length or
|
|
285
|
+
// transfer-encoding, whatever the verb, and that evidence was spotted during the header
|
|
286
|
+
// copy. The verb list and the "body methods" settings read this branch used to pay per
|
|
287
|
+
// request said nothing the headers had not already said; the setting still gates the
|
|
288
|
+
// body parsers, which is where it matters
|
|
289
|
+
if (/** @type {any} */ (this)._declaresBody) {
|
|
277
290
|
this._subscribeBody();
|
|
278
291
|
} else {
|
|
279
292
|
this.receivedData = true;
|
|
280
|
-
|
|
293
|
+
// not pushed here: ending a Readable costs a scheduled tick and its bookkeeping,
|
|
294
|
+
// and on a bodyless request nobody may ever look. The null goes out from _read(),
|
|
295
|
+
// which is where every consumer arrives
|
|
296
|
+
this.#emptyBody = true;
|
|
281
297
|
}
|
|
282
298
|
}
|
|
283
299
|
|
|
@@ -320,6 +336,13 @@ module.exports = class Request extends Readable {
|
|
|
320
336
|
* lift the backpressure that a full queue put on it.
|
|
321
337
|
*/
|
|
322
338
|
_read() {
|
|
339
|
+
// first, so a bodyless stream still ends for a consumer that arrives after the
|
|
340
|
+
// response finished, which express allows
|
|
341
|
+
if (this.#emptyBody) {
|
|
342
|
+
this.#emptyBody = false;
|
|
343
|
+
this.push(null);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
323
346
|
if (this.#paused && !this.#responseEnded) {
|
|
324
347
|
this.#paused = false;
|
|
325
348
|
this._res.resume();
|
package/src/response.js
CHANGED
|
@@ -202,9 +202,32 @@ module.exports = class Response extends Writable {
|
|
|
202
202
|
*/
|
|
203
203
|
_onCloseCleanup() {
|
|
204
204
|
this.#ended = true;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
205
|
+
this._unlinkPending();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Takes this response out of its app's pending list, which the graceful close() drains. The
|
|
210
|
+
* list head lives in a holder on the per-app response prototype layer, see the Application
|
|
211
|
+
* constructor; the linked flag makes a second call, from the drain or a late 'close', a no-op.
|
|
212
|
+
*/
|
|
213
|
+
_unlinkPending() {
|
|
214
|
+
if (this._pendingLinked !== true) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
this._pendingLinked = false;
|
|
218
|
+
const pending = /** @type {any} */ (this)._pendingIn;
|
|
219
|
+
const prev = this._pendingPrev;
|
|
220
|
+
const next = this._pendingNext;
|
|
221
|
+
if (prev) {
|
|
222
|
+
prev._pendingNext = next;
|
|
223
|
+
} else if (pending && pending.head === this) {
|
|
224
|
+
pending.head = next;
|
|
225
|
+
}
|
|
226
|
+
if (next) {
|
|
227
|
+
next._pendingPrev = prev;
|
|
228
|
+
}
|
|
229
|
+
this._pendingPrev = null;
|
|
230
|
+
this._pendingNext = null;
|
|
208
231
|
}
|
|
209
232
|
|
|
210
233
|
/**
|
|
@@ -284,8 +307,8 @@ module.exports = class Response extends Writable {
|
|
|
284
307
|
}
|
|
285
308
|
|
|
286
309
|
if (!Buffer.isBuffer(chunk) && !(chunk instanceof ArrayBuffer)) {
|
|
310
|
+
// the Buffer view is enough, uWS reads its offset and length itself
|
|
287
311
|
chunk = Buffer.from(chunk);
|
|
288
|
-
chunk = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
|
|
289
312
|
}
|
|
290
313
|
|
|
291
314
|
if (this.chunkedTransfer) {
|
|
@@ -522,9 +545,8 @@ module.exports = class Response extends Writable {
|
|
|
522
545
|
} else if (!data && contentLength) {
|
|
523
546
|
this._res.endWithoutBody(contentLength.toString());
|
|
524
547
|
} else {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
548
|
+
// a Buffer goes to uWS as the view it is: copying it into a fresh ArrayBuffer was
|
|
549
|
+
// an allocation per body, and uWS reads the view's own offset and length
|
|
528
550
|
if (this.req.method === "HEAD") {
|
|
529
551
|
const length = Buffer.byteLength(data ?? "");
|
|
530
552
|
this._res.endWithoutBody(length.toString());
|
|
@@ -912,7 +934,7 @@ module.exports = class Response extends Writable {
|
|
|
912
934
|
// serve smaller files using workers
|
|
913
935
|
if (this.app.workers.length && stat.size < 768 * 1024 && !partial) {
|
|
914
936
|
this.app
|
|
915
|
-
.
|
|
937
|
+
.readSmallFile(fullpath, stat)
|
|
916
938
|
.then((data) => {
|
|
917
939
|
if (this.finished || this.aborted) {
|
|
918
940
|
// the client went away while the worker was reading. Express reports
|
package/src/router.js
CHANGED
|
@@ -671,6 +671,30 @@ function onNativeAborted() {
|
|
|
671
671
|
response.socket?.emit("error", err);
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
/**
|
|
675
|
+
*
|
|
676
|
+
*/
|
|
677
|
+
/**
|
|
678
|
+
* The per-request constants of a fully literal native registration. µWS matched the URL byte for
|
|
679
|
+
* byte against this exact pattern and dispatches by method, so the request constructor can take
|
|
680
|
+
* these as given instead of asking uWS and recomputing them on every request.
|
|
681
|
+
*
|
|
682
|
+
* @param {string} path the registered pattern, which is what getUrl() would have answered
|
|
683
|
+
* @param {string} method uppercase, fixed by which uWS verb the registration used
|
|
684
|
+
* @param {boolean} strict the owner's strict routing, frozen here like the twin registration is
|
|
685
|
+
*/
|
|
686
|
+
function nativePreset(path, method, strict) {
|
|
687
|
+
const endsWithSlash = path.charCodeAt(path.length - 1) === 0x2f;
|
|
688
|
+
return {
|
|
689
|
+
path,
|
|
690
|
+
method,
|
|
691
|
+
endsWithSlash,
|
|
692
|
+
opPath: endsWithSlash && path !== "/" && !strict ? path.slice(0, -1) : path,
|
|
693
|
+
isOptions: method === "OPTIONS",
|
|
694
|
+
isHead: method === "HEAD"
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
674
698
|
/**
|
|
675
699
|
*
|
|
676
700
|
*/
|
|
@@ -1313,10 +1337,11 @@ module.exports = class Router extends EventEmitter {
|
|
|
1313
1337
|
*
|
|
1314
1338
|
* @param {any} res uWS response
|
|
1315
1339
|
* @param {any} req uWS request, readable only during this call
|
|
1340
|
+
* @param {any} [preset] a literal registration's constants, see nativePreset
|
|
1316
1341
|
* @returns {any} the request, with the response reachable as request.res
|
|
1317
1342
|
*/
|
|
1318
|
-
handleRequest(res, req) {
|
|
1319
|
-
const request = new this._request(req, res, this);
|
|
1343
|
+
handleRequest(res, req, preset) {
|
|
1344
|
+
const request = new this._request(req, res, this, preset);
|
|
1320
1345
|
const response = new this._response(res, request, this);
|
|
1321
1346
|
request.res = response;
|
|
1322
1347
|
response.req = request;
|
|
@@ -1381,7 +1406,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1381
1406
|
if (route.path.includes(":")) {
|
|
1382
1407
|
route.optimizedParams = route.path.match(regExParam).map((p) => p.slice(1));
|
|
1383
1408
|
}
|
|
1384
|
-
const makeHandler = (chain) => {
|
|
1409
|
+
const makeHandler = (chain, preset) => {
|
|
1385
1410
|
// all three are registration-time constants: computing them in the handler was a
|
|
1386
1411
|
// closure and a scan of the chain on every native request.
|
|
1387
1412
|
// Falling back resumes after the mount, not after the router's leaf: the leaf can have
|
|
@@ -1394,7 +1419,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1394
1419
|
// and this one never did. nativeDone and nativeFail defer their epilogues to a
|
|
1395
1420
|
// microtask, which is where the await used to resume, so the visible order holds
|
|
1396
1421
|
return (res, req) => {
|
|
1397
|
-
const request = this.handleRequest(res, req);
|
|
1422
|
+
const request = this.handleRequest(res, req, preset);
|
|
1398
1423
|
const response = request.res;
|
|
1399
1424
|
if (optimizedParams) {
|
|
1400
1425
|
request.optimizedParams = new NullObject();
|
|
@@ -1419,12 +1444,22 @@ module.exports = class Router extends EventEmitter {
|
|
|
1419
1444
|
// chain runs without re-matching the method, so the get registration must not see it
|
|
1420
1445
|
const getChain =
|
|
1421
1446
|
route.method === "GET" ? optimizedPath.filter((r) => r.all || r.method !== "HEAD") : optimizedPath;
|
|
1422
|
-
let fn = makeHandler(getChain);
|
|
1423
1447
|
route.optimizedPath = optimizedPath;
|
|
1424
1448
|
|
|
1449
|
+
// A fully literal registration knows path and method here, so each registration site
|
|
1450
|
+
// hands the request constructor its own constants. An "any" registration serves every
|
|
1451
|
+
// verb and a parameterised one matches paths it cannot spell, so both stay dynamic
|
|
1452
|
+
const canPreset = !route.optimizedParams && method !== "any";
|
|
1453
|
+
// the route's own router decides, not the app running the registration: a router created
|
|
1454
|
+
// with { strict: true } and mounted on an app without it does not answer /things/, and
|
|
1455
|
+
// registering that path here is the only way it could
|
|
1456
|
+
const strictHere = Boolean((route.owner ?? this).get("strict routing"));
|
|
1457
|
+
|
|
1458
|
+
let fn = makeHandler(getChain, canPreset ? nativePreset(route.path, route.method, strictHere) : undefined);
|
|
1459
|
+
const jsFn = fn;
|
|
1460
|
+
|
|
1425
1461
|
let replacedPath = route.path;
|
|
1426
|
-
const
|
|
1427
|
-
const headFn = getChain.length === optimizedPath.length ? realFn : makeHandler(optimizedPath);
|
|
1462
|
+
const headChain = getChain.length === optimizedPath.length ? getChain : optimizedPath;
|
|
1428
1463
|
|
|
1429
1464
|
// the response prototype the route will really run under: its own app's, which sees a
|
|
1430
1465
|
// method patched there or inherited from a parent app, falling back to the registering app
|
|
@@ -1447,17 +1482,29 @@ module.exports = class Router extends EventEmitter {
|
|
|
1447
1482
|
}
|
|
1448
1483
|
|
|
1449
1484
|
this.uwsApp[method](replacedPath, fn);
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1485
|
+
if (!strictHere && route.path[route.path.length - 1] !== "/") {
|
|
1486
|
+
// a declarative response answers the twin as itself; a preset handler cannot be
|
|
1487
|
+
// shared, since the twin's path is its own constant
|
|
1488
|
+
const slashFn =
|
|
1489
|
+
fn !== jsFn
|
|
1490
|
+
? fn
|
|
1491
|
+
: canPreset
|
|
1492
|
+
? makeHandler(getChain, nativePreset(route.path + "/", route.method, strictHere))
|
|
1493
|
+
: fn;
|
|
1494
|
+
this.uwsApp[method](replacedPath + "/", slashFn);
|
|
1455
1495
|
if (method === "get") {
|
|
1456
|
-
this.uwsApp.head(
|
|
1496
|
+
this.uwsApp.head(
|
|
1497
|
+
replacedPath + "/",
|
|
1498
|
+
makeHandler(headChain, canPreset ? nativePreset(route.path + "/", "HEAD", strictHere) : undefined)
|
|
1499
|
+
);
|
|
1457
1500
|
}
|
|
1458
1501
|
}
|
|
1459
1502
|
if (method === "get") {
|
|
1460
|
-
|
|
1503
|
+
// its own handler always: the shared one would carry the GET registration's method
|
|
1504
|
+
this.uwsApp.head(
|
|
1505
|
+
replacedPath,
|
|
1506
|
+
makeHandler(headChain, canPreset ? nativePreset(route.path, "HEAD", strictHere) : undefined)
|
|
1507
|
+
);
|
|
1461
1508
|
}
|
|
1462
1509
|
}
|
|
1463
1510
|
|
package/src/utils.js
CHANGED
|
@@ -573,6 +573,9 @@ const defaultSettings = {
|
|
|
573
573
|
// asking which framework is running, and every hardening guide says to remove it. Set it back
|
|
574
574
|
// to true if something depends on it.
|
|
575
575
|
"x-powered-by": false,
|
|
576
|
+
// fulmine's own: unchanged small files served by sendFile come from a bounded cache
|
|
577
|
+
// validated per request against the file's stat, see Application#readSmallFile
|
|
578
|
+
"file cache": true,
|
|
576
579
|
// "case sensitive routing" is deliberately absent: unset means insensitive, as in Express 5.
|
|
577
580
|
// The native µWS router matches bytes, so the compiler in _compileOptimizedRoutes only hands
|
|
578
581
|
// it routes whose earlier siblings it can prove agree under either case rule.
|