fulmine.js 5.10.0 → 5.11.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 +8 -6
- package/package.json +1 -1
- package/src/application.js +4 -0
- package/src/declarative.js +106 -32
- package/src/middlewares.js +14 -5
- package/src/response.js +11 -7
- package/src/utils.js +64 -1
- package/src/verify.js +10 -4
package/README.md
CHANGED
|
@@ -349,15 +349,15 @@ routes get:
|
|
|
349
349
|
|
|
350
350
|
1. Fulmine tries to optimize routing as much as possible, but it's only possible if:
|
|
351
351
|
|
|
352
|
-
- the path is a plain string, or its parameters are whole segments: `/users/:id` and `/a/:b/c/:d` qualify, `/flights/:from-:to` does not, and neither does a `*splat` or a `{}` group. Routing is case-insensitive by default, as in Express; a request in the registered case is still served natively, any other case takes the ordinary path, and a route whose overlap with an earlier one leans on a cased literal goes the ordinary way for every request.
|
|
352
|
+
- the path is a plain string, or its parameters are whole segments: `/users/:id` and `/a/:b/c/:d` qualify, `/flights/:from-:to` does not, and neither does a `*splat` or a `{}` group. Routing is case-insensitive by default, as in Express; a request in the registered case is still served natively, any other case takes the ordinary path, and a route whose overlap with an earlier one leans on a cased literal goes the ordinary way for every request. That last one is worth knowing about: `app.set("case sensitive routing", true)` is Express's own setting, and with it `/Users/list` no longer overlaps `/users/:id`, so both are matched by µWS instead of one of them falling back.
|
|
353
353
|
- inside a mounted router, nothing registered after the route in that router could match the same path. `/orders/:id`, `/orders/:id/items` and `/invoices/:id` are all optimized together, since no request reaches two of them. `/users/:id` followed by `/users/me` is not: Express answers `/users/me` with the first of the two and the native router would answer it with the second, so both go the ordinary way.
|
|
354
354
|
|
|
355
355
|
Optimized routes can be up to 10 times faster than normal routes, as they're using native uWS router and have pre-calculated path.
|
|
356
356
|
|
|
357
|
-
On top of that, a handler simple enough to be read at registration time is compiled into a uWS declarative response and answered natively, without entering JavaScript at all. That needs the route to have nothing in front of it, not a middleware and not a `Router` it was mounted under, and a single handler that only calls `res.status`, `res.set`, `res.append`, `res.send`, `res.json`, `res.sendStatus` or `res.end` with literal arguments, plus `req.params` and `req.query`. Anything else, a variable, a call, an `if`, falls back to ordinary routing. `return res.send(...)` compiles, `res.send(...)` does too, and so does an object or an array of literals however deeply nested. Mounting a `Router` costs only this: the routes inside one are still registered on the native uWS router with their full path, and are as fast as any other optimized route. Three things follow from the response being static:
|
|
357
|
+
On top of that, a handler simple enough to be read at registration time is compiled into a uWS declarative response and answered natively, without entering JavaScript at all. That needs the route to have nothing in front of it, not a middleware and not a `Router` it was mounted under, and a single handler that only calls `res.status`, `res.set`, `res.type`, `res.append`, `res.send`, `res.json`, `res.sendStatus` or `res.end` with literal arguments, plus `req.params` and `req.query`. `res.set` takes a pair or a whole object of them, and `res.type` takes what it takes anywhere, since a media type is a lookup on a literal. Anything else, a variable, a call, an `if`, falls back to ordinary routing. `return res.send(...)` compiles, `res.send(...)` does too, and so does an object or an array of literals however deeply nested. Mounting a `Router` costs only this: the routes inside one are still registered on the native uWS router with their full path, and are as fast as any other optimized route. Three things follow from the response being static:
|
|
358
358
|
|
|
359
359
|
- it cannot answer `304 Not Modified`. The ETag is still sent, so caches keep working, but a conditional request gets the whole body back rather than an empty 304. Express replies 304 there.
|
|
360
|
-
- it is framed as `Transfer-Encoding: chunked
|
|
360
|
+
- it carries a `Content-Length` while its body is literal all the way through. A body with a piece taken from the request, `res.send(req.params.id)`, has no length until the request arrives, so that one is framed as `Transfer-Encoding: chunked`. uWS writes the framing either way, which is why neither header can be set by hand.
|
|
361
361
|
- it answers `Connection: keep-alive` even to a request that asked for `Connection: close`. The connection is still closed, since uWS decides that itself, and a client that asked to close is closing anyway.
|
|
362
362
|
|
|
363
363
|
`app.set("declarative responses", false)` turns the whole thing off if you would rather have Express's exact framing than the speed.
|
|
@@ -440,11 +440,11 @@ app.use(express.compression({ threshold: 1024 }));
|
|
|
440
440
|
|
|
441
441
|
Runnable: [`examples/compression.js`](./examples/compression.js).
|
|
442
442
|
|
|
443
|
-
5. If a route answers with a JSON shape you know in advance, [express-fast-json-stringify](https://www.npmjs.com/package/express-fast-json-stringify) compiles that shape into a serializer and `res.fastJson()` replaces `res.json()`. `JSON.stringify()` has to walk an object it knows nothing about; a compiled serializer does not.
|
|
443
|
+
5. If a route answers with a JSON shape you know in advance, [express-fast-json-stringify](https://www.npmjs.com/package/express-fast-json-stringify) compiles that shape into a serializer and `res.fastJson()` replaces `res.json()`. `JSON.stringify()` has to walk an object it knows nothing about; a compiled serializer does not. It is worth reaching for, and a CPU profile says why: on a route answering 3.6KB of JSON, serialising it is about 25% of the time that is not spent waiting, ahead of the ETag at 19% and of everything the framework does to route the request and build its request and response objects.
|
|
444
444
|
|
|
445
445
|
6. Do not set `body methods` to read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower.
|
|
446
446
|
|
|
447
|
-
7. `app.set("etag", false)` is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no `304 Not Modified` replies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated.
|
|
447
|
+
7. `app.set("etag", false)` is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. It is the single biggest thing an ordinary route does: in a CPU profile of one, hashing the body and building the tag are about 21% of the time that is not spent waiting, more than writing the headers and more than building the request and the response together. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no `304 Not Modified` replies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated.
|
|
448
448
|
|
|
449
449
|
8. By default, Fulmine creates 1 (or 0 if your CPU has only 1 core) child thread to improve performance of reading files. You can change this number by setting `threads` to a different number in `express()`, or set to 0 to disable thread pool (`express({ threads: 0 })`). Threads are shared between all express() instances, with largest `threads` number being used. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both.
|
|
450
450
|
|
|
@@ -667,10 +667,12 @@ Two of these keep a compiled form alongside the value, which you can also set di
|
|
|
667
667
|
- `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
|
|
668
668
|
- `query parser fn`, likewise for `query parser`.
|
|
669
669
|
|
|
670
|
-
Fulmine adds
|
|
670
|
+
Fulmine adds five of its own:
|
|
671
671
|
|
|
672
672
|
- `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
|
|
673
|
+
- `connection headers`, on by default. Express sends `Connection: keep-alive` and `Keep-Alive` on every response, and so does this. Turn it off and neither goes out, while a connection the client asked to close still answers `Connection: close`: it is the advertisement that goes, not the truth. Worth 2% to 3.5% here on a route that is not compiled, plus the bytes.
|
|
673
674
|
- `file cache`, on by default. Small files served by `res.sendFile` come from a bounded in-process cache, checked against the file's `stat` on every request, so an edited file is never served stale. Turn it off where every request has to reach the disk, which is what a public benchmark asks of a standard entry: it was worth about 4% on a 4KB file here, so the cost of turning it off is small.
|
|
675
|
+
- `stat cache`, off by default. Takes a duration, `app.set("stat cache", "1s")`. The size and mtime of a file served by `res.sendFile` or `express.static` are remembered for that long, so a file that is asked for again inside the window costs no syscall at all. It was worth 15% on a 3KB file and 3% on a 200KB one, where the bytes are the work. What it costs is the one promise the `file cache` keeps: inside the window an edited file is served as it was, so keep the window shorter than you would notice.
|
|
674
676
|
- `trust proxy protocol`, off by default. Takes `req.ip` from a PROXY protocol preamble, described under [Behind a proxy](#behind-a-proxy). Read the warning there before turning it on.
|
|
675
677
|
|
|
676
678
|
### Request
|
package/package.json
CHANGED
package/src/application.js
CHANGED
|
@@ -25,6 +25,7 @@ const {
|
|
|
25
25
|
compileTrust,
|
|
26
26
|
createETagGenerator,
|
|
27
27
|
fastQueryParse,
|
|
28
|
+
durationSetting,
|
|
28
29
|
NullObject
|
|
29
30
|
} = require("./utils.js");
|
|
30
31
|
const parseQuery = require("./parse-query.js");
|
|
@@ -379,6 +380,9 @@ class Application extends Router {
|
|
|
379
380
|
configurable: true,
|
|
380
381
|
value: false
|
|
381
382
|
});
|
|
383
|
+
} else if (key === "stat cache") {
|
|
384
|
+
// compiled here so the read path is a number and not a duration to parse per request
|
|
385
|
+
this.settings["stat cache ms"] = durationSetting(value, "stat cache");
|
|
382
386
|
} else if (key === "query parser") {
|
|
383
387
|
if (value === "extended") {
|
|
384
388
|
this.settings["query parser fn"] = fastQueryParse;
|
package/src/declarative.js
CHANGED
|
@@ -18,7 +18,7 @@ limitations under the License.
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const acorn = require("acorn");
|
|
21
|
-
const { stringify, withDefaultCharset, withUtf8Charset } = require("./utils.js");
|
|
21
|
+
const { stringify, withDefaultCharset, withUtf8Charset, contentTypeFor } = require("./utils.js");
|
|
22
22
|
// H3App, DeclarativeResponse and _cfg all exist at runtime but are missing from the
|
|
23
23
|
// declaration file the package ships, so the module is read through a loose alias
|
|
24
24
|
const uWS = require("uWebSockets.js");
|
|
@@ -27,8 +27,28 @@ const statuses = require("statuses");
|
|
|
27
27
|
|
|
28
28
|
const parser = acorn.Parser;
|
|
29
29
|
|
|
30
|
-
const allowedResMethods = [
|
|
30
|
+
const allowedResMethods = [
|
|
31
|
+
"set",
|
|
32
|
+
"header",
|
|
33
|
+
"setHeader",
|
|
34
|
+
"type",
|
|
35
|
+
"contentType",
|
|
36
|
+
"sendStatus",
|
|
37
|
+
"status",
|
|
38
|
+
"send",
|
|
39
|
+
"json",
|
|
40
|
+
"end",
|
|
41
|
+
"append"
|
|
42
|
+
];
|
|
43
|
+
|
|
31
44
|
const allowedIdentifiers = ["query", "params", ...allowedResMethods];
|
|
45
|
+
|
|
46
|
+
/** What res.type(x) sets the content type to, which is a lookup on a literal. */
|
|
47
|
+
const typeValueOf = (type) => (type.indexOf("/") === -1 ? contentTypeFor(type) : type);
|
|
48
|
+
|
|
49
|
+
// what one instruction of a declarative response can carry, since uWS writes its length as a u16
|
|
50
|
+
const MAX_INSTRUCTION_LENGTH = 65535;
|
|
51
|
+
|
|
32
52
|
// the three that write a body, of which only one may appear
|
|
33
53
|
const bodyMethods = new Set(["send", "json", "end"]);
|
|
34
54
|
// and the four that finish the response, after which nothing a handler does is observable
|
|
@@ -84,6 +104,23 @@ function collectNodeTypes(node, types) {
|
|
|
84
104
|
}
|
|
85
105
|
}
|
|
86
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The key a property writes, when it is one this can read: a plain name or a literal, never
|
|
109
|
+
* computed and never a getter or a spread.
|
|
110
|
+
*
|
|
111
|
+
* @param {any} property
|
|
112
|
+
* @returns {string|null} null when the shape is not one of those
|
|
113
|
+
*/
|
|
114
|
+
function literalKeyOf(property) {
|
|
115
|
+
if (property.type !== "Property" || property.computed || property.kind !== "init") {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (property.key.type === "Identifier") {
|
|
119
|
+
return property.key.name;
|
|
120
|
+
}
|
|
121
|
+
return property.key.type === "Literal" ? String(property.key.value) : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
87
124
|
/**
|
|
88
125
|
* The value a literal expression denotes, for the shapes whose value is known at registration
|
|
89
126
|
* time. Anything else throws, which the catch around the whole compiler turns into ordinary
|
|
@@ -403,34 +440,62 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
403
440
|
|
|
404
441
|
// get headers
|
|
405
442
|
for (const call of callExprs) {
|
|
443
|
+
const isType = call.obj.propertyName === "type" || call.obj.propertyName === "contentType";
|
|
406
444
|
if (
|
|
407
445
|
call.obj.propertyName === "header" ||
|
|
408
446
|
call.obj.propertyName === "setHeader" ||
|
|
409
|
-
call.obj.propertyName === "set"
|
|
447
|
+
call.obj.propertyName === "set" ||
|
|
448
|
+
isType
|
|
410
449
|
) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (call.
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
450
|
+
// type() is set("content-type", ...) with the media type looked up first, and
|
|
451
|
+
// set() takes a whole object as well, which is one set() per pair. setHeader is
|
|
452
|
+
// node's and throws on anything but a string, so it is not offered the object.
|
|
453
|
+
let pairs;
|
|
454
|
+
if (isType) {
|
|
455
|
+
if (call.arguments[0].type !== "Literal") {
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
pairs = [["content-type", typeValueOf(String(call.arguments[0].value))]];
|
|
459
|
+
} else if (call.arguments.length === 1 && call.obj.propertyName !== "setHeader") {
|
|
460
|
+
if (call.arguments[0].type !== "ObjectExpression") {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
pairs = [];
|
|
464
|
+
for (const property of call.arguments[0].properties) {
|
|
465
|
+
const key = literalKeyOf(property);
|
|
466
|
+
if (key === null || property.value.type !== "Literal") {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
pairs.push([key, String(property.value.value)]);
|
|
470
|
+
}
|
|
426
471
|
} else {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
472
|
+
if (call.arguments[0].type !== "Literal" || call.arguments[1]?.type !== "Literal") {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
// String() at capture: a numeric literal would reach uWS's writeHeader as
|
|
476
|
+
// itself, and uWS refuses anything that is not a string
|
|
477
|
+
pairs = [[call.arguments[0].value, String(call.arguments[1].value)]];
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
for (let [header, value] of pairs) {
|
|
481
|
+
const name = String(header).toLowerCase();
|
|
482
|
+
// res.set charsets a content-type and res.setHeader does not, since the second
|
|
483
|
+
// is node's and node does not know what a media type is
|
|
484
|
+
if (call.obj.propertyName !== "setHeader" && name === "content-type") {
|
|
485
|
+
value = withDefaultCharset(value);
|
|
486
|
+
}
|
|
487
|
+
const index = headers.findIndex((entry) => String(entry[0]).toLowerCase() === name);
|
|
488
|
+
if (index === -1) {
|
|
489
|
+
headers.push([header, value]);
|
|
490
|
+
} else {
|
|
491
|
+
// in place, so the header keeps the position it was first given
|
|
492
|
+
headers[index][1] = value;
|
|
493
|
+
// set replaces the header outright, so any further value append left there
|
|
494
|
+
// goes with it. Replacing only the first left the response carrying both.
|
|
495
|
+
for (let i = headers.length - 1; i > index; i--) {
|
|
496
|
+
if (String(headers[i][0]).toLowerCase() === name) {
|
|
497
|
+
headers.splice(i, 1);
|
|
498
|
+
}
|
|
434
499
|
}
|
|
435
500
|
}
|
|
436
501
|
}
|
|
@@ -656,14 +721,15 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
656
721
|
// the same two the ordinary path seeds every response with. Without them a route answered
|
|
657
722
|
// different headers depending only on whether it happened to be compilable, which is worse
|
|
658
723
|
// than either choice on its own, and a client had no idle timeout to go on.
|
|
724
|
+
const advertise = app.get("connection headers") !== false;
|
|
659
725
|
const connection = headers.find((header) => header[0].toLowerCase() === "connection");
|
|
660
|
-
if (!connection) {
|
|
726
|
+
if (!connection && advertise) {
|
|
661
727
|
decRes = decRes.writeHeader("connection", "keep-alive");
|
|
662
728
|
}
|
|
663
729
|
// not on a connection the handler is closing: Keep-Alive describes one that is staying
|
|
664
730
|
// open, and the ordinary path leaves it out for the same reason
|
|
665
731
|
const closing = typeof connection?.[1] === "string" && connection[1].toLowerCase() === "close";
|
|
666
|
-
if (!closing && !headers.some((header) => header[0].toLowerCase() === "keep-alive")) {
|
|
732
|
+
if (advertise && !closing && !headers.some((header) => header[0].toLowerCase() === "keep-alive")) {
|
|
667
733
|
decRes = decRes.writeHeader("keep-alive", "timeout=10");
|
|
668
734
|
}
|
|
669
735
|
|
|
@@ -702,15 +768,23 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
702
768
|
}
|
|
703
769
|
}
|
|
704
770
|
|
|
705
|
-
// No Content-Length here
|
|
706
|
-
//
|
|
707
|
-
// as well produces a response carrying both, which is invalid and which clients reject
|
|
708
|
-
// outright. Every declarative response is therefore chunked, where Express always sends
|
|
709
|
-
// a length. Changing it means changing uWS.
|
|
771
|
+
// No Content-Length header here: uWS writes the framing itself, and a response carrying
|
|
772
|
+
// both is invalid. Which framing it writes is decided at the end of this function.
|
|
710
773
|
if (app.get("x-powered-by")) {
|
|
711
774
|
decRes = decRes.writeHeader("x-powered-by", "Fulmine");
|
|
712
775
|
}
|
|
713
776
|
|
|
777
|
+
// A body that is literal all the way through goes out as one end(), which is what makes
|
|
778
|
+
// uWS frame it with a Content-Length, as Express does. A part interpolated from the
|
|
779
|
+
// request has no length until the request arrives, so those stay a write each and uWS
|
|
780
|
+
// chunks them.
|
|
781
|
+
const literal = body.every((part) => part.type === "text")
|
|
782
|
+
? body.map((part) => String(part.value)).join("")
|
|
783
|
+
: null;
|
|
784
|
+
if (literal && literal.length <= MAX_INSTRUCTION_LENGTH) {
|
|
785
|
+
return decRes.end(literal);
|
|
786
|
+
}
|
|
787
|
+
|
|
714
788
|
for (const bodyPart of body) {
|
|
715
789
|
if (bodyPart.type === "text" && String(bodyPart.value).length) {
|
|
716
790
|
decRes = decRes.write(String(bodyPart.value));
|
package/src/middlewares.js
CHANGED
|
@@ -37,6 +37,7 @@ const {
|
|
|
37
37
|
memoizeByString,
|
|
38
38
|
containsDotFile,
|
|
39
39
|
negotiateEncoding,
|
|
40
|
+
cachedStat,
|
|
40
41
|
ENCODING_BR,
|
|
41
42
|
ENCODING_GZIP
|
|
42
43
|
} = require("./utils.js");
|
|
@@ -329,9 +330,10 @@ function twinsOf(filePath, ttl) {
|
|
|
329
330
|
* @param {string} filePath absolute path of the file that was asked for
|
|
330
331
|
* @param {string|undefined} accept the request's Accept-Encoding
|
|
331
332
|
* @param {number} ttl how long the twin cache holds an answer, 0 to ask the disk every time
|
|
333
|
+
* @param {number} statTtl how long the twin's own stat stays good, from the "stat cache" setting
|
|
332
334
|
* @returns {{suffix: string, encoding: string, stat: import("fs").Stats}|undefined}
|
|
333
335
|
*/
|
|
334
|
-
function pickPrecompressed(filePath, accept, ttl) {
|
|
336
|
+
function pickPrecompressed(filePath, accept, ttl, statTtl) {
|
|
335
337
|
if (!accept || !hasTwins(filePath.slice(filePath.lastIndexOf(".")))) {
|
|
336
338
|
return undefined;
|
|
337
339
|
}
|
|
@@ -346,7 +348,7 @@ function pickPrecompressed(filePath, accept, ttl) {
|
|
|
346
348
|
}
|
|
347
349
|
if (known === undefined || known[variant.encoding === "br" ? "br" : "gz"] !== false) {
|
|
348
350
|
try {
|
|
349
|
-
const stat =
|
|
351
|
+
const stat = cachedStat(filePath + variant.suffix, statTtl);
|
|
350
352
|
if (!stat.isDirectory()) {
|
|
351
353
|
if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = true;
|
|
352
354
|
return { suffix: variant.suffix, encoding: variant.encoding, stat };
|
|
@@ -532,14 +534,19 @@ function serveStatic(root, options) {
|
|
|
532
534
|
// decides those is the stat of the thing that was asked for.
|
|
533
535
|
let twin;
|
|
534
536
|
if (options.preCompressed && !rawPath.endsWith("/") && !req.endsWithSlash) {
|
|
535
|
-
twin = pickPrecompressed(
|
|
537
|
+
twin = pickPrecompressed(
|
|
538
|
+
filePath,
|
|
539
|
+
req.headers["accept-encoding"],
|
|
540
|
+
twinTtl,
|
|
541
|
+
req.app.settings["stat cache ms"]
|
|
542
|
+
);
|
|
536
543
|
if (twin) {
|
|
537
544
|
stat = twin.stat;
|
|
538
545
|
}
|
|
539
546
|
}
|
|
540
547
|
try {
|
|
541
548
|
if (stat === undefined) {
|
|
542
|
-
stat =
|
|
549
|
+
stat = cachedStat(statTarget, req.app.settings["stat cache ms"]);
|
|
543
550
|
}
|
|
544
551
|
} catch (err) {
|
|
545
552
|
// the one to report when nothing is found: send hands each failed attempt to the next
|
|
@@ -647,7 +654,9 @@ function serveStatic(root, options) {
|
|
|
647
654
|
// told. Said before the lookup, because it is true even when there is no variant
|
|
648
655
|
res.vary("Accept-Encoding");
|
|
649
656
|
// already found before the stat below, on the ordinary path
|
|
650
|
-
const variant =
|
|
657
|
+
const variant =
|
|
658
|
+
twin ??
|
|
659
|
+
pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl, req.app.settings["stat cache ms"]);
|
|
651
660
|
if (variant) {
|
|
652
661
|
_path += variant.suffix;
|
|
653
662
|
stat = variant.stat;
|
package/src/response.js
CHANGED
|
@@ -37,6 +37,7 @@ const {
|
|
|
37
37
|
httpError,
|
|
38
38
|
contentTypeFor,
|
|
39
39
|
statTag,
|
|
40
|
+
cachedStat,
|
|
40
41
|
NullObject
|
|
41
42
|
} = require("./utils.js");
|
|
42
43
|
const { Writable } = require("stream");
|
|
@@ -289,12 +290,15 @@ module.exports = class Response extends LazyWritable {
|
|
|
289
290
|
this.writingChunk = false;
|
|
290
291
|
// timeout=10 is uWS's idle timeout. On the node shim the hosting server enforces its own
|
|
291
292
|
// keepAliveTimeout, so node is left to write the truthful Connection and Keep-Alive itself.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
293
|
+
// "connection headers" off advertises neither, which Express always does: see below for
|
|
294
|
+
// the one this still writes.
|
|
295
|
+
this.headers =
|
|
296
|
+
res._nodeRes || app.settings["connection headers"] === false
|
|
297
|
+
? {}
|
|
298
|
+
: {
|
|
299
|
+
connection: "keep-alive",
|
|
300
|
+
"keep-alive": "timeout=10"
|
|
301
|
+
};
|
|
298
302
|
// the client asked for the connection to be closed, and uWS closes it, so saying otherwise
|
|
299
303
|
// would be telling the client something the transport contradicts. A declarative response
|
|
300
304
|
// cannot do this, being written once and not per request.
|
|
@@ -1045,7 +1049,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
1045
1049
|
let stat = options._stat;
|
|
1046
1050
|
if (!stat) {
|
|
1047
1051
|
try {
|
|
1048
|
-
stat =
|
|
1052
|
+
stat = cachedStat(fullpath, this.app.settings["stat cache ms"]);
|
|
1049
1053
|
} catch (err) {
|
|
1050
1054
|
// the fs error itself, carrying its errno and path, with send's status written on
|
|
1051
1055
|
// it: a missing file is the request's 404, an unreadable one is the server's 500
|
package/src/utils.js
CHANGED
|
@@ -24,6 +24,8 @@ const qs = require("qs");
|
|
|
24
24
|
const parseQuery = require("./parse-query.js");
|
|
25
25
|
const crypto = require("crypto");
|
|
26
26
|
const statuses = require("statuses");
|
|
27
|
+
const ms = require("ms");
|
|
28
|
+
const fs = require("fs");
|
|
27
29
|
const { Stats } = require("fs");
|
|
28
30
|
|
|
29
31
|
const EMPTY_REGEX = new RegExp(``);
|
|
@@ -945,12 +947,71 @@ const defaultSettings = {
|
|
|
945
947
|
// The native µWS router matches bytes, so the compiler in _compileOptimizedRoutes only hands
|
|
946
948
|
// it routes whose earlier siblings it can prove agree under either case rule.
|
|
947
949
|
"declarative responses": true,
|
|
950
|
+
// off: with a window set, the size and mtime of a file served by sendFile are remembered for
|
|
951
|
+
// it, which is one syscall less per request and a file that can be served as it was a moment
|
|
952
|
+
// ago. "stat cache ms" is the window in milliseconds, compiled from it by set()
|
|
953
|
+
"stat cache": false,
|
|
954
|
+
"stat cache ms": 0,
|
|
948
955
|
// off, and it is a security setting rather than a compatibility one: with it on, req.ip is the
|
|
949
956
|
// address a PROXY protocol preamble declared. µWS reads that preamble from any client, so this
|
|
950
957
|
// belongs only to a server nothing can reach except the proxy in front of it. See Request#_readRawIp
|
|
951
|
-
"trust proxy protocol": false
|
|
958
|
+
"trust proxy protocol": false,
|
|
959
|
+
// on, because Express sends both on every response. Off, nothing is advertised and only a
|
|
960
|
+
// connection that is closing says so, which is fewer bytes and one header write less
|
|
961
|
+
"connection headers": true
|
|
952
962
|
};
|
|
953
963
|
|
|
964
|
+
// What a file's stat was, for as long as "stat cache" says it stays good. Size and mtime only,
|
|
965
|
+
// never a body, and only when a window was asked for: nginx's open_file_cache makes the same
|
|
966
|
+
// trade, and the worst a stale entry does is answer with the file as it was a moment ago.
|
|
967
|
+
const statCache = new Map();
|
|
968
|
+
const STAT_CACHE_LIMIT = 4096;
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* The stat of a path, from the cache when a window was asked for and it is still good.
|
|
972
|
+
*
|
|
973
|
+
* A failure is never remembered: a file that is not there is not the hot path, and a file that
|
|
974
|
+
* appears has to be seen at once.
|
|
975
|
+
*
|
|
976
|
+
* @param {string} file
|
|
977
|
+
* @param {number} ttl milliseconds an answer stays good, 0 to ask the disk every time
|
|
978
|
+
* @returns {import("fs").Stats}
|
|
979
|
+
*/
|
|
980
|
+
function cachedStat(file, ttl) {
|
|
981
|
+
if (ttl <= 0) {
|
|
982
|
+
return fs.statSync(file);
|
|
983
|
+
}
|
|
984
|
+
const now = Date.now();
|
|
985
|
+
const known = statCache.get(file);
|
|
986
|
+
if (known !== undefined && known.until > now) {
|
|
987
|
+
return known.stat;
|
|
988
|
+
}
|
|
989
|
+
const stat = fs.statSync(file);
|
|
990
|
+
// cleared rather than evicted one by one, as twinsOf does: a directory big enough to reach
|
|
991
|
+
// the limit is being served by something other than an application server anyway
|
|
992
|
+
if (statCache.size >= STAT_CACHE_LIMIT) {
|
|
993
|
+
statCache.clear();
|
|
994
|
+
}
|
|
995
|
+
statCache.set(file, { stat, until: now + ttl });
|
|
996
|
+
return stat;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* A duration setting as milliseconds: false is off, a string is read by ms, a number is itself.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {any} value
|
|
1003
|
+
* @param {string} name for the error, which names the setting the application wrote
|
|
1004
|
+
* @returns {number}
|
|
1005
|
+
*/
|
|
1006
|
+
function durationSetting(value, name) {
|
|
1007
|
+
const parsed =
|
|
1008
|
+
value === false || value === undefined ? 0 : typeof value === "string" ? ms(/** @type {any} */ (value)) : value;
|
|
1009
|
+
if (typeof parsed !== "number" || !(parsed >= 0)) {
|
|
1010
|
+
throw new TypeError(`${name} must be a duration`);
|
|
1011
|
+
}
|
|
1012
|
+
return parsed;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
954
1015
|
/**
|
|
955
1016
|
* Turns whatever "trust proxy" was set to into the function proxy-addr wants: a predicate saying
|
|
956
1017
|
* whether the address at hop i is trusted. true trusts everything, a number trusts that many hops,
|
|
@@ -1417,6 +1478,8 @@ const NullObject = /** @type {any} */ (function () {});
|
|
|
1417
1478
|
NullObject.prototype = Object.create(null);
|
|
1418
1479
|
|
|
1419
1480
|
module.exports = {
|
|
1481
|
+
cachedStat,
|
|
1482
|
+
durationSetting,
|
|
1420
1483
|
removeDuplicateSlashes,
|
|
1421
1484
|
patternToRegex,
|
|
1422
1485
|
escapePathLiteral,
|
package/src/verify.js
CHANGED
|
@@ -38,6 +38,12 @@ const path = require("path");
|
|
|
38
38
|
// file and then fails on a symbol, which is a worse error than not finding it at all.
|
|
39
39
|
const MIN_GLIBC = "2.38";
|
|
40
40
|
|
|
41
|
+
// The oldest node this package runs on, and the image to name when something older is found. Both
|
|
42
|
+
// follow engines rather than being written out here, so raising it moves every message with it.
|
|
43
|
+
const MIN_NODE = require("../package.json").engines.node.replace(/[^0-9.]/g, "");
|
|
44
|
+
const MIN_NODE_MAJOR = MIN_NODE.split(".")[0];
|
|
45
|
+
const SWAP_IMAGE = `node:${MIN_NODE_MAJOR}-trixie-slim`;
|
|
46
|
+
|
|
41
47
|
// What a project may carry that needs a different API here rather than none. Everything that just
|
|
42
48
|
// works, and everything that only wants a faster built-in, is `npx fulmine migrate`'s business.
|
|
43
49
|
const NEEDS_A_LOOK = {
|
|
@@ -139,7 +145,7 @@ function checkLibc(platform, glibc) {
|
|
|
139
145
|
return result(
|
|
140
146
|
"no",
|
|
141
147
|
`glibc ${glibc}`,
|
|
142
|
-
`the binaries need ${MIN_GLIBC} or newer. A newer base image is the fix:
|
|
148
|
+
`the binaries need ${MIN_GLIBC} or newer. A newer base image is the fix: ${SWAP_IMAGE}.`
|
|
143
149
|
);
|
|
144
150
|
}
|
|
145
151
|
return result("ok", `glibc ${glibc}`);
|
|
@@ -238,13 +244,13 @@ function checkDockerfiles(dir) {
|
|
|
238
244
|
const where = `${name}: ${image}`;
|
|
239
245
|
if (/alpine|musl/i.test(image)) {
|
|
240
246
|
results.push(
|
|
241
|
-
result("no", where,
|
|
247
|
+
result("no", where, `musl, and there is no musl build: ${SWAP_IMAGE} is the closest swap.`)
|
|
242
248
|
);
|
|
243
249
|
continue;
|
|
244
250
|
}
|
|
245
251
|
const node = /^node:(\d+)/.exec(image);
|
|
246
|
-
if (node && Number(node[1]) <
|
|
247
|
-
results.push(result("no", where, `this package needs node
|
|
252
|
+
if (node && Number(node[1]) < Number(MIN_NODE_MAJOR)) {
|
|
253
|
+
results.push(result("no", where, `this package needs node ${MIN_NODE_MAJOR} or newer: ${SWAP_IMAGE}.`));
|
|
248
254
|
continue;
|
|
249
255
|
}
|
|
250
256
|
results.push(result("ok", where));
|