fulmine.js 5.1.2 → 5.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.2",
3
+ "version": "5.1.4",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -70,7 +70,7 @@
70
70
  "cookie": "^1.1.1",
71
71
  "cookie-signature": "^1.2.2",
72
72
  "encodeurl": "^2.0.0",
73
- "fast-querystring": "^1.1.2",
73
+ "fast-decode-uri-component": "^1.0.1",
74
74
  "fast-zlib": "^2.0.1",
75
75
  "fresh": "^2.0.0",
76
76
  "iconv-lite": "^0.7.3",
@@ -131,6 +131,7 @@
131
131
  "express-rate-limit": "^8.5.2",
132
132
  "express-session": "^1.19.0",
133
133
  "express-subdomain": "^1.0.6",
134
+ "fast-querystring": "^1.1.2",
134
135
  "globals": "^17.8.0",
135
136
  "graphql-http": "^1.22.4",
136
137
  "helmet": "^8.2.0",
@@ -28,7 +28,7 @@ const {
28
28
  fastQueryParse,
29
29
  NullObject
30
30
  } = require("./utils.js");
31
- const querystring = require("fast-querystring");
31
+ const parseQuery = require("./parse-query.js");
32
32
  const Request = require("./request.js");
33
33
  const Response = require("./response.js");
34
34
  const ViewClass = require("./view.js");
@@ -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
@@ -187,10 +194,17 @@ class Application extends Router {
187
194
  // the uWS listen socket, and the responses being served right now: close() stops the
188
195
  // first and waits for the second, the way node's server.close() does
189
196
  this._listenSocket = undefined;
190
- this._pendingResponses = new Set();
191
- // on the per-app prototype layer, not per response: the set is the same for every
192
- // response this app serves, and the per-request write was pure repetition
193
- /** @type {any} */ (this.response)._pendingIn = this._pendingResponses;
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;
194
208
  this._draining = false;
195
209
  // read here, at construction, the way express does; an empty NODE_ENV means development,
196
210
  // which the ?? in the shared default would miss
@@ -250,6 +264,56 @@ class Application extends Router {
250
264
  });
251
265
  }
252
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
+
253
317
  /**
254
318
  * Reads or writes an application setting. One argument is the getter, and the check is on
255
319
  * `arguments.length`, so `set(key, undefined)` still writes. Some keys have a side effect:
@@ -281,7 +345,7 @@ class Application extends Router {
281
345
  if (value === "extended") {
282
346
  this.settings["query parser fn"] = fastQueryParse;
283
347
  } else if (value === "simple" || value === true) {
284
- this.settings["query parser fn"] = querystring.parse;
348
+ this.settings["query parser fn"] = parseQuery;
285
349
  } else if (typeof value === "function") {
286
350
  this.settings["query parser fn"] = value;
287
351
  } else if (value === false) {
@@ -374,8 +438,16 @@ class Application extends Router {
374
438
  // removal rides the close listener the Response constructor already has, since a second
375
439
  // once() per request measured a tenth of a microsecond on the hot path.
376
440
  // An aborted response only flips its flags without emitting 'close', which is why
377
- // close()'s drain also sweeps the set by those flags instead of trusting this alone
378
- this._pendingResponses.add(request.res);
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;
379
451
  return request;
380
452
  }
381
453
 
@@ -388,6 +460,9 @@ class Application extends Router {
388
460
  this.uwsApp.any("/*", async (res, req) => {
389
461
  const request = this.handleRequest(res, req);
390
462
  const response = request.res;
463
+ // armed up front here: this handler awaits, so the response outlives the callback
464
+ // on every path through it
465
+ this._armAbort(res, response);
391
466
 
392
467
  try {
393
468
  const routed = this._routeRequest(request, response);
@@ -695,19 +770,23 @@ class Application extends Router {
695
770
  this.uwsApp.close();
696
771
  this.emit("close");
697
772
  };
698
- if (this._pendingResponses.size === 0) {
773
+ if (this._pending.head === null) {
699
774
  process.nextTick(finish);
700
775
  return this;
701
776
  }
702
- // a finished response emits 'close' and removes itself; an aborted one only flips its
777
+ // a finished response emits 'close' and unlinks itself; an aborted one only flips its
703
778
  // flags, so the drain sweeps by them. The timer also keeps the loop alive until done.
704
779
  const sweep = setInterval(() => {
705
- for (const response of this._pendingResponses) {
780
+ let response = this._pending.head;
781
+ while (response !== null) {
782
+ // taken before the unlink, which nulls the pointers
783
+ const next = response._pendingNext;
706
784
  if (response.finished || response.aborted) {
707
- this._pendingResponses.delete(response);
785
+ response._unlinkPending();
708
786
  }
787
+ response = next;
709
788
  }
710
- if (this._pendingResponses.size === 0) {
789
+ if (this._pending.head === null) {
711
790
  clearInterval(sweep);
712
791
  finish();
713
792
  }
@@ -21,7 +21,7 @@ const bytes = require("bytes");
21
21
  const zlib = require("fast-zlib");
22
22
  const typeis = require("type-is");
23
23
  const qs = require("qs");
24
- const querystring = require("fast-querystring");
24
+ const parseQuery = require("./parse-query.js");
25
25
  const { AsyncResource } = require("async_hooks");
26
26
  const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString } = require("./utils.js");
27
27
 
@@ -954,7 +954,8 @@ const urlencoded = createBodyParser(
954
954
  })
955
955
  );
956
956
  } else {
957
- req.body = querystring.parse(body);
957
+ // the vendored parser, so an urlencoded body inspects like req.query does
958
+ req.body = parseQuery(body);
958
959
  }
959
960
  } catch (e) {
960
961
  // qs reports a depth overflow as a RangeError with its own wording; body-parser
package/src/node-shim.js CHANGED
@@ -395,6 +395,8 @@ function serveNodeRequest(router, nodeReq, nodeRes, next) {
395
395
  const shimReq = new NodeHttpRequest(nodeReq);
396
396
  const request = router.handleRequest(shimRes, shimReq);
397
397
  const response = request.res;
398
+ // the shim's onAborted rides node's own close event, needed on every request here
399
+ router._armAbort(shimRes, response);
398
400
 
399
401
  return router._routeRequest(request, response).then((matched) => {
400
402
  if (matched || response.headersSent || response.aborted) {
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+
3
+ /*
4
+ The parser from fast-querystring 1.1.x (MIT, Copyright (c) Yagiz Nizipli,
5
+ https://github.com/anonrig/fast-querystring), vendored for one change: the result is a bare
6
+ Object.create(null) instead of the library's Empty-constructor trick. The trick is faster to
7
+ construct but node inspects it as "Empty <[Object: null prototype] {}>", where Express shows
8
+ "[Object: null prototype]", and matching that used to cost an Object.assign copy of every parse
9
+ on every query-carrying request. The copy was worth more than the trick.
10
+ */
11
+
12
+ const fastDecode = require("fast-decode-uri-component");
13
+
14
+ const plusRegex = /\+/g;
15
+
16
+ /**
17
+ * node's querystring.parse semantics on a null-prototype result: repeated keys accumulate into
18
+ * arrays, '+' is a space, percent sequences decode when present and stay literal when broken.
19
+ *
20
+ * @param {string} input
21
+ * @returns {Record<string, string | string[]>}
22
+ */
23
+ function parseQuery(input) {
24
+ const result = Object.create(null);
25
+
26
+ if (typeof input !== "string") {
27
+ return result;
28
+ }
29
+
30
+ const inputLength = input.length;
31
+ let key;
32
+ let value = "";
33
+ let startingIndex = -1;
34
+ let equalityIndex = -1;
35
+ let shouldDecodeKey = false;
36
+ let shouldDecodeValue = false;
37
+ let keyHasPlus = false;
38
+ let valueHasPlus = false;
39
+ let hasBothKeyValuePair;
40
+ let c;
41
+
42
+ // a boundary of input.length + 1, so the last pair is handled inside the loop
43
+ for (let i = 0; i < inputLength + 1; i++) {
44
+ c = i !== inputLength ? input.charCodeAt(i) : 38;
45
+
46
+ // '&' or the end of the input closes the current pair
47
+ if (c === 38) {
48
+ hasBothKeyValuePair = equalityIndex > startingIndex;
49
+
50
+ // the equality index doubles as the end of the key when there was no '='
51
+ if (!hasBothKeyValuePair) {
52
+ equalityIndex = i;
53
+ }
54
+
55
+ key = input.slice(startingIndex + 1, equalityIndex);
56
+
57
+ // only a pair with at least an '=' or a non-empty key lands in the result
58
+ if (hasBothKeyValuePair || key.length > 0) {
59
+ if (keyHasPlus) {
60
+ key = key.replace(plusRegex, " ");
61
+ }
62
+ if (shouldDecodeKey) {
63
+ key = fastDecode(key) || key;
64
+ }
65
+ if (hasBothKeyValuePair) {
66
+ value = input.slice(equalityIndex + 1, i);
67
+ if (valueHasPlus) {
68
+ value = value.replace(plusRegex, " ");
69
+ }
70
+ if (shouldDecodeValue) {
71
+ value = fastDecode(value) || value;
72
+ }
73
+ }
74
+
75
+ const currentValue = result[key];
76
+ if (currentValue === undefined) {
77
+ result[key] = value;
78
+ } else {
79
+ // value.pop is cheaper than Array.isArray here, as upstream measured
80
+ if (currentValue.pop) {
81
+ currentValue.push(value);
82
+ } else {
83
+ result[key] = [currentValue, value];
84
+ }
85
+ }
86
+ }
87
+
88
+ value = "";
89
+ startingIndex = i;
90
+ equalityIndex = i;
91
+ shouldDecodeKey = false;
92
+ shouldDecodeValue = false;
93
+ keyHasPlus = false;
94
+ valueHasPlus = false;
95
+ } else if (c === 61) {
96
+ if (equalityIndex <= startingIndex) {
97
+ equalityIndex = i;
98
+ } else {
99
+ // a second '=' belongs to the value and needs decoding
100
+ shouldDecodeValue = true;
101
+ }
102
+ } else if (c === 43) {
103
+ if (equalityIndex > startingIndex) {
104
+ valueHasPlus = true;
105
+ } else {
106
+ keyHasPlus = true;
107
+ }
108
+ } else if (c === 37) {
109
+ if (equalityIndex > startingIndex) {
110
+ shouldDecodeValue = true;
111
+ } else {
112
+ shouldDecodeKey = true;
113
+ }
114
+ }
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ module.exports = parseQuery;
package/src/request.js CHANGED
@@ -22,6 +22,7 @@ const parseRange = require("range-parser");
22
22
  const proxyaddr = require("proxy-addr");
23
23
  const { isIP } = require("node:net");
24
24
  const fresh = require("fresh");
25
+ const parseQuery = require("./parse-query.js");
25
26
  const { Readable } = require("stream");
26
27
 
27
28
  // accepts, type-is, proxy-addr and fresh all declare a node IncomingMessage and read nothing off
@@ -540,10 +541,14 @@ module.exports = class Request extends Readable {
540
541
  return this.#cachedQuery;
541
542
  }
542
543
  const qp = this.app.get("query parser fn");
543
- // copied onto a plain null-prototype object, or node inspects fast-querystring's result as
544
- // "Empty <[Object: null prototype] {}>" where Express shows "[Object: null prototype]".
545
- // Object.create(null) and not { __proto__: null }: 318ns against 640
546
- const parsed = qp ? Object.assign(Object.create(null), qp(this._rawQuery)) : Object.create(null);
544
+ // the vendored default already answers on a bare null prototype, so it goes out as is;
545
+ // any other parser is copied onto one, which is what kept fast-querystring's result from
546
+ // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
547
+ const parsed = qp
548
+ ? qp === parseQuery
549
+ ? parseQuery(this._rawQuery)
550
+ : Object.assign(Object.create(null), qp(this._rawQuery))
551
+ : Object.create(null);
547
552
  this.#cachedQuery = parsed;
548
553
  return parsed;
549
554
  }
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
- // the application's graceful close() waits on this set, which lives on the per-app
206
- // response prototype layer, see the Application constructor
207
- /** @type {any} */ (this)._pendingIn?.delete(this);
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
  /**
@@ -911,7 +934,7 @@ module.exports = class Response extends Writable {
911
934
  // serve smaller files using workers
912
935
  if (this.app.workers.length && stat.size < 768 * 1024 && !partial) {
913
936
  this.app
914
- .readFileWithWorker(fullpath)
937
+ .readSmallFile(fullpath, stat)
915
938
  .then((data) => {
916
939
  if (this.finished || this.aborted) {
917
940
  // the client went away while the worker was reading. Express reports
package/src/router.js CHANGED
@@ -1345,11 +1345,22 @@ module.exports = class Router extends EventEmitter {
1345
1345
  const response = new this._response(res, request, this);
1346
1346
  request.res = response;
1347
1347
  response.req = request;
1348
- res.onAborted(onNativeAborted.bind(response));
1349
1348
 
1350
1349
  return request;
1351
1350
  }
1352
1351
 
1352
+ /**
1353
+ * Tells uWS whom to call on a client abort. Out of handleRequest, because uWS only needs it
1354
+ * for a response that outlives its handler callback: the native handler arms it in its
1355
+ * finally when the answer is still pending, which on a synchronous route it never is.
1356
+ *
1357
+ * @param {any} res uWS response
1358
+ * @param {any} response
1359
+ */
1360
+ _armAbort(res, response) {
1361
+ res.onAborted(onNativeAborted.bind(response));
1362
+ }
1363
+
1353
1364
  /**
1354
1365
  * Whether a route registered later in the same router could match a path this one matches.
1355
1366
  *
@@ -1437,6 +1448,11 @@ module.exports = class Router extends EventEmitter {
1437
1448
  // whatever runs after this line is outside the cork uWS held for this
1438
1449
  // callback, so later writes have to open their own
1439
1450
  response._corkNeeded = true;
1451
+ // an abort can only arrive after this callback returns, so a response that
1452
+ // already finished inside it never needs uWS told at all
1453
+ if (!response.finished) {
1454
+ this._armAbort(res, response);
1455
+ }
1440
1456
  }
1441
1457
  };
1442
1458
  };
package/src/utils.js CHANGED
@@ -19,7 +19,7 @@ const mime = require("mime-types");
19
19
  const path = require("path");
20
20
  const proxyaddr = require("proxy-addr");
21
21
  const qs = require("qs");
22
- const querystring = require("fast-querystring");
22
+ const parseQuery = require("./parse-query.js");
23
23
  const crypto = require("crypto");
24
24
  const statuses = require("statuses");
25
25
  const { Stats } = require("fs");
@@ -46,7 +46,8 @@ function fastQueryParse(query, options) {
46
46
  }
47
47
  if (len <= 128) {
48
48
  if (!query.includes("[") && !query.includes("%5B") && !query.includes(".") && !query.includes("%2E")) {
49
- return Object.assign(Object.create(null), querystring.parse(query));
49
+ // already on a bare null prototype, no copy needed, see parse-query.js
50
+ return parseQuery(query);
50
51
  }
51
52
  }
52
53
  return Object.assign(Object.create(null), qs.parse(query, options));
@@ -564,7 +565,7 @@ const defaultSettings = {
564
565
  etag: "weak",
565
566
  "etag fn": () => createETagGenerator({ weak: true }),
566
567
  "query parser": "simple",
567
- "query parser fn": () => querystring.parse,
568
+ "query parser fn": () => parseQuery,
568
569
  "subdomain offset": 2,
569
570
  "trust proxy": false,
570
571
  views: () => path.join(process.cwd(), "views"),
@@ -573,6 +574,9 @@ const defaultSettings = {
573
574
  // asking which framework is running, and every hardening guide says to remove it. Set it back
574
575
  // to true if something depends on it.
575
576
  "x-powered-by": false,
577
+ // fulmine's own: unchanged small files served by sendFile come from a bounded cache
578
+ // validated per request against the file's stat, see Application#readSmallFile
579
+ "file cache": true,
576
580
  // "case sensitive routing" is deliberately absent: unset means insensitive, as in Express 5.
577
581
  // The native µWS router matches bytes, so the compiler in _compileOptimizedRoutes only hands
578
582
  // it routes whose earlier siblings it can prove agree under either case rule.