fulmine.js 5.12.3 → 5.13.1
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 +82 -28
- package/package.json +28 -1
- package/src/adopt.js +324 -0
- package/src/cli.js +25 -6
- package/src/declarative.js +8 -16
- package/src/middlewares.js +11 -4
- package/src/nest.d.ts +39 -0
- package/src/nest.js +119 -0
- package/src/request.js +9 -9
- package/src/response.js +24 -3
- package/src/router.js +21 -1
- package/src/testing.js +27 -3
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
|
|
@@ -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.1",
|
|
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
|
},
|
|
@@ -29,6 +41,8 @@
|
|
|
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
43
|
"examples:install": "npm --prefix examples install",
|
|
44
|
+
"test:integrations": "npm --prefix integrations test",
|
|
45
|
+
"integrations:install": "npm --prefix integrations install",
|
|
32
46
|
"fuzz:wire": "node tools/wire-fuzz.js",
|
|
33
47
|
"fuzz:headers": "node tools/header-fuzz.js",
|
|
34
48
|
"fuzz:session": "node tools/session-fuzz.js"
|
|
@@ -93,10 +107,21 @@
|
|
|
93
107
|
"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0",
|
|
94
108
|
"vary": "^1.1.2"
|
|
95
109
|
},
|
|
110
|
+
"peerDependencies": {
|
|
111
|
+
"@nestjs/platform-express": ">=10"
|
|
112
|
+
},
|
|
113
|
+
"peerDependenciesMeta": {
|
|
114
|
+
"@nestjs/platform-express": {
|
|
115
|
+
"optional": true
|
|
116
|
+
}
|
|
117
|
+
},
|
|
96
118
|
"devDependencies": {
|
|
97
119
|
"@commitlint/cli": "^21.2.1",
|
|
98
120
|
"@commitlint/config-conventional": "^21.2.0",
|
|
99
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",
|
|
100
125
|
"@release-it/conventional-changelog": "^12.0.0",
|
|
101
126
|
"@types/accepts": "^1.3.7",
|
|
102
127
|
"@types/bytes": "^3.1.5",
|
|
@@ -160,8 +185,10 @@
|
|
|
160
185
|
"pkg-pr-new": "^0.0.87",
|
|
161
186
|
"prettier": "^3.9.6",
|
|
162
187
|
"pug": "^3.0.4",
|
|
188
|
+
"reflect-metadata": "^0.2.2",
|
|
163
189
|
"release-it": "^21.0.1",
|
|
164
190
|
"response-time": "^2.3.4",
|
|
191
|
+
"rxjs": "^7.8.2",
|
|
165
192
|
"serve-favicon": "^2.5.1",
|
|
166
193
|
"serve-index": "^1.9.2",
|
|
167
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/cli.js
CHANGED
|
@@ -31,6 +31,13 @@ limitations under the License.
|
|
|
31
31
|
//
|
|
32
32
|
// Whether this machine and this project can run it at all: the node version, the C library, the
|
|
33
33
|
// µWebSockets.js binary, the base image a Dockerfile names. See src/verify.js.
|
|
34
|
+
//
|
|
35
|
+
// npx fulmine override [dir]
|
|
36
|
+
// npx fulmine angular [dir]
|
|
37
|
+
//
|
|
38
|
+
// The two things a project needs that are a line in a JSON file rather than a specifier in a source
|
|
39
|
+
// file: the package manager substitution, for a framework that requires express in its own code,
|
|
40
|
+
// and angular.json's externalDependencies. See src/adopt.js.
|
|
34
41
|
|
|
35
42
|
const fs = require("fs");
|
|
36
43
|
const path = require("path");
|
|
@@ -38,6 +45,7 @@ const acorn = require("acorn");
|
|
|
38
45
|
// the same walk express.testing asserts on, so the command and the assertions cannot drift
|
|
39
46
|
const { collectRoutes } = require("./testing.js");
|
|
40
47
|
const { verify } = require("./verify.js");
|
|
48
|
+
const { override, angular } = require("./adopt.js");
|
|
41
49
|
|
|
42
50
|
const FROM = "express";
|
|
43
51
|
const TO = "fulmine.js";
|
|
@@ -85,11 +93,12 @@ const DIFFERENCES = [
|
|
|
85
93
|
'Express sends X-Powered-By: Express unless told not to. Set app.set("x-powered-by", true) to send it.'
|
|
86
94
|
],
|
|
87
95
|
[
|
|
88
|
-
"a compiled route
|
|
89
|
-
"A handler simple enough to be read at registration time is answered natively
|
|
90
|
-
"
|
|
91
|
-
"
|
|
92
|
-
|
|
96
|
+
"a compiled route keeps its connection header",
|
|
97
|
+
"A handler simple enough to be read at registration time is answered natively, and a client\n" +
|
|
98
|
+
"that sent Connection: close is still told keep-alive, though the socket does close. A body\n" +
|
|
99
|
+
"with a piece of the query in it is framed chunked, since its length is not known until the\n" +
|
|
100
|
+
"request arrives. A response that would carry a validator is never compiled, so conditional\n" +
|
|
101
|
+
'requests behave as on Express. app.set("declarative responses", false) turns it off.'
|
|
93
102
|
],
|
|
94
103
|
[
|
|
95
104
|
"headers are capped at 4096 bytes by default",
|
|
@@ -822,9 +831,19 @@ function main(argv) {
|
|
|
822
831
|
if (command === "explain") {
|
|
823
832
|
return explain(argv.slice(1));
|
|
824
833
|
}
|
|
834
|
+
if (command === "override") {
|
|
835
|
+
return override(argv.slice(1));
|
|
836
|
+
}
|
|
837
|
+
if (command === "angular") {
|
|
838
|
+
return angular(argv.slice(1));
|
|
839
|
+
}
|
|
825
840
|
if (command !== "migrate") {
|
|
826
841
|
console.log(`Usage:
|
|
827
842
|
npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
|
|
843
|
+
npx ${TO} override [dir] answer ${FROM} with this package for the whole dependency tree, for
|
|
844
|
+
when a framework requires ${FROM} in its own code and not in yours
|
|
845
|
+
npx ${TO} angular [dir] declare this package external in angular.json's server build, which
|
|
846
|
+
esbuild otherwise tries to inline a native binary into
|
|
828
847
|
npx ${TO} profile [entry] load an application without listening and print what compiling
|
|
829
848
|
its routes decided, route by route
|
|
830
849
|
npx ${TO} explain <route> what happens when a request for that route arrives
|
|
@@ -832,7 +851,7 @@ function main(argv) {
|
|
|
832
851
|
npx ${TO} differences print what behaves differently, without changing anything
|
|
833
852
|
|
|
834
853
|
Options:
|
|
835
|
-
--dry-run migrate: say what would change and change nothing`);
|
|
854
|
+
--dry-run migrate, override, angular: say what would change and change nothing`);
|
|
836
855
|
return command ? 1 : 0;
|
|
837
856
|
}
|
|
838
857
|
|
package/src/declarative.js
CHANGED
|
@@ -715,13 +715,10 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
715
715
|
return false;
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
-
// A status that carries no content,
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
// a 204 as bodiless whatever the headers say, so those ten bytes were read as the start of
|
|
723
|
-
// the next answer on the connection, which is a desync on any keep-alive client. The
|
|
724
|
-
// ordinary path already writes all three the way express does: this hands them back to it.
|
|
718
|
+
// A status that carries no content. Compiled, the body went out with it, and a client
|
|
719
|
+
// frames these as bodiless whatever the headers say, so those bytes were read as the start
|
|
720
|
+
// of the next answer on the connection. The ordinary path already writes all three the way
|
|
721
|
+
// express does.
|
|
725
722
|
if (BODILESS_STATUSES.has(statusCode) || statusCode < 200) {
|
|
726
723
|
return false;
|
|
727
724
|
}
|
|
@@ -771,15 +768,10 @@ module.exports = function compileDeclarative(cb, app) {
|
|
|
771
768
|
body.push({ type: "text", value: statuses.message[statusCode] || String(statusCode) });
|
|
772
769
|
}
|
|
773
770
|
|
|
774
|
-
// A response that would carry a validator is not compiled at all
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
// then ignore it, so every revalidation got 200 and the whole body where Express answers
|
|
779
|
-
// 304 with none. Dropping the ETag instead would have kept the route compiled, at the
|
|
780
|
-
// price of no validator at all on the simplest routes of every application. Refusing
|
|
781
|
-
// keeps Express's answer, and `etag` false is how a route that does not need one stays
|
|
782
|
-
// compiled, which is what both benchmarks here already set.
|
|
771
|
+
// A response that would carry a validator is not compiled at all: µWS answers it without
|
|
772
|
+
// reading the request, so it could never turn a conditional GET into the 304 the validator
|
|
773
|
+
// invites. Dropping the ETag instead would leave the simplest routes of an application
|
|
774
|
+
// without one, so `etag` false is how a route stays compiled.
|
|
783
775
|
if (headers.some((header) => VALIDATOR_HEADERS.has(header[0].toLowerCase()))) {
|
|
784
776
|
return false;
|
|
785
777
|
}
|
package/src/middlewares.js
CHANGED
|
@@ -810,13 +810,20 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
810
810
|
return next();
|
|
811
811
|
}
|
|
812
812
|
|
|
813
|
+
// The property goes on the request before anything is decided, and its value stays
|
|
814
|
+
// undefined: body-parser's read() does exactly this, and the two halves both matter.
|
|
815
|
+
// Undefined, so a handler can still tell "nothing parsed this" from "the body was
|
|
816
|
+
// empty", which seeding an empty object would lose. Present, because `"body" in req`
|
|
817
|
+
// is how a library asks whether a parser has run at all: Apollo's express middleware
|
|
818
|
+
// refuses the request with a 500 when the property is missing, and tRPC's adapter
|
|
819
|
+
// reads the body itself when it is, so getting either half wrong breaks one of them.
|
|
820
|
+
if (!("body" in req)) {
|
|
821
|
+
req.body = undefined;
|
|
822
|
+
}
|
|
823
|
+
|
|
813
824
|
// straight from the raw entries: three headers do not justify building the object
|
|
814
825
|
const type = req._rawHeader("content-type");
|
|
815
826
|
|
|
816
|
-
// req.body is deliberately left undefined until a parser claims the request. That is
|
|
817
|
-
// what lets a handler tell "nothing parsed this" apart from "the body was empty",
|
|
818
|
-
// so it must not be seeded with an empty object first.
|
|
819
|
-
|
|
820
827
|
// skip reading body for no content type
|
|
821
828
|
// a function decides for itself, and body-parser lets it see a request that carries no
|
|
822
829
|
// content-type at all. Only the string and array forms need one to match against
|
package/src/nest.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
import { ExpressAdapter } from "@nestjs/platform-express";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Nest's Express adapter, listening on uWebSockets.js instead of on node.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* import { NestFactory } from "@nestjs/core";
|
|
24
|
+
* import { FulmineExpressAdapter } from "fulmine.js/nest";
|
|
25
|
+
*
|
|
26
|
+
* const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
|
|
27
|
+
* await app.listen(3000);
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* `@nestjs/platform-express` is an optional peer dependency: this entry point is the only thing
|
|
31
|
+
* that needs it, and nothing loads it unless you import this.
|
|
32
|
+
*/
|
|
33
|
+
export declare class FulmineExpressAdapter extends ExpressAdapter {
|
|
34
|
+
/**
|
|
35
|
+
* @param instance an application from `fulmine()`; one is created when omitted. Pass your own
|
|
36
|
+
* when it needs options, TLS being the usual reason: `fulmine({ uwsOptions })`.
|
|
37
|
+
*/
|
|
38
|
+
constructor(instance?: any);
|
|
39
|
+
}
|
package/src/nest.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
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
|
+
// require("fulmine.js/nest"): the Nest HTTP adapter, so a Nest application runs on µWS without
|
|
18
|
+
// anyone having to write this file themselves.
|
|
19
|
+
//
|
|
20
|
+
// import { NestFactory } from "@nestjs/core";
|
|
21
|
+
// import { FulmineExpressAdapter } from "fulmine.js/nest";
|
|
22
|
+
//
|
|
23
|
+
// const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
|
|
24
|
+
// await app.listen(3000);
|
|
25
|
+
//
|
|
26
|
+
// @nestjs/platform-express takes any Express instance, and this is one, so everything above the
|
|
27
|
+
// adapter - controllers, pipes, guards, interceptors - is untouched. Three things below it are not,
|
|
28
|
+
// and they are the whole reason this file exists:
|
|
29
|
+
//
|
|
30
|
+
// - initHttpServer wraps the instance in http.createServer() and listens on that. Every request
|
|
31
|
+
// would then arrive through node's parser and be replayed into µWS's shapes by node-shim.js,
|
|
32
|
+
// which is the slow path that exists for supertest. The app already answers as an http.Server,
|
|
33
|
+
// so it is the server instead of being put inside one.
|
|
34
|
+
// - registerParserMiddleware decides whether Nest's body parsers are already in the chain by
|
|
35
|
+
// scanning app.router.stack for them. There is no layer array here to scan, routes are compiled
|
|
36
|
+
// rather than kept as layers, so the answer was always "no" and a second call added a second
|
|
37
|
+
// pair. It is remembered here instead, which is the same answer by a different route.
|
|
38
|
+
// - httpsOptions asks node to make a TLS server out of the instance. TLS here belongs to µWS and
|
|
39
|
+
// is configured when the app is built, so that combination is refused with the line to write
|
|
40
|
+
// rather than silently starting a plaintext server.
|
|
41
|
+
//
|
|
42
|
+
// @nestjs/platform-express is an optional peer dependency: this file is the only one that requires
|
|
43
|
+
// it, and nothing loads this file unless you ask for it by name.
|
|
44
|
+
|
|
45
|
+
"use strict";
|
|
46
|
+
|
|
47
|
+
const { ExpressAdapter } = require("@nestjs/platform-express");
|
|
48
|
+
const fulmine = require("./index.js");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Nest's Express adapter, listening on µWebSockets.js instead of on node.
|
|
52
|
+
*
|
|
53
|
+
* Pass a configured app when you need one, `new FulmineExpressAdapter(fulmine({ uwsOptions }))`;
|
|
54
|
+
* with no argument it builds a default one, the same as `new ExpressAdapter()` does.
|
|
55
|
+
*/
|
|
56
|
+
class FulmineExpressAdapter extends ExpressAdapter {
|
|
57
|
+
/**
|
|
58
|
+
* @param {any} [instance] an application from `fulmine()`; one is created when omitted
|
|
59
|
+
*/
|
|
60
|
+
constructor(instance) {
|
|
61
|
+
super(instance || fulmine());
|
|
62
|
+
/**
|
|
63
|
+
* Whether Nest's body parsers are in the chain, standing in for the layer array Express
|
|
64
|
+
* has and this does not. See registerParserMiddleware below.
|
|
65
|
+
* @type {boolean}
|
|
66
|
+
*/
|
|
67
|
+
this._parsersRegistered = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The app is the server. Nest calls this once, from NestApplication's constructor.
|
|
72
|
+
*
|
|
73
|
+
* @param {any} [options] the options NestFactory.create was given
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
initHttpServer(options) {
|
|
77
|
+
if (options?.httpsOptions) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
"fulmine.js: httpsOptions cannot be used here, since there is no node server to give " +
|
|
80
|
+
"them to. TLS belongs to µWS and is configured when the app is built:\n" +
|
|
81
|
+
' new FulmineExpressAdapter(fulmine({ uwsOptions: { key_file_name: "key.pem", cert_file_name: "cert.pem" } }))'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
this.httpServer = this.getInstance();
|
|
85
|
+
if (options?.forceCloseConnections) {
|
|
86
|
+
// trackOpenConnections() listens for 'connection', which nothing emits: the sockets
|
|
87
|
+
// belong to µWS and never become node ones. Said out loud, because a shutdown that
|
|
88
|
+
// quietly waits forever for what it thinks it can destroy is worse than one that does
|
|
89
|
+
// not offer to. Through Nest's own logger, so the line arrives where every other line
|
|
90
|
+
// from the framework does; it is private in the typings and inherited all the same
|
|
91
|
+
/** @type {any} */ (this).logger.warn(
|
|
92
|
+
"forceCloseConnections has no effect on fulmine.js: the sockets belong to µWS. " +
|
|
93
|
+
"app.close() stops accepting and waits for the requests in flight; an idle keep-alive " +
|
|
94
|
+
"connection is closed by µWS through uwsOptions.idleTimeout, not by node."
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Nest's json and urlencoded parsers, added once however often this is called.
|
|
101
|
+
*
|
|
102
|
+
* Express answers "are they there already" by scanning `app.router.stack` for a layer whose
|
|
103
|
+
* handler is named `jsonParser` or `urlencodedParser`. There is no such array here, so the scan
|
|
104
|
+
* answered no every time and a second call put a second pair in front of every request.
|
|
105
|
+
*
|
|
106
|
+
* @param {string} [prefix]
|
|
107
|
+
* @param {boolean} [rawBody]
|
|
108
|
+
* @returns {void}
|
|
109
|
+
*/
|
|
110
|
+
registerParserMiddleware(prefix, rawBody) {
|
|
111
|
+
if (this._parsersRegistered) return;
|
|
112
|
+
this._parsersRegistered = true;
|
|
113
|
+
super.registerParserMiddleware(prefix, rawBody);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// a named export and nothing else: `import { FulmineExpressAdapter } from "fulmine.js/nest"`, which
|
|
118
|
+
// is how @nestjs/platform-express exports ExpressAdapter too
|
|
119
|
+
module.exports = { FulmineExpressAdapter };
|
package/src/request.js
CHANGED
|
@@ -181,10 +181,9 @@ function endsWithChunked(value) {
|
|
|
181
181
|
* The path of the url a request carries right now, without the query.
|
|
182
182
|
*
|
|
183
183
|
* Express reads it off req.url on every access, so a middleware that assigns req.url is seen by
|
|
184
|
-
* whatever runs next, the callback after it in the same route included: the router takes a
|
|
185
|
-
* over at its next hop
|
|
186
|
-
*
|
|
187
|
-
* while the two agree, which is every read of a request nobody rewrote.
|
|
184
|
+
* whatever runs next, the callback after it in the same route included: the router only takes a
|
|
185
|
+
* rewrite over at its next hop. The cached field answers while the two agree, which is every read
|
|
186
|
+
* of a request nobody rewrote.
|
|
188
187
|
*
|
|
189
188
|
* @param {any} req
|
|
190
189
|
* @returns {string}
|
|
@@ -369,11 +368,12 @@ module.exports = class Request extends LazyReadable {
|
|
|
369
368
|
/** A bodyless request whose empty end has not been delivered yet, see the constructor. */
|
|
370
369
|
#emptyBody = false;
|
|
371
370
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
371
|
+
// `body` is deliberately not declared here. A class field would put the property on every
|
|
372
|
+
// request, and on Express there is none until a body parser assigns one. `"body" in req` is how
|
|
373
|
+
// a library asks whether the body has already been read, and tRPC's express adapter asks
|
|
374
|
+
// exactly that: answering yes on a request nobody had parsed handed it an undefined body and
|
|
375
|
+
// turned every mutation into "Unexpected end of JSON input". Its type lives in types.d.ts,
|
|
376
|
+
// where the rest of the public request surface is described.
|
|
377
377
|
|
|
378
378
|
/**
|
|
379
379
|
* The response this request arrived with, linked so either reaches the other.
|
package/src/response.js
CHANGED
|
@@ -589,9 +589,16 @@ module.exports = class Response extends LazyWritable {
|
|
|
589
589
|
*
|
|
590
590
|
* Nothing is written here despite the name: the headers go out when the body does.
|
|
591
591
|
*
|
|
592
|
+
* Every header goes through setHeader and not through set. This is node's method, not
|
|
593
|
+
* Express's: Express does not override it, so a content-type given here keeps the value it was
|
|
594
|
+
* given, where `res.set("content-type", "text/html")` would have a charset appended to it. A
|
|
595
|
+
* handler that builds its own response and writes it with writeHead is how every meta-framework
|
|
596
|
+
* on top of Express answers, @astrojs/node and @sveltejs/adapter-node included, so the charset
|
|
597
|
+
* was being added to pages nobody asked it for.
|
|
598
|
+
*
|
|
592
599
|
* @param {number} statusCode
|
|
593
|
-
* @param {string|Record<string, any
|
|
594
|
-
* @param {Record<string, any
|
|
600
|
+
* @param {string|Record<string, any>|any[]} [statusMessage] the reason phrase, or the headers
|
|
601
|
+
* @param {Record<string, any>|any[]} [headers]
|
|
595
602
|
* @returns {this}
|
|
596
603
|
*/
|
|
597
604
|
writeHead(statusCode, statusMessage, headers) {
|
|
@@ -605,8 +612,22 @@ module.exports = class Response extends LazyWritable {
|
|
|
605
612
|
// string reaching here was already taken as the phrase above and simply has no keys.
|
|
606
613
|
headers = /** @type {Record<string, any>} */ (statusMessage);
|
|
607
614
|
}
|
|
615
|
+
if (Array.isArray(headers)) {
|
|
616
|
+
// node takes a flat list here, name then value, and not a list of pairs. An odd length
|
|
617
|
+
// is the caller's mistake and node names the argument in what it throws
|
|
618
|
+
if (headers.length % 2 !== 0) {
|
|
619
|
+
/** @type {NodeJS.ErrnoException} */
|
|
620
|
+
const err = new TypeError(`The argument 'headers' is invalid. Received ${JSON.stringify(headers)}`);
|
|
621
|
+
err.code = "ERR_INVALID_ARG_VALUE";
|
|
622
|
+
throw err;
|
|
623
|
+
}
|
|
624
|
+
for (let i = 0; i < headers.length; i += 2) {
|
|
625
|
+
this.setHeader(headers[i], headers[i + 1]);
|
|
626
|
+
}
|
|
627
|
+
return this;
|
|
628
|
+
}
|
|
608
629
|
for (const header in headers) {
|
|
609
|
-
this.
|
|
630
|
+
this.setHeader(header, headers[header]);
|
|
610
631
|
}
|
|
611
632
|
return this;
|
|
612
633
|
}
|
package/src/router.js
CHANGED
|
@@ -893,6 +893,16 @@ function onNativeAborted() {
|
|
|
893
893
|
// error goes only to whoever listens for it, since a destroy(err) with no listener
|
|
894
894
|
// would take down the process
|
|
895
895
|
request.emit("aborted");
|
|
896
|
+
// and the response dies between the two, which is where node puts it. Destroyed rather than
|
|
897
|
+
// told to emit 'close', because being destroyed is the state express is in here and everything
|
|
898
|
+
// after it follows from that state rather than having to be reproduced: 'close' goes out once,
|
|
899
|
+
// a later res.write returns false and calls its callback with ERR_STREAM_DESTROYED, and no
|
|
900
|
+
// 'error' is emitted, which a destroy(err) here would.
|
|
901
|
+
//
|
|
902
|
+
// Without this a handler learnt about the abort only from a write failing, so one that had sent
|
|
903
|
+
// its head and gone quiet never learnt at all. `res.on("close")` is where cancellation hangs in
|
|
904
|
+
// every proxy and every streaming endpoint, so it never ran for exactly the shape that needs it.
|
|
905
|
+
response.destroy();
|
|
896
906
|
request.destroy(request.listenerCount("error") > 0 ? err : undefined);
|
|
897
907
|
response.socket?.emit("error", err);
|
|
898
908
|
}
|
|
@@ -1079,7 +1089,17 @@ function stepsOver(route, req) {
|
|
|
1079
1089
|
if (route.bodyMethods === undefined) {
|
|
1080
1090
|
route.bodyMethods = req.app.get("body methods") ?? null;
|
|
1081
1091
|
}
|
|
1082
|
-
|
|
1092
|
+
if (route.bodyMethods !== null && route.bodyMethods.includes(req.method)) {
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
// The layer is not entered, so it leaves the one mark it would have left: the parser puts
|
|
1096
|
+
// `body` on the request before it works out that there is nothing to read. A library asks
|
|
1097
|
+
// `"body" in req` to tell "a parser has run" from "none has", and a skip that did not leave
|
|
1098
|
+
// it would answer a GET differently from express. See the same seeding in middlewares.js
|
|
1099
|
+
if (!("body" in req)) {
|
|
1100
|
+
req.body = undefined;
|
|
1101
|
+
}
|
|
1102
|
+
return true;
|
|
1083
1103
|
}
|
|
1084
1104
|
|
|
1085
1105
|
/**
|
package/src/testing.js
CHANGED
|
@@ -171,6 +171,32 @@ function expectNative(app, patterns) {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Why a route µWS already matches is still not compiled into a response.
|
|
176
|
+
*
|
|
177
|
+
* The handler is the last answer, not the first: three refusals come before it, and blaming the
|
|
178
|
+
* handler for one of those sends the reader to rewrite something that was already simple enough.
|
|
179
|
+
*
|
|
180
|
+
* @param {any} app
|
|
181
|
+
* @param {{path: string}} entry
|
|
182
|
+
* @returns {string}
|
|
183
|
+
*/
|
|
184
|
+
function whyNotCompiled(app, entry) {
|
|
185
|
+
if (!app.get("declarative responses")) {
|
|
186
|
+
return "answered by µWS, but declarative responses are turned off";
|
|
187
|
+
}
|
|
188
|
+
if (entry.path.includes(":")) {
|
|
189
|
+
return "answered by µWS, but the route captures, and nothing runs to decode the value";
|
|
190
|
+
}
|
|
191
|
+
if (app.get("etag")) {
|
|
192
|
+
return (
|
|
193
|
+
"answered by µWS, but a response carrying an ETag could never answer the conditional " +
|
|
194
|
+
'request it invites: app.set("etag", false) is what puts a route here'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return "answered by µWS, but the handler is not simple enough to compile";
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
/**
|
|
175
201
|
* Throws unless every route named is answered from a response written at startup, which is the
|
|
176
202
|
* step past native: µWS answers it without entering javascript at all.
|
|
@@ -189,9 +215,7 @@ function expectDeclarative(app, patterns) {
|
|
|
189
215
|
.map(
|
|
190
216
|
(entry) =>
|
|
191
217
|
` ${entry.method} ${entry.path}\n ` +
|
|
192
|
-
(entry.native
|
|
193
|
-
? "answered by µWS, but the handler is no longer simple enough to compile"
|
|
194
|
-
: entry.reason)
|
|
218
|
+
(entry.native ? whyNotCompiled(app, entry) : entry.reason)
|
|
195
219
|
)
|
|
196
220
|
.join("\n") +
|
|
197
221
|
`\n\nRun \`npx fulmine profile\` to see the whole picture.`
|