fulmine.js 5.1.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.2",
3
+ "version": "5.1.3",
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": {
@@ -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:
@@ -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
 
@@ -695,19 +767,23 @@ class Application extends Router {
695
767
  this.uwsApp.close();
696
768
  this.emit("close");
697
769
  };
698
- if (this._pendingResponses.size === 0) {
770
+ if (this._pending.head === null) {
699
771
  process.nextTick(finish);
700
772
  return this;
701
773
  }
702
- // a finished response emits 'close' and removes itself; an aborted one only flips its
774
+ // a finished response emits 'close' and unlinks itself; an aborted one only flips its
703
775
  // flags, so the drain sweeps by them. The timer also keeps the loop alive until done.
704
776
  const sweep = setInterval(() => {
705
- for (const response of this._pendingResponses) {
777
+ let response = this._pending.head;
778
+ while (response !== null) {
779
+ // taken before the unlink, which nulls the pointers
780
+ const next = response._pendingNext;
706
781
  if (response.finished || response.aborted) {
707
- this._pendingResponses.delete(response);
782
+ response._unlinkPending();
708
783
  }
784
+ response = next;
709
785
  }
710
- if (this._pendingResponses.size === 0) {
786
+ if (this._pending.head === null) {
711
787
  clearInterval(sweep);
712
788
  finish();
713
789
  }
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/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.