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 +1 -1
- package/src/application.js +87 -11
- package/src/response.js +27 -4
- package/src/utils.js +3 -0
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
|
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
378
|
-
|
|
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.
|
|
770
|
+
if (this._pending.head === null) {
|
|
699
771
|
process.nextTick(finish);
|
|
700
772
|
return this;
|
|
701
773
|
}
|
|
702
|
-
// a finished response emits 'close' and
|
|
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
|
-
|
|
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
|
-
|
|
782
|
+
response._unlinkPending();
|
|
708
783
|
}
|
|
784
|
+
response = next;
|
|
709
785
|
}
|
|
710
|
-
if (this.
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -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
|
-
.
|
|
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.
|