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 +83 -29
- package/package.json +33 -3
- package/src/adopt.js +324 -0
- package/src/application.js +2 -2
- package/src/cli.js +25 -6
- package/src/declarative.js +17 -9
- package/src/middlewares.js +11 -4
- package/src/nest.d.ts +39 -0
- package/src/nest.js +119 -0
- package/src/request.js +103 -22
- package/src/response.js +24 -3
- package/src/router.js +72 -13
- package/src/testing.js +27 -3
- package/src/utils.js +22 -2
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.
|
|
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
|
|
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.
|
|
159
|
-
|
|
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.
|
|
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 {
|
|
190
|
-
import fulmine from "fulmine.js";
|
|
197
|
+
import { FulmineExpressAdapter } from "fulmine.js/nest";
|
|
191
198
|
|
|
192
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
399
|
-
|
|
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",
|
|
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
|
|
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.
|
|
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": "^
|
|
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 };
|
package/src/application.js
CHANGED
|
@@ -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.
|
|
533
|
-
return this.
|
|
532
|
+
if (request._mustRefuse === true) {
|
|
533
|
+
return this._refuseRequest(response);
|
|
534
534
|
}
|
|
535
535
|
try {
|
|
536
536
|
this._routeRequestDirect(request, response);
|