fulmine.js 5.9.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 CHANGED
@@ -18,12 +18,12 @@ import type { Request, Response } from "fulmine.js";
18
18
  There is a command that does that replacing for you, across a whole project, and then tells you the handful of things that behave differently:
19
19
 
20
20
  ```sh
21
- npx fulmine verify # can this machine and this image even run it
22
- npx fulmine migrate --dry-run # say what it would change, change nothing
23
- npx fulmine migrate # do it
24
- npx fulmine differences # just the list of what to check by hand
25
- npx fulmine profile # what listen() decided about each route
26
- npx fulmine explain /api/items # what happens when a request for that route arrives
21
+ npx fulmine.js verify # can this machine and this image even run it
22
+ npx fulmine.js migrate --dry-run # say what it would change, change nothing
23
+ npx fulmine.js migrate # do it
24
+ npx fulmine.js differences # just the list of what to check by hand
25
+ npx fulmine.js profile # what listen() decided about each route
26
+ npx fulmine.js explain /api/items # what happens when a request for that route arrives
27
27
  ```
28
28
 
29
29
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
@@ -61,6 +61,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
61
61
  - [Router](#router)
62
62
  - [Tested middlewares](#tested-middlewares)
63
63
  - [Tested view engines](#tested-view-engines)
64
+ - [Examples](./examples/README.md)
64
65
  - [Working on Fulmine](./CONTRIBUTING.md)
65
66
 
66
67
  ## Why this exists
@@ -115,22 +116,19 @@ It is likewise not affiliated with the OpenJS Foundation or the Express.js proje
115
116
 
116
117
  ## Migrating
117
118
 
118
- In a lot of cases, replacing `require("express")` with `require("fulmine.js")` is the whole migration. `npx fulmine migrate` does that across a project:
119
+ In a lot of cases, replacing `require("express")` with `require("fulmine.js")` is the whole migration. `npx fulmine.js migrate` does that across a project:
119
120
 
120
121
  ```sh
121
- npx fulmine migrate [dir] # defaults to the current directory
122
- npx fulmine migrate --dry-run # say what it would rewrite and rewrite nothing
123
- npx fulmine differences # print the list below and change nothing
122
+ npx fulmine.js migrate [dir] # defaults to the current directory
123
+ npx fulmine.js migrate --dry-run # say what it would rewrite and rewrite nothing
124
+ npx fulmine.js differences # print the list below and change nothing
124
125
  ```
125
126
 
126
- The command is installed under both `fulmine` and `fulmine.js`. Use `fulmine`: `npx` cannot run a
127
- command whose name ends in `.js` on Windows, where it exits without a word.
128
-
129
127
  It also names the middlewares it found that have a faster one built in here, `compression`,
130
128
  `body-parser` and `serve-static`, and leaves them to you: the replacement is reached through the
131
129
  `express` import, and no rewrite can know that it is in scope where they are required.
132
130
 
133
- `npx fulmine verify` is the question that comes before all of that: whether this machine, and the
131
+ `npx fulmine.js verify` is the question that comes before all of that: whether this machine, and the
134
132
  image this will be deployed in, can run it at all. There is a µWebSockets.js binary underneath, and
135
133
  a binary is built per platform, per architecture and per node ABI, and linked against glibc. An
136
134
  Alpine base, a node version the pinned build has no binary for, a `FROM node:20-alpine` written
@@ -248,7 +246,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
248
246
 
249
247
  ## Differences from Express
250
248
 
251
- - `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`.
249
+ - `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
252
250
  - `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
253
251
  - request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
254
252
  - **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
@@ -291,6 +289,8 @@ app.listen(3000, () => {
291
289
  });
292
290
  ```
293
291
 
292
+ Runnable: [`examples/https.js`](./examples/https.js).
293
+
294
294
  - This also applies to non-SSL HTTP too. Use `app.listen()` rather than creating a server by hand. `http.createServer(app)` does work, because the app is a request listener like Express's and answers node's requests through a shim, which is what lets `supertest`, `vhost` and anything else that calls an app keep working. But it serves those requests through `node:http` rather than through µWS, so the speed is Express's. It is there for compatibility, not for production.
295
295
  - Node.JS max header size is 16384 bytes, while uWebSockets by default is 4096 bytes, so if you need longer headers set the env variable `UWS_HTTP_MAX_HEADERS_SIZE` to max byte count you need.
296
296
  - uWebSockets drops a request whose body arrives slower than 16KB/s, and the timeout is not reachable from JavaScript, while Node.JS waits as long as the client needs. Uploads over very slow connections can therefore fail here and succeed on Express. A body stalled for 5 seconds still completes; one stalled for 12 seconds gets its socket reset at around 11.8 seconds.
@@ -325,7 +325,7 @@ arriving at a handler costs no matching at all:
325
325
  That is the whole difference on a large route table: the scan grows with the table and the match
326
326
  does not, which is why a thousand routes measure 10x and a handful measure 3x.
327
327
 
328
- Two more things happen on the way in, and `npx fulmine profile` will tell you which of them your
328
+ Two more things happen on the way in, and `npx fulmine.js profile` will tell you which of them your
329
329
  routes get:
330
330
 
331
331
  ```text
@@ -349,24 +349,24 @@ 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` and carries no `Content-Length`, because uWS writes that framing itself.
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.
364
364
 
365
- None of that is guesswork you have to do from the outside. `listen()` decides it all, and `npx fulmine profile` prints what it decided:
365
+ None of that is guesswork you have to do from the outside. `listen()` decides it all, and `npx fulmine.js profile` prints what it decided:
366
366
 
367
367
  ```sh
368
- npx fulmine profile # the file package.json's "main" points at
369
- npx fulmine profile server.js # or name it
368
+ npx fulmine.js profile # the file "main" or the start script points at
369
+ npx fulmine.js profile server.js # or name it
370
370
  ```
371
371
 
372
372
  ```text
@@ -403,9 +403,9 @@ expectDeclarative(app, "/health"); // the step past native: no javascript at all
403
403
  routeReport(app); // the whole list, to assert on however you like
404
404
  ```
405
405
 
406
- A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening.
406
+ A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening. Runnable: [`examples/fast-routes.js`](./examples/fast-routes.js).
407
407
 
408
- `npx fulmine explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
408
+ `npx fulmine.js explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
409
409
 
410
410
  ```text
411
411
  GET /api/items/:id
@@ -425,9 +425,9 @@ The same verdict reaches the browser, per request, with `express.serverTiming()`
425
425
  Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
426
426
  ```
427
427
 
428
- `route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine profile` is where those are counted.
428
+ `route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine.js profile` is where those are counted. Runnable: [`examples/server-timing.js`](./examples/server-timing.js).
429
429
 
430
- 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag.
430
+ 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag. Runnable: [`examples/static-precompressed.js`](./examples/static-precompressed.js).
431
431
 
432
432
  3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
433
433
 
@@ -438,11 +438,13 @@ Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;du
438
438
  app.use(express.compression({ threshold: 1024 }));
439
439
  ```
440
440
 
441
- 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.
441
+ Runnable: [`examples/compression.js`](./examples/compression.js).
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. 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.
442
444
 
443
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.
444
446
 
445
- 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.
446
448
 
447
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.
448
450
 
@@ -461,7 +463,7 @@ app.get("/", (req, res) => res.send("hello"));
461
463
  app.listen(3000, () => console.log(`worker ${process.pid} listening`));
462
464
  ```
463
465
 
464
- Anything held per process is now held per worker: an in-memory cache, a rate-limit counter, a session store or a `Map` of connected sockets is not shared, and needs Redis or something like it to be. `app.close()` in the primary stops the workers, and a `SIGTERM` or `SIGINT` that reaches only the primary, which is what a container sends, is passed on to them.
466
+ Anything held per process is now held per worker: an in-memory cache, a rate-limit counter, a session store or a `Map` of connected sockets is not shared, and needs Redis or something like it to be. `app.close()` in the primary stops the workers, and a `SIGTERM` or `SIGINT` that reaches only the primary, which is what a container sends, is passed on to them. Runnable: [`examples/cluster.js`](./examples/cluster.js).
465
467
 
466
468
  ## WebSockets
467
469
 
@@ -493,7 +495,7 @@ app.ws("/room/:id", {
493
495
  - **Paths are the ones µWS matches**: literal, or with parameters that are a whole segment such as `/room/:id`. Anything else throws where it is written rather than failing to match later.
494
496
  - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
495
497
 
496
- A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing.
498
+ A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing. Runnable, with a page that opens the socket: [`examples/websocket.js`](./examples/websocket.js).
497
499
 
498
500
  If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/dimdenGD/ultimate-ws) is a drop-in replacement for it written against Ultimate Express, and Fulmine still exposes the mechanism it hooks into, but that combination is not covered by this project's tests. `app.uwsApp` also remains available for anything µWS offers that this does not.
499
501
 
@@ -524,7 +526,7 @@ function before it checks for a server, and an app here is callable. That refusa
524
526
  answer. Even if it accepted the object, there is no node socket behind it to take an upgrade over,
525
527
  so it would have failed later and more quietly. Plain HTTP keeps serving either way. This is covered
526
528
  by `tests/tests/middlewares/socket-io.js`, which runs the same file against Express and against
527
- Fulmine and compares the output.
529
+ Fulmine and compares the output. Runnable: [`examples/socket-io.js`](./examples/socket-io.js).
528
530
 
529
531
  ## HTTP/3
530
532
 
@@ -565,7 +567,9 @@ app.set("trust proxy protocol", true);
565
567
 
566
568
  `trust proxy` and this can both be on. The preamble decides what the connection's address is, and
567
569
  `trust proxy` then peels `X-Forwarded-For` off that, so a proxy that sends both is read the way it
568
- meant.
570
+ meant. It is the binary v2 preamble that µWS reads, not the v1 text line, so a connection starting
571
+ with `PROXY TCP4 ...` is answered as a malformed request. Runnable, with a client that writes one:
572
+ [`examples/proxy-protocol.js`](./examples/proxy-protocol.js).
569
573
 
570
574
  ## Versioning
571
575
 
@@ -663,10 +667,12 @@ Two of these keep a compiled form alongside the value, which you can also set di
663
667
  - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
664
668
  - `query parser fn`, likewise for `query parser`.
665
669
 
666
- Fulmine adds three of its own:
670
+ Fulmine adds five of its own:
667
671
 
668
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.
669
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.
670
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.
671
677
 
672
678
  ### Request
@@ -813,6 +819,20 @@ Any Express view engine should work. Here's list of engines we include in our te
813
819
  - ✅ [express-handlebars](https://npmjs.com/package/express-handlebars)
814
820
  - ✅ [swig](https://npmjs.com/package/swig)
815
821
 
822
+ ## Examples
823
+
824
+ [`examples/`](./examples/README.md) has one runnable file per thing this does that Express does not:
825
+ the cluster option, `app.ws()`, socket.io through `attachApp`, the pre-compressed twins,
826
+ `express.compression()`, `express.serverTiming()`, TLS through `uwsOptions`, the PROXY protocol,
827
+ what `listen()` decided about each route, and the app answering as an `http.Server`. What an
828
+ Express application already does is documented by Express and is not repeated there.
829
+
830
+ ```sh
831
+ cd examples
832
+ npm install
833
+ node websocket.js
834
+ ```
835
+
816
836
  ## Working on Fulmine
817
837
 
818
838
  How to run the suites, what each of them is for, and how to write a comparison test:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.9.0",
3
+ "version": "5.11.0",
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": {
@@ -29,9 +29,7 @@
29
29
  "release:local": "node tools/release-local.js",
30
30
  "cover:full": "nyc --silent npm run test && nyc --silent --no-clean npm run test:unit && nyc --silent --no-clean npm run test:express && nyc report",
31
31
  "cover:check": "nyc check-coverage --statements 94.5 --branches 90 --functions 94 --lines 94.5",
32
- "demo:start": "npm --prefix demo install && npm --prefix demo start",
33
- "demo:deploy": "cd demo && fly deploy",
34
- "demo:logs": "fly logs --app fulmine-demo"
32
+ "examples:install": "npm --prefix examples install"
35
33
  },
36
34
  "engines": {
37
35
  "node": ">=22"
@@ -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/cli.js CHANGED
@@ -304,7 +304,40 @@ function walk(node, visit) {
304
304
  const DEFAULT_ENTRIES = ["server.js", "app.js", "index.js", "src/server.js", "src/app.js", "src/index.js"];
305
305
 
306
306
  /**
307
- * The file to load, from the argument, or from package.json's main, or from the usual names.
307
+ * The file a start script runs, when it runs node on one.
308
+ *
309
+ * "main" is about what a package exports, and a service usually exports nothing: the entry of a
310
+ * deployed application is far more often the one written here, which is also the only place that
311
+ * knows about a src/ or a bin/ the usual names do not cover.
312
+ *
313
+ * @param {unknown} script the "start" script, as package.json wrote it
314
+ * @returns {string|null}
315
+ */
316
+ function entryFromScript(script) {
317
+ if (typeof script !== "string") {
318
+ return null;
319
+ }
320
+ const words = script.split(/\s+/).filter(Boolean);
321
+ if (!/^(node|nodejs)$/.test(path.basename(words[0] ?? "", ".exe"))) {
322
+ // ts-node, nodemon, a shell pipeline: what that runs is not a file this can load
323
+ return null;
324
+ }
325
+ for (const word of words.slice(1)) {
326
+ if (word.startsWith("-")) {
327
+ continue; // --env-file=.env, --watch, and the rest of node's own flags
328
+ }
329
+ const candidate = path.resolve(word);
330
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
331
+ return candidate;
332
+ }
333
+ break; // the first thing that is not a flag is the file, and it is not there
334
+ }
335
+ return null;
336
+ }
337
+
338
+ /**
339
+ * The file to load, from the argument, from package.json's main or start script, or from the
340
+ * usual names.
308
341
  *
309
342
  * @param {string|undefined} given
310
343
  * @returns {string|null}
@@ -319,6 +352,12 @@ function findEntry(given) {
319
352
  if (pkg.main && fs.existsSync(path.resolve(pkg.main))) {
320
353
  return path.resolve(pkg.main);
321
354
  }
355
+ // a main that names a file nobody built, dist/server.js in a TypeScript project, is worth
356
+ // no more than no main at all
357
+ const started = entryFromScript(pkg.scripts?.start);
358
+ if (started) {
359
+ return started;
360
+ }
322
361
  } catch {
323
362
  // no package.json, or one that will not parse: the usual names are still worth trying
324
363
  }
@@ -331,6 +370,57 @@ function findEntry(given) {
331
370
  return null;
332
371
  }
333
372
 
373
+ /**
374
+ * Every build of this library the application could load, as the prototype that owns listen().
375
+ *
376
+ * The command runs from its own copy, and the application loads whichever one resolves from its
377
+ * own directory. That is usually the same file and sometimes is not: a global install, an
378
+ * `npx fulmine.js@version`, a workspace that hoisted a second copy, or the `express` name pointing
379
+ * here through an override. Patching only this command's copy leaves the application's own listen()
380
+ * to bind the port, and the command then reports that the file built nothing.
381
+ *
382
+ * An app is a callable, so its own prototype is not the one that carries the methods: walk up to
383
+ * whichever link owns listen.
384
+ *
385
+ * @param {string} entry
386
+ * @returns {any[]} the prototypes to stub, this command's copy first
387
+ */
388
+ function listenOwners(entry) {
389
+ const builds = new Set([require("./index.js")]);
390
+ for (const specifier of [TO, FROM]) {
391
+ try {
392
+ builds.add(require(require.resolve(specifier, { paths: [path.dirname(entry), process.cwd()] })));
393
+ } catch {
394
+ // not installed next to the application, or not resolvable from there
395
+ }
396
+ }
397
+
398
+ const owners = [];
399
+ for (const build of builds) {
400
+ if (typeof build !== "function") {
401
+ continue;
402
+ }
403
+ let app;
404
+ try {
405
+ app = build();
406
+ } catch {
407
+ continue; // not an application factory, or one that will not build without arguments
408
+ }
409
+ // real express resolves under the same two names, and has none of this to stub
410
+ if (typeof app._compileOptimizedRoutes !== "function") {
411
+ continue;
412
+ }
413
+ let proto = Object.getPrototypeOf(app);
414
+ while (proto && !Object.prototype.hasOwnProperty.call(proto, "listen")) {
415
+ proto = Object.getPrototypeOf(proto);
416
+ }
417
+ if (proto && !owners.includes(proto)) {
418
+ owners.push(proto);
419
+ }
420
+ }
421
+ return owners;
422
+ }
423
+
334
424
  /**
335
425
  * The applications a file builds, compiled but not listening.
336
426
  *
@@ -348,42 +438,38 @@ function loadApps(argv, command) {
348
438
  if (!entry) {
349
439
  console.error(
350
440
  `Nothing to ${command}: name the file that builds the application, or run this from a
351
- ` + "directory whose package.json main points at it."
441
+ ` + "directory whose package.json main or start script points at it."
352
442
  );
353
443
  return null;
354
444
  }
355
445
 
356
- // the same module instance the application will load, so patching this prototype patches the
357
- // application it builds. An app is a callable, so its own prototype is not the one that carries
358
- // the methods: walk up to whichever link owns listen
359
- const express = require("./index.js");
360
- let proto = Object.getPrototypeOf(express());
361
- while (proto && !Object.prototype.hasOwnProperty.call(proto, "listen")) {
362
- proto = Object.getPrototypeOf(proto);
363
- }
364
- if (!proto) {
446
+ const owners = listenOwners(entry);
447
+ if (owners.length === 0) {
365
448
  console.error("This build of fulmine has no listen() to stand in for, which should not happen.");
366
449
  return null;
367
450
  }
368
451
 
369
452
  const listened = [];
370
- const realListen = proto.listen;
371
- proto.listen = function stubbedListen() {
372
- this._compileOptimizedRoutes();
373
- listened.push(this);
374
- return this;
375
- };
453
+ const real = owners.map((proto) => proto.listen);
454
+ for (const proto of owners) {
455
+ proto.listen = function stubbedListen() {
456
+ this._compileOptimizedRoutes();
457
+ listened.push(this);
458
+ return this;
459
+ };
460
+ }
461
+ const restore = () => owners.forEach((proto, i) => (proto.listen = real[i]));
376
462
 
377
463
  try {
378
464
  require(entry);
379
465
  } catch (e) {
380
466
  const error = /** @type {any} */ (e);
381
- proto.listen = realListen;
467
+ restore();
382
468
  console.error(`${path.relative(process.cwd(), entry)} could not be loaded:
383
469
  ${error.stack ?? error}`);
384
470
  return null;
385
471
  }
386
- proto.listen = realListen;
472
+ restore();
387
473
 
388
474
  let apps = listened;
389
475
  if (apps.length === 0) {
@@ -400,7 +486,10 @@ ${error.stack ?? error}`);
400
486
  if (apps.length === 0) {
401
487
  console.error(
402
488
  `${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor
403
- ` + "exported one. Point this at the file that does."
489
+ ` +
490
+ `exported one. Point this at the file that does. A listen() that runs after an await is
491
+ ` +
492
+ "not seen either, since this loads the file rather than waiting on what it started."
404
493
  );
405
494
  return null;
406
495
  }
@@ -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 = ["set", "header", "setHeader", "sendStatus", "status", "send", "json", "end", "append"];
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
- if (call.arguments[0].type !== "Literal" || call.arguments[1].type !== "Literal") {
412
- return false;
413
- }
414
- // String() at capture: a numeric literal would reach uWS's writeHeader as itself,
415
- // and uWS refuses anything that is not a string
416
- let [header, value] = [call.arguments[0].value, String(call.arguments[1].value)];
417
- const name = String(header).toLowerCase();
418
- // res.set charsets a content-type and res.setHeader does not, since the second is
419
- // node's and node does not know what a media type is
420
- if (call.obj.propertyName !== "setHeader" && name === "content-type") {
421
- value = withDefaultCharset(value);
422
- }
423
- const index = headers.findIndex((entry) => String(entry[0]).toLowerCase() === name);
424
- if (index === -1) {
425
- headers.push([header, value]);
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
- // in place, so the header keeps the position it was first given
428
- headers[index][1] = value;
429
- // set replaces the header outright, so any further value append left there
430
- // goes with it. Replacing only the first left the response carrying both.
431
- for (let i = headers.length - 1; i > index; i--) {
432
- if (String(headers[i][0]).toLowerCase() === name) {
433
- headers.splice(i, 1);
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, and it is not an oversight. uWS writes a DeclarativeResponse
706
- // with Transfer-Encoding: chunked and adds that header itself, so setting Content-Length
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));
@@ -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 = fs.statSync(filePath + variant.suffix);
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(filePath, req.headers["accept-encoding"], twinTtl);
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 = fs.statSync(statTarget);
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 = twin ?? pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
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
- this.headers = res._nodeRes
293
- ? {}
294
- : {
295
- connection: "keep-alive",
296
- "keep-alive": "timeout=10"
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 = fs.statSync(fullpath);
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/types.d.ts CHANGED
@@ -77,6 +77,18 @@ declare module "fulmine.js" {
77
77
  function expectDeclarative(app: Fulmine, patterns: string | string[]): void;
78
78
  }
79
79
 
80
+ // Server-Timing, carrying how the request was routed. Express has no such middleware, so
81
+ // like compression() there is nothing to re-export
82
+ interface ServerTimingOptions {
83
+ /** Whether to report how the request was routed. Default true. */
84
+ routing?: boolean;
85
+ /** Whether to report the time up to the head. Default true. */
86
+ total?: boolean;
87
+ /** What the total is called. Default "total". */
88
+ name?: string;
89
+ }
90
+ export function serverTiming(options?: ServerTimingOptions): e.RequestHandler;
91
+
80
92
  export function compression(options?: CompressionOptions): e.RequestHandler;
81
93
  export namespace compression {
82
94
  /** The default filter: any compressible content type. */
@@ -184,3 +196,15 @@ declare module "fulmine.js" {
184
196
 
185
197
  export = express;
186
198
  }
199
+
200
+ // What express.serverTiming() hangs on the response. Express's own Response extends this global
201
+ // interface, which is how a middleware adds to it. They are written per request rather than on the
202
+ // prototype, so a route only has them where that middleware ran, which the optional marks say.
203
+ declare namespace Express {
204
+ interface Response {
205
+ /** Adds a mark of your own. A mark with only a description is a legal entry. */
206
+ timing?(name: string, duration?: number, description?: string): this;
207
+ /** Times a piece of work under a name. A promise is timed to where it settles. */
208
+ time?<T>(name: string, work: () => T): T;
209
+ }
210
+ }
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: node:22-trixie-slim.`
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, "musl, and there is no musl build: node:22-trixie-slim is the closest swap.")
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]) < 22) {
247
- results.push(result("no", where, `this package needs node 22 or newer: node:22-trixie-slim.`));
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));