fulmine.js 5.12.2 → 5.13.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
@@ -21,6 +21,8 @@ There is a command that does that replacing for you, across a whole project, and
21
21
  npx fulmine.js verify # can this machine and this image even run it
22
22
  npx fulmine.js migrate --dry-run # say what it would change, change nothing
23
23
  npx fulmine.js migrate # do it
24
+ npx fulmine.js override # when a framework requires express in its own code, not in yours
25
+ npx fulmine.js angular # angular.json's server build, which esbuild would otherwise inline
24
26
  npx fulmine.js differences # just the list of what to check by hand
25
27
  npx fulmine.js profile # what listen() decided about each route
26
28
  npx fulmine.js explain /api/items # what happens when a request for that route arrives
@@ -64,6 +66,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
64
66
  - [Response](#response)
65
67
  - [Router](#router)
66
68
  - [Tested middlewares](#tested-middlewares)
69
+ - [Tested frameworks](#tested-frameworks)
67
70
  - [Tested view engines](#tested-view-engines)
68
71
  - [Examples](./examples/README.md)
69
72
  - [Working on Fulmine](./CONTRIBUTING.md)
@@ -78,9 +81,9 @@ Compatibility here is not a claim, it is a test suite. Every test runs against r
78
81
 
79
82
  Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
80
83
 
81
- **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 4.3x: hello-world 1.9x to 2.2x, an API endpoint with params and a query 3.2x to 4.3x, five route shapes served by one process 2.5x to 3.3x, nested routers 2.1x to 3.1x, a urlencoded body 3.4x to 4.1x, a thousand concurrent connections 2.7x to 3.2x. Route tables are where the native router shows: a thousand routes 9.7x to 12.9x, with a parameter in every one of them 10.4x to 14x, a parameterised route in a mounted router 7.1x to 8.3x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.5x to 1.7x after the per-request work of August 2026.
84
+ **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. The spreads below are the last nine CI runs, which landed on three different runner shapes, all on Node 26. Plain routing lands between 1.8x and 4.9x: hello-world 1.8x to 2.9x, an API endpoint with params and a query 3.1x to 4.9x, five route shapes served by one process 2.4x to 4.0x, nested routers 2.0x to 3.4x, a urlencoded body 3.3x to 4.6x, a thousand concurrent connections 2.6x to 3.7x. Route tables are where the native router shows: a thousand routes 9.7x to 17.4x, with a parameter in every one of them 10x to 21.2x, a parameterised route in a mounted router 6.8x to 8.8x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.7x to 2.1x after the per-request work of August 2026.
82
85
 
83
- **Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere around 1.0x to 1.2x, and no amount of work on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
86
+ **Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere between 1.0x and 1.5x, depending on how much of the request is the shared work, and no amount of effort on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
84
87
 
85
88
  Two things worth knowing before comparing numbers with anyone:
86
89
 
@@ -155,8 +158,16 @@ The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express appl
155
158
  one-line change applies, and `@angular/ssr`'s own `AngularNodeAppEngine` and
156
159
  `writeResponseToNodeResponse` work against Fulmine's request and response unchanged. One extra step
157
160
  is needed, and it is Angular's build rather than this library: the server bundle is built with
158
- esbuild, which tries to inline every dependency and cannot load µWS's native binary. Declare the two
159
- as external in `angular.json`:
161
+ esbuild, which tries to inline every dependency and cannot load µWS's native binary. The two names
162
+ have to be declared external in `angular.json`, which is what this writes:
163
+
164
+ ```sh
165
+ npx fulmine.js angular # every server build in angular.json
166
+ npx fulmine.js angular --dry-run # say what it would write, write nothing
167
+ ```
168
+
169
+ It adds this to each build target that produces a server bundle, and leaves the browser-only ones
170
+ alone:
160
171
 
161
172
  ```json
162
173
  "architect": { "build": { "options": {
@@ -179,40 +190,51 @@ with no cache at all on the serving side.
179
190
  ### NestJS
180
191
 
181
192
  `@nestjs/platform-express` takes an Express instance, so it takes this one, and everything in a Nest
182
- application keeps working. One line stands between that and the speed: the adapter wraps whatever
183
- instance it is given in `http.createServer()` and listens on that, which is the shim, so every
184
- request goes through `node:http` and the application runs at Express's pace. The app here already
185
- answers as an `http.Server`, so it can be the server rather than being wrapped in one:
193
+ application keeps working. The adapter is in the package, so there is nothing to write:
186
194
 
187
195
  ```ts
188
196
  import { NestFactory } from "@nestjs/core";
189
- import { ExpressAdapter } from "@nestjs/platform-express";
190
- import fulmine from "fulmine.js";
197
+ import { FulmineExpressAdapter } from "fulmine.js/nest";
191
198
 
192
- class FulmineAdapter extends ExpressAdapter {
193
- initHttpServer() {
194
- // instead of http.createServer(instance): listen() and close() are then µWS's
195
- (this as any).httpServer = this.getInstance();
196
- }
197
- }
198
-
199
- const app = await NestFactory.create(AppModule, new FulmineAdapter(fulmine()));
199
+ const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
200
200
  await app.listen(3000);
201
201
  ```
202
202
 
203
+ Pass your own application where it needs options, TLS being the usual reason:
204
+ `new FulmineExpressAdapter(fulmine({ uwsOptions }))`. `@nestjs/platform-express` is an optional peer
205
+ dependency, so nothing is installed for anyone who never imports this.
206
+
207
+ What it changes is one line and two edges. The line: Nest's own adapter wraps whatever instance it
208
+ is given in `http.createServer()` and listens on that, which is the shim, so every request goes
209
+ through `node:http` and the application runs at Express's pace. The app here already answers as an
210
+ `http.Server`, so it is the server rather than being put inside one. The edges: `forceCloseConnections`
211
+ has nothing to destroy, since the sockets belong to µWS and nothing emits `connection`, so it now
212
+ says so instead of quietly doing nothing; and Nest decides whether it has already added its body
213
+ parsers by scanning `app.router.stack`, which is not there, so the adapter remembers instead of
214
+ letting a second call add a second pair. `httpsOptions` is refused rather than silently starting a
215
+ plaintext server: TLS is configured on the app, through `uwsOptions`.
216
+
203
217
  Measured on the same Nest application, controllers, pipes and body parsing unchanged: **1.2x on a
204
218
  route answering text and 1.9x on one answering JSON with a route parameter**. `app.close()` closes
205
219
  the port, as it does on the shim.
206
220
 
207
- Two things to know. `forceCloseConnections` has nothing to destroy, since the sockets belong to µWS
208
- and nothing emits `connection`, and Nest looks at `app.router.stack` to decide whether it has
209
- already added its body parsers, which is not there, so it adds them once more than it would.
221
+ A Nest application answering the same bytes on both is [a case in the integration
222
+ suite](./integrations/cases/nest.js), so this is tested rather than claimed.
210
223
 
211
224
  ### When Express is somebody else's dependency
212
225
 
213
226
  A framework built on Express does not `require("express")` in your code, it requires it in its own,
214
227
  so there is nothing for `migrate` to rewrite. Every package manager can answer `express` with this
215
- package instead, for your project and everything under it:
228
+ package instead, for your project and everything under it, and this writes the block for whichever
229
+ one your project uses:
230
+
231
+ ```sh
232
+ npx fulmine.js override # read the lockfile, write the block, say what to run next
233
+ npx fulmine.js override --dry-run # say what it would write, write nothing
234
+ ```
235
+
236
+ It refuses rather than overwrites where a substitution is already there and is not this package. By
237
+ hand it is one of these:
216
238
 
217
239
  ```jsonc
218
240
  // npm and its lockfile, in package.json
@@ -285,7 +307,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
285
307
  - `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).
286
308
  - `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.
287
309
  - 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.
288
- - **A request whose `Content-Length` cannot be trusted is refused by hanging up, with no answer at all.** Node's parser refuses two of these with a `400` and Fulmine refuses the same two: a repeated `Content-Length`, whatever the values say, and one that is not a plain count of bytes, an empty value included. µWS accepts both and frames the request on the first value, or on no body at all, so what the client sent as a body is read as the next request on the connection: that is request smuggling, and a proxy in front reading the other value is all it takes. The answer differs from Express because it cannot be helped. µWS only skips the request it has already queued when the response is closed rather than completed, and writing the `400` completes it, so the choice is between telling the client and stopping the smuggled request. Nothing well behaved sends two content-lengths.
310
+ - **A request whose framing cannot be trusted is refused by hanging up, with no answer at all.** Node's parser refuses each of these with a `400` and Fulmine refuses the same ones: a repeated `Content-Length`; one that is not a plain count of bytes, an empty value or a count past `Number.MAX_SAFE_INTEGER` included; a `Transfer-Encoding` whose last coding is not `chunked`; and a method nobody defines, which includes a lowercase one, since methods are case sensitive. µWS accepts all of them. It frames the request on the first length, or on no body at all, and it takes any token as a method, so `{"a":1}GET /path HTTP/1.1` is a request line to it. What the client sent as a body is then read as the next request on the connection: that is request smuggling, and a proxy in front disagreeing about the framing is all it takes. The answer differs from Express because it cannot be helped. µWS only skips the request it has already queued when the response is closed rather than completed, and writing the `400` completes it, so the choice is between telling the client and stopping the smuggled request. Nothing well behaved sends any of these.
289
311
  - **A compiled route answers `connection: keep-alive` to a client that sent `Connection: close`.** A handler simple enough to be read at registration time is answered by µWS from a response written once at `listen()`, and that response cannot read the request. The socket still closes, so what is wrong is the header and not the transport. A response that would carry a validator is never compiled, so conditional requests behave as on Express; `app.set("declarative responses", false)` turns compiling off.
290
312
  - **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
313
  - For HTTPS, instead of doing this:
@@ -393,10 +415,17 @@ routes get:
393
415
 
394
416
  Optimized routes can be up to 10 times faster than normal routes, as they're using native uWS router and have pre-calculated path.
395
417
 
396
- 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:
418
+ 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.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.
397
419
 
398
- - 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.
399
- - 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.
420
+ Three things are refused whatever the handler does, and all three are the same fact: a response written at startup cannot read the request.
421
+
422
+ - one that would carry an `ETag` or a `Last-Modified`, since it could never answer with the `304 Not Modified` that the validator invites. `etag` is on by default, so `app.set("etag", false)` is what puts an ordinary route on this path.
423
+ - one whose route captures, `/users/:id`, since a value that cannot be decoded is a `400` in Express and nothing runs here to raise it.
424
+ - a `204`, `205` or `304`, since the body would go out with the status and a client frames those as bodiless whatever it reads.
425
+
426
+ Two things then follow from the response being static:
427
+
428
+ - 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.query.q)`, 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.
400
429
  - 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.
401
430
 
402
431
  `app.set("declarative responses", false)` turns the whole thing off if you would rather have Express's exact framing than the speed.
@@ -483,7 +512,7 @@ Runnable: [`examples/compression.js`](./examples/compression.js).
483
512
 
484
513
  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.
485
514
 
486
- 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.
515
+ 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, and note that it is the same setting that decides whether a simple route is compiled into a native response, above.
487
516
 
488
517
  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.
489
518
 
@@ -530,7 +559,7 @@ app.ws("/room/:id", {
530
559
  - **The behavior object is µWS's**, settings included: `maxPayloadLength`, `idleTimeout`, `compression`, `maxBackpressure`, `sendPingsAutomatically` and the rest are passed through untouched, as are the `open`, `message`, `drain`, `close`, `ping`, `pong`, `dropped` and `subscription` handlers. The socket is µWS's too, so `send`, `subscribe`, `publish`, `cork` and `getBufferedAmount` behave exactly as its documentation describes.
531
560
  - **`upgrade(req, res)` is this project's addition.** It runs before the handshake with the same `Request` and `Response` your routes get, so a session, a token or a header decides whether the socket opens. Answering the response, with `res.sendStatus(401)` or any other write, declines the upgrade. Returning a promise holds the handshake until it settles, which is what an authentication lookup needs.
532
561
  - **`ws.req` is that request**, and it outlives the response: the client's address, headers, query and params are readable from any handler for as long as the socket is open. Hanging your own values on it in `upgrade` is how per-connection state gets to `message`.
533
- - **Routers work.** `router.ws("/lobby", …)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
562
+ - **Routers work.** `router.ws("/lobby", ...)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
534
563
  - **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.
535
564
  - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
536
565
 
@@ -706,8 +735,10 @@ Two of these keep a compiled form alongside the value, which you can also set di
706
735
  - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
707
736
  - `query parser fn`, likewise for `query parser`.
708
737
 
709
- Fulmine adds six of its own:
738
+ Fulmine adds eight of its own:
710
739
 
740
+ - `body methods`, unset by default. The body is read for POST, PUT, PATCH and QUERY, and this names the methods to read one for as well: `app.set("body methods", ["DELETE"])`. Reading a body no handler asks for costs about 15%, which is why the built-in list is short rather than every method.
741
+ - `native routes`, on by default. Off, every request walks the ordinary chain instead of letting µWS match what it can, which is slower and answers the same. It is a diagnostic rather than a tuning knob: it exists so one application can be served both ways and the two sets of answers compared, which is how the optimizer is tested. A compiled response needs a native registration to hang on, so turning this off turns `declarative responses` off with it.
711
742
  - `etag methods`, unset by default. Express computes the generated ETag for every method, and so does this until told otherwise. `app.set("etag methods", ["GET", "HEAD"])` skips the digest on every other method, where freshness is not defined and the validator can never match: worth 21% here on a 4KB POST answer. An ETag set by hand still goes out whatever the method.
712
743
  - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
713
744
  - `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.
@@ -848,6 +879,29 @@ Almost all middlewares that are compatible with Express are compatible with Fulm
848
879
  `express` itself, so testing it here needs a dependency override rather than the one-line swap
849
880
  everything else takes.
850
881
 
882
+ ## Tested frameworks
883
+
884
+ The list above is middlewares. A framework built on Express is a much larger user of the Express
885
+ surface than any application is, so those have a suite of their own, in
886
+ [`integrations/`](./integrations): the same application served twice, once on Express and once here,
887
+ with the two outputs compared byte for byte. The four that render pages are built first, by that
888
+ suite, so what is compared is what their own build produces.
889
+
890
+ - ✅ [NestJS](https://nestjs.com) through [`fulmine.js/nest`](#nestjs)
891
+ - ✅ [Next.js](https://nextjs.org) as a custom server, `next().getRequestHandler()`
892
+ - ✅ [Astro](https://astro.build) through `@astrojs/node` in middleware mode
893
+ - ✅ [SvelteKit](https://svelte.dev/docs/kit) through `@sveltejs/adapter-node`
894
+ - ✅ [React Router v7](https://reactrouter.com) through `@react-router/express`
895
+ - ✅ [Apollo Server](https://www.apollographql.com/docs/apollo-server) through
896
+ [`@as-integrations/express5`](https://www.npmjs.com/package/@as-integrations/express5)
897
+ - ✅ [tRPC](https://trpc.io) through `@trpc/server/adapters/express`
898
+ - ✅ [Angular SSR](#angular-ssr), which is an ordinary Express `server.ts` plus one line of build
899
+ configuration
900
+
901
+ Each of these mounts on an ordinary Express application, so there is nothing to install and nothing
902
+ to configure beyond what that framework already asks for. Nest is the exception, and only because
903
+ its adapter decides what to listen on: that one is [`fulmine.js/nest`](#nestjs).
904
+
851
905
  ## Tested view engines
852
906
 
853
907
  Any Express view engine should work. Here's list of engines we include in our test suite:
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.12.2",
3
+ "version": "5.13.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/types.d.ts",
9
+ "default": "./src/index.js"
10
+ },
11
+ "./nest": {
12
+ "types": "./src/nest.d.ts",
13
+ "default": "./src/nest.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
6
18
  "bin": {
7
19
  "fulmine": "src/cli.js"
8
20
  },
@@ -28,7 +40,12 @@
28
40
  "release:local": "node tools/release-local.js",
29
41
  "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",
30
42
  "cover:check": "nyc check-coverage --statements 94.5 --branches 90 --functions 94 --lines 94.5",
31
- "examples:install": "npm --prefix examples install"
43
+ "examples:install": "npm --prefix examples install",
44
+ "test:integrations": "npm --prefix integrations test",
45
+ "integrations:install": "npm --prefix integrations install",
46
+ "fuzz:wire": "node tools/wire-fuzz.js",
47
+ "fuzz:headers": "node tools/header-fuzz.js",
48
+ "fuzz:session": "node tools/session-fuzz.js"
32
49
  },
33
50
  "engines": {
34
51
  "node": ">=22"
@@ -72,7 +89,7 @@
72
89
  "bytes": "^3.1.2",
73
90
  "compressible": "^2.0.18",
74
91
  "content-disposition": "^1.1.0",
75
- "cookie": "^1.1.1",
92
+ "cookie": "^0.7.2",
76
93
  "cookie-signature": "^1.2.2",
77
94
  "encodeurl": "^2.0.0",
78
95
  "fast-decode-uri-component": "^1.0.1",
@@ -90,10 +107,21 @@
90
107
  "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0",
91
108
  "vary": "^1.1.2"
92
109
  },
110
+ "peerDependencies": {
111
+ "@nestjs/platform-express": ">=10"
112
+ },
113
+ "peerDependenciesMeta": {
114
+ "@nestjs/platform-express": {
115
+ "optional": true
116
+ }
117
+ },
93
118
  "devDependencies": {
94
119
  "@commitlint/cli": "^21.2.1",
95
120
  "@commitlint/config-conventional": "^21.2.0",
96
121
  "@eslint/js": "^10.0.1",
122
+ "@nestjs/common": "^11.2.1",
123
+ "@nestjs/core": "^11.2.1",
124
+ "@nestjs/platform-express": "^11.2.1",
97
125
  "@release-it/conventional-changelog": "^12.0.0",
98
126
  "@types/accepts": "^1.3.7",
99
127
  "@types/bytes": "^3.1.5",
@@ -157,8 +185,10 @@
157
185
  "pkg-pr-new": "^0.0.87",
158
186
  "prettier": "^3.9.6",
159
187
  "pug": "^3.0.4",
188
+ "reflect-metadata": "^0.2.2",
160
189
  "release-it": "^21.0.1",
161
190
  "response-time": "^2.3.4",
191
+ "rxjs": "^7.8.2",
162
192
  "serve-favicon": "^2.5.1",
163
193
  "serve-index": "^1.9.2",
164
194
  "serve-static": "^2.2.1",
package/src/adopt.js ADDED
@@ -0,0 +1,324 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // The two commands that edit a config file rather than source: `npx fulmine.js override` and
18
+ // `npx fulmine.js angular`.
19
+ //
20
+ // `migrate` rewrites `require("express")` in your own files, which is all an application needs. The
21
+ // two cases it cannot reach are both a line in a JSON file that nobody remembers the shape of:
22
+ //
23
+ // override A framework built on Express does not require it in your code, it requires it in its
24
+ // own, so there is no specifier to rewrite. Every package manager can answer `express`
25
+ // with this package instead, for the whole tree, and each one spells it differently.
26
+ // angular An Angular server bundle is built with esbuild, which inlines every dependency and
27
+ // cannot load µWS's native binary. Two names in `externalDependencies` fix it, and
28
+ // nothing in the error message it fails with says so.
29
+
30
+ "use strict";
31
+
32
+ const fs = require("fs");
33
+ const path = require("path");
34
+
35
+ const SELF = "fulmine.js";
36
+ const REPLACES = "express";
37
+
38
+ /** The major this package tracks, which is the range an override should ask for. */
39
+ const MAJOR = require("../package.json").version.split(".")[0];
40
+
41
+ /** Where each manager keeps its substitutions, and what to call it when telling someone. */
42
+ const MANAGERS = {
43
+ npm: { keys: ["overrides"], reinstall: "npm install" },
44
+ pnpm: { keys: ["pnpm", "overrides"], reinstall: "pnpm install" },
45
+ yarn: { keys: ["resolutions"], reinstall: "yarn install" }
46
+ };
47
+
48
+ /** A lockfile, and the manager that wrote it. bun is here to be refused, see detectManager. */
49
+ const LOCKFILES = [
50
+ ["pnpm-lock.yaml", "pnpm"],
51
+ ["yarn.lock", "yarn"],
52
+ ["package-lock.json", "npm"],
53
+ ["npm-shrinkwrap.json", "npm"],
54
+ ["bun.lock", "bun"],
55
+ ["bun.lockb", "bun"]
56
+ ];
57
+
58
+ /**
59
+ * The indentation a file already uses, so rewriting it does not reformat every line.
60
+ *
61
+ * @param {string} source
62
+ * @returns {string|number} what JSON.stringify takes as its third argument
63
+ */
64
+ function indentOf(source) {
65
+ const first = source.match(/\n([ \t]+)"/);
66
+ if (!first) {
67
+ return 4;
68
+ }
69
+ return first[1].startsWith("\t") ? "\t" : first[1].length;
70
+ }
71
+
72
+ /**
73
+ * Reads a JSON file, or explains why it could not be read rather than throwing a parser error.
74
+ *
75
+ * The read is attempted rather than guarded by an existence check. Both commands go on to write the
76
+ * file they read, and a check on a path followed by a write to the same path is the shape of a race
77
+ * whatever the odds of losing it. `code` is what a caller names the missing file by.
78
+ *
79
+ * @param {string} file
80
+ * @returns {{data: any, source: string}|{error: string, code: string|undefined}}
81
+ */
82
+ function readJson(file) {
83
+ let source;
84
+ try {
85
+ source = fs.readFileSync(file, "utf8");
86
+ } catch (e) {
87
+ const err = /** @type {NodeJS.ErrnoException} */ (e);
88
+ return { error: `${file} could not be read: ${err.message}`, code: err.code };
89
+ }
90
+ try {
91
+ return { data: JSON.parse(source), source };
92
+ } catch (e) {
93
+ const err = /** @type {Error} */ (e);
94
+ return { error: `${file} is not valid JSON and was left alone: ${err.message}`, code: undefined };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Which package manager this project uses.
100
+ *
101
+ * The `packageManager` field is asked first: a project that declares one is using it whatever
102
+ * lockfiles are lying around. A project with neither gets npm, since that is what `npx` came with.
103
+ *
104
+ * @param {string} dir
105
+ * @param {any} pkg the parsed package.json
106
+ * @returns {{manager: string, why: string}}
107
+ */
108
+ function detectManager(dir, pkg) {
109
+ const declared = typeof pkg.packageManager === "string" ? pkg.packageManager.split("@")[0] : undefined;
110
+ if (declared && (MANAGERS[declared] || declared === "bun")) {
111
+ return { manager: declared, why: `the packageManager field says ${declared}` };
112
+ }
113
+ for (const [file, manager] of LOCKFILES) {
114
+ if (fs.existsSync(path.join(dir, file))) {
115
+ return { manager, why: `${file} is here` };
116
+ }
117
+ }
118
+ return { manager: "npm", why: "no lockfile and no packageManager field, so npm" };
119
+ }
120
+
121
+ /**
122
+ * Reads a nested key, and answers undefined rather than throwing on a missing level.
123
+ *
124
+ * @param {any} object
125
+ * @param {string[]} keys
126
+ * @returns {any}
127
+ */
128
+ function readPath(object, keys) {
129
+ let at = object;
130
+ for (const key of keys) {
131
+ if (at === null || typeof at !== "object") return undefined;
132
+ at = at[key];
133
+ }
134
+ return at;
135
+ }
136
+
137
+ /**
138
+ * Writes a nested key, making the levels above it as it goes.
139
+ *
140
+ * @param {any} object
141
+ * @param {string[]} keys
142
+ * @param {any} value
143
+ * @returns {void}
144
+ */
145
+ function writePath(object, keys, value) {
146
+ let at = object;
147
+ for (const key of keys.slice(0, -1)) {
148
+ if (at[key] === null || typeof at[key] !== "object") at[key] = {};
149
+ at = at[key];
150
+ }
151
+ at[keys[keys.length - 1]] = value;
152
+ }
153
+
154
+ /**
155
+ * npx fulmine.js override [dir] [--dry-run]
156
+ *
157
+ * Puts the substitution in package.json where this project's package manager reads it, and says
158
+ * what to run next. It deliberately does not run the install: the reinstall throws away
159
+ * node_modules, and that is not something a command should do to somebody's working tree without
160
+ * being watched.
161
+ *
162
+ * @param {string[]} argv everything after the command name
163
+ * @returns {number} exit code
164
+ */
165
+ function override(argv) {
166
+ const dryRun = argv.includes("--dry-run");
167
+ const dir = path.resolve(argv.find((arg) => !arg.startsWith("--")) ?? ".");
168
+ const file = path.join(dir, "package.json");
169
+
170
+ const read = readJson(file);
171
+ if ("error" in read) {
172
+ console.error(read.code === "ENOENT" ? `no package.json in ${dir}` : read.error);
173
+ return 1;
174
+ }
175
+ const { data: pkg, source } = read;
176
+
177
+ const { manager, why } = detectManager(dir, pkg);
178
+ if (manager === "bun") {
179
+ console.error(
180
+ `this project uses bun (${why}), and bun cannot run this package at all: µWebSockets.js\n` +
181
+ "is a native node addon and bun does not load it. Nothing was changed."
182
+ );
183
+ return 1;
184
+ }
185
+
186
+ const { keys, reinstall } = MANAGERS[manager];
187
+ const where = keys.join(".");
188
+ const wanted = `npm:${SELF}@^${MAJOR}`;
189
+
190
+ const existing = readPath(pkg, [...keys, REPLACES]);
191
+ if (existing === wanted) {
192
+ console.log(`${where}.${REPLACES} already says ${wanted}, so there is nothing to change.`);
193
+ console.log(`Check it took: ${manager === "npm" ? "npm ls express" : `${manager} why express`}`);
194
+ return 0;
195
+ }
196
+ if (existing !== undefined) {
197
+ console.error(
198
+ `${where}.${REPLACES} already says ${JSON.stringify(existing)}, which is not this package.\n` +
199
+ "Nothing was changed: overwriting somebody else's substitution is not this command's call."
200
+ );
201
+ return 1;
202
+ }
203
+
204
+ writePath(pkg, [...keys, REPLACES], wanted);
205
+ const rewritten = JSON.stringify(pkg, null, indentOf(source)) + "\n";
206
+
207
+ // the block on its own, so what was added can be read without diffing a whole package.json
208
+ const shown = {};
209
+ writePath(shown, keys, { [REPLACES]: wanted });
210
+ console.log(`${manager}, because ${why}`);
211
+ console.log(`${dryRun ? "would add" : "added"} to package.json:\n`);
212
+ console.log(
213
+ JSON.stringify(shown, null, 2)
214
+ .split("\n")
215
+ .map((line) => ` ${line}`)
216
+ .join("\n") + "\n"
217
+ );
218
+ if (!dryRun) {
219
+ fs.writeFileSync(file, rewritten);
220
+ }
221
+
222
+ console.log("Then reinstall, so the lockfile is written again:\n");
223
+ console.log(` rm -rf node_modules && ${reinstall}\n`);
224
+ const check = manager === "npm" ? "npm ls express" : `${manager} why express`;
225
+ console.log(`It took when \`${check}\` names ${SELF}.\n`);
226
+ console.log("Two things to know before you trust it:\n");
227
+ console.log(` The substitution reaches every dependency that asks for ${REPLACES}, including ones you have`);
228
+ console.log(" never looked at. Run your own tests afterwards, and read `npx fulmine.js differences`:");
229
+ console.log(" what a framework does with Express is usually more than what an application does.\n");
230
+ console.log(` A package that reaches into ${REPLACES}/lib/... rather than its public surface will not find`);
231
+ console.log(" what it expects, since the files there are ours.\n");
232
+ return 0;
233
+ }
234
+
235
+ /**
236
+ * Every build target in an angular.json that produces a server bundle.
237
+ *
238
+ * A browser-only build has nothing to declare external: the bundle it makes never loads µWS. What
239
+ * marks a server build is `ssr`, `server` or `outputMode` in its options, which is what `ng add
240
+ * @angular/ssr` writes.
241
+ *
242
+ * @param {any} config the parsed angular.json
243
+ * @returns {{name: string, options: any}[]}
244
+ */
245
+ function serverBuilds(config) {
246
+ const found = [];
247
+ for (const [name, project] of Object.entries(config.projects ?? {})) {
248
+ const targets = readPath(project, ["architect"]) ?? readPath(project, ["targets"]);
249
+ const options = readPath(targets, ["build", "options"]);
250
+ if (!options || typeof options !== "object") continue;
251
+ if (options.ssr === undefined && options.server === undefined && options.outputMode === undefined) continue;
252
+ found.push({ name, options });
253
+ }
254
+ return found;
255
+ }
256
+
257
+ /**
258
+ * npx fulmine.js angular [dir] [--dry-run]
259
+ *
260
+ * Declares this package and µWebSockets.js external in every server build, which is what stops
261
+ * esbuild trying to inline a native binary it cannot read.
262
+ *
263
+ * @param {string[]} argv everything after the command name
264
+ * @returns {number} exit code
265
+ */
266
+ function angular(argv) {
267
+ const dryRun = argv.includes("--dry-run");
268
+ const given = path.resolve(argv.find((arg) => !arg.startsWith("--")) ?? ".");
269
+
270
+ // The argument is either the file or the directory holding it, and which one comes out of
271
+ // reading it rather than out of a stat: a directory reads as EISDIR and a missing path as
272
+ // ENOENT, and in both cases the file to look for is angular.json inside it. Asked this way
273
+ // round because this function goes on to write the file it read, and a check on a path followed
274
+ // by a write to the same path is the shape of a race whatever the odds of losing it.
275
+ let file = given;
276
+ let read = readJson(file);
277
+ if ("error" in read && (read.code === "EISDIR" || read.code === "ENOENT")) {
278
+ file = path.join(given, "angular.json");
279
+ read = readJson(file);
280
+ }
281
+ if ("error" in read) {
282
+ console.error(read.code === "ENOENT" ? `no angular.json at ${file}` : read.error);
283
+ return 1;
284
+ }
285
+ const { data: config, source } = read;
286
+
287
+ const builds = serverBuilds(config);
288
+ if (!builds.length) {
289
+ console.error(
290
+ "no server build in angular.json: every build target here produces a browser bundle, and a\n" +
291
+ "browser bundle never loads µWebSockets.js. Nothing to declare external, and nothing changed.\n" +
292
+ "Run `ng add @angular/ssr` first if this application is meant to render on the server."
293
+ );
294
+ return 1;
295
+ }
296
+
297
+ let changed = 0;
298
+ for (const { name, options } of builds) {
299
+ const external = Array.isArray(options.externalDependencies) ? options.externalDependencies : [];
300
+ const missing = [SELF, "uWebSockets.js"].filter((one) => !external.includes(one));
301
+ if (!missing.length) {
302
+ console.log(`${name}: already external, nothing to change`);
303
+ continue;
304
+ }
305
+ options.externalDependencies = [...external, ...missing];
306
+ changed++;
307
+ console.log(`${name}: ${dryRun ? "would declare" : "declared"} ${missing.join(" and ")} external`);
308
+ }
309
+
310
+ if (!changed) {
311
+ return 0;
312
+ }
313
+ if (!dryRun) {
314
+ fs.writeFileSync(file, JSON.stringify(config, null, indentOf(source)) + "\n");
315
+ }
316
+
317
+ console.log(`\n${dryRun ? "would rewrite" : "rewrote"} ${path.relative(process.cwd(), file) || file}\n`);
318
+ console.log("The server.ts that `ng add @angular/ssr` generates is an ordinary Express application, so");
319
+ console.log("`npx fulmine.js migrate` is what changes the import in it. @angular/ssr's own");
320
+ console.log("AngularNodeAppEngine and writeResponseToNodeResponse work against this unchanged.\n");
321
+ return 0;
322
+ }
323
+
324
+ module.exports = { override, angular, detectManager, serverBuilds, indentOf };
@@ -529,8 +529,8 @@ class Application extends Router {
529
529
  _serveGeneric(res, req) {
530
530
  const request = this.handleRequest(res, req);
531
531
  const response = request.res;
532
- if (request._badFraming === true) {
533
- return this._refuseFraming(response);
532
+ if (request._mustRefuse === true) {
533
+ return this._refuseRequest(response);
534
534
  }
535
535
  try {
536
536
  this._routeRequestDirect(request, response);