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 CHANGED
@@ -21,6 +21,8 @@ There is a command that does that replacing for you, across a whole project, and
21
21
  npx fulmine.js verify # can this machine and this image even run it
22
22
  npx fulmine.js migrate --dry-run # say what it would change, change nothing
23
23
  npx fulmine.js migrate # do it
24
+ npx fulmine.js override # when a framework requires express in its own code, not in yours
25
+ npx fulmine.js angular # angular.json's server build, which esbuild would otherwise inline
24
26
  npx fulmine.js differences # just the list of what to check by hand
25
27
  npx fulmine.js profile # what listen() decided about each route
26
28
  npx fulmine.js explain /api/items # what happens when a request for that route arrives
@@ -64,6 +66,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
64
66
  - [Response](#response)
65
67
  - [Router](#router)
66
68
  - [Tested middlewares](#tested-middlewares)
69
+ - [Tested frameworks](#tested-frameworks)
67
70
  - [Tested view engines](#tested-view-engines)
68
71
  - [Examples](./examples/README.md)
69
72
  - [Working on Fulmine](./CONTRIBUTING.md)
@@ -78,9 +81,9 @@ Compatibility here is not a claim, it is a test suite. Every test runs against r
78
81
 
79
82
  Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
80
83
 
81
- **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 4.3x: hello-world 1.9x to 2.2x, an API endpoint with params and a query 3.2x to 4.3x, five route shapes served by one process 2.5x to 3.3x, nested routers 2.1x to 3.1x, a urlencoded body 3.4x to 4.1x, a thousand concurrent connections 2.7x to 3.2x. Route tables are where the native router shows: a thousand routes 9.7x to 12.9x, with a parameter in every one of them 10.4x to 14x, a parameterised route in a mounted router 7.1x to 8.3x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.5x to 1.7x after the per-request work of August 2026.
84
+ **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. The spreads below are the last nine CI runs, which landed on three different runner shapes, all on Node 26. Plain routing lands between 1.8x and 4.9x: hello-world 1.8x to 2.9x, an API endpoint with params and a query 3.1x to 4.9x, five route shapes served by one process 2.4x to 4.0x, nested routers 2.0x to 3.4x, a urlencoded body 3.3x to 4.6x, a thousand concurrent connections 2.6x to 3.7x. Route tables are where the native router shows: a thousand routes 9.7x to 17.4x, with a parameter in every one of them 10x to 21.2x, a parameterised route in a mounted router 6.8x to 8.8x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.7x to 2.1x after the per-request work of August 2026.
82
85
 
83
- **Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere around 1.0x to 1.2x, and no amount of work on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
86
+ **Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere between 1.0x and 1.5x, depending on how much of the request is the shared work, and no amount of effort on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
84
87
 
85
88
  Two things worth knowing before comparing numbers with anyone:
86
89
 
@@ -155,8 +158,16 @@ The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express appl
155
158
  one-line change applies, and `@angular/ssr`'s own `AngularNodeAppEngine` and
156
159
  `writeResponseToNodeResponse` work against Fulmine's request and response unchanged. One extra step
157
160
  is needed, and it is Angular's build rather than this library: the server bundle is built with
158
- esbuild, which tries to inline every dependency and cannot load µWS's native binary. Declare the two
159
- as external in `angular.json`:
161
+ esbuild, which tries to inline every dependency and cannot load µWS's native binary. The two names
162
+ have to be declared external in `angular.json`, which is what this writes:
163
+
164
+ ```sh
165
+ npx fulmine.js angular # every server build in angular.json
166
+ npx fulmine.js angular --dry-run # say what it would write, write nothing
167
+ ```
168
+
169
+ It adds this to each build target that produces a server bundle, and leaves the browser-only ones
170
+ alone:
160
171
 
161
172
  ```json
162
173
  "architect": { "build": { "options": {
@@ -179,40 +190,51 @@ with no cache at all on the serving side.
179
190
  ### NestJS
180
191
 
181
192
  `@nestjs/platform-express` takes an Express instance, so it takes this one, and everything in a Nest
182
- application keeps working. One line stands between that and the speed: the adapter wraps whatever
183
- instance it is given in `http.createServer()` and listens on that, which is the shim, so every
184
- request goes through `node:http` and the application runs at Express's pace. The app here already
185
- answers as an `http.Server`, so it can be the server rather than being wrapped in one:
193
+ application keeps working. The adapter is in the package, so there is nothing to write:
186
194
 
187
195
  ```ts
188
196
  import { NestFactory } from "@nestjs/core";
189
- import { ExpressAdapter } from "@nestjs/platform-express";
190
- import fulmine from "fulmine.js";
197
+ import { FulmineExpressAdapter } from "fulmine.js/nest";
191
198
 
192
- class FulmineAdapter extends ExpressAdapter {
193
- initHttpServer() {
194
- // instead of http.createServer(instance): listen() and close() are then µWS's
195
- (this as any).httpServer = this.getInstance();
196
- }
197
- }
198
-
199
- const app = await NestFactory.create(AppModule, new FulmineAdapter(fulmine()));
199
+ const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
200
200
  await app.listen(3000);
201
201
  ```
202
202
 
203
+ Pass your own application where it needs options, TLS being the usual reason:
204
+ `new FulmineExpressAdapter(fulmine({ uwsOptions }))`. `@nestjs/platform-express` is an optional peer
205
+ dependency, so nothing is installed for anyone who never imports this.
206
+
207
+ What it changes is one line and two edges. The line: Nest's own adapter wraps whatever instance it
208
+ is given in `http.createServer()` and listens on that, which is the shim, so every request goes
209
+ through `node:http` and the application runs at Express's pace. The app here already answers as an
210
+ `http.Server`, so it is the server rather than being put inside one. The edges: `forceCloseConnections`
211
+ has nothing to destroy, since the sockets belong to µWS and nothing emits `connection`, so it now
212
+ says so instead of quietly doing nothing; and Nest decides whether it has already added its body
213
+ parsers by scanning `app.router.stack`, which is not there, so the adapter remembers instead of
214
+ letting a second call add a second pair. `httpsOptions` is refused rather than silently starting a
215
+ plaintext server: TLS is configured on the app, through `uwsOptions`.
216
+
203
217
  Measured on the same Nest application, controllers, pipes and body parsing unchanged: **1.2x on a
204
218
  route answering text and 1.9x on one answering JSON with a route parameter**. `app.close()` closes
205
219
  the port, as it does on the shim.
206
220
 
207
- Two things to know. `forceCloseConnections` has nothing to destroy, since the sockets belong to µWS
208
- and nothing emits `connection`, and Nest looks at `app.router.stack` to decide whether it has
209
- already added its body parsers, which is not there, so it adds them once more than it would.
221
+ A Nest application answering the same bytes on both is [a case in the integration
222
+ suite](./integrations/cases/nest.js), so this is tested rather than claimed.
210
223
 
211
224
  ### When Express is somebody else's dependency
212
225
 
213
226
  A framework built on Express does not `require("express")` in your code, it requires it in its own,
214
227
  so there is nothing for `migrate` to rewrite. Every package manager can answer `express` with this
215
- package instead, for your project and everything under it:
228
+ package instead, for your project and everything under it, and this writes the block for whichever
229
+ one your project uses:
230
+
231
+ ```sh
232
+ npx fulmine.js override # read the lockfile, write the block, say what to run next
233
+ npx fulmine.js override --dry-run # say what it would write, write nothing
234
+ ```
235
+
236
+ It refuses rather than overwrites where a substitution is already there and is not this package. By
237
+ hand it is one of these:
216
238
 
217
239
  ```jsonc
218
240
  // npm and its lockfile, in package.json
@@ -393,10 +415,17 @@ routes get:
393
415
 
394
416
  Optimized routes can be up to 10 times faster than normal routes, as they're using native uWS router and have pre-calculated path.
395
417
 
396
- On top of that, a handler simple enough to be read at registration time is compiled into a uWS declarative response and answered natively, without entering JavaScript at all. That needs the route to have nothing in front of it, not a middleware and not a `Router` it was mounted under, and a single handler that only calls `res.status`, `res.set`, `res.type`, `res.append`, `res.send`, `res.json`, `res.sendStatus` or `res.end` with literal arguments, plus `req.params` and `req.query`. `res.set` takes a pair or a whole object of them, and `res.type` takes what it takes anywhere, since a media type is a lookup on a literal. Anything else, a variable, a call, an `if`, falls back to ordinary routing. `return res.send(...)` compiles, `res.send(...)` does too, and so does an object or an array of literals however deeply nested. Mounting a `Router` costs only this: the routes inside one are still registered on the native uWS router with their full path, and are as fast as any other optimized route. Three things follow from the response being static:
418
+ On top of that, a handler simple enough to be read at registration time is compiled into a uWS declarative response and answered natively, without entering JavaScript at all. That needs the route to have nothing in front of it, not a middleware and not a `Router` it was mounted under, and a single handler that only calls `res.status`, `res.set`, `res.type`, `res.append`, `res.send`, `res.json`, `res.sendStatus` or `res.end` with literal arguments, plus `req.query`. `res.set` takes a pair or a whole object of them, and `res.type` takes what it takes anywhere, since a media type is a lookup on a literal. Anything else, a variable, a call, an `if`, falls back to ordinary routing. `return res.send(...)` compiles, `res.send(...)` does too, and so does an object or an array of literals however deeply nested. Mounting a `Router` costs only this: the routes inside one are still registered on the native uWS router with their full path, and are as fast as any other optimized route.
397
419
 
398
- - it cannot answer `304 Not Modified`. The ETag is still sent, so caches keep working, but a conditional request gets the whole body back rather than an empty 304. Express replies 304 there.
399
- - it carries a `Content-Length` while its body is literal all the way through. A body with a piece taken from the request, `res.send(req.params.id)`, has no length until the request arrives, so that one is framed as `Transfer-Encoding: chunked`. uWS writes the framing either way, which is why neither header can be set by hand.
420
+ Three things are refused whatever the handler does, and all three are the same fact: a response written at startup cannot read the request.
421
+
422
+ - one that would carry an `ETag` or a `Last-Modified`, since it could never answer with the `304 Not Modified` that the validator invites. `etag` is on by default, so `app.set("etag", false)` is what puts an ordinary route on this path.
423
+ - one whose route captures, `/users/:id`, since a value that cannot be decoded is a `400` in Express and nothing runs here to raise it.
424
+ - a `204`, `205` or `304`, since the body would go out with the status and a client frames those as bodiless whatever it reads.
425
+
426
+ Two things then follow from the response being static:
427
+
428
+ - it carries a `Content-Length` while its body is literal all the way through. A body with a piece taken from the request, `res.send(req.query.q)`, has no length until the request arrives, so that one is framed as `Transfer-Encoding: chunked`. uWS writes the framing either way, which is why neither header can be set by hand.
400
429
  - it answers `Connection: keep-alive` even to a request that asked for `Connection: close`. The connection is still closed, since uWS decides that itself, and a client that asked to close is closing anyway.
401
430
 
402
431
  `app.set("declarative responses", false)` turns the whole thing off if you would rather have Express's exact framing than the speed.
@@ -483,7 +512,7 @@ Runnable: [`examples/compression.js`](./examples/compression.js).
483
512
 
484
513
  6. Do not set `body methods` to read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower.
485
514
 
486
- 7. `app.set("etag", false)` is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. It is the single biggest thing an ordinary route does: in a CPU profile of one, hashing the body and building the tag are about 21% of the time that is not spent waiting, more than writing the headers and more than building the request and the response together. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no `304 Not Modified` replies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated.
515
+ 7. `app.set("etag", false)` is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. It is the single biggest thing an ordinary route does: in a CPU profile of one, hashing the body and building the tag are about 21% of the time that is not spent waiting, more than writing the headers and more than building the request and the response together. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no `304 Not Modified` replies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated, and note that it is the same setting that decides whether a simple route is compiled into a native response, above.
487
516
 
488
517
  8. By default, Fulmine creates 1 (or 0 if your CPU has only 1 core) child thread to improve performance of reading files. You can change this number by setting `threads` to a different number in `express()`, or set to 0 to disable thread pool (`express({ threads: 0 })`). Threads are shared between all express() instances, with largest `threads` number being used. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both.
489
518
 
@@ -530,7 +559,7 @@ app.ws("/room/:id", {
530
559
  - **The behavior object is µWS's**, settings included: `maxPayloadLength`, `idleTimeout`, `compression`, `maxBackpressure`, `sendPingsAutomatically` and the rest are passed through untouched, as are the `open`, `message`, `drain`, `close`, `ping`, `pong`, `dropped` and `subscription` handlers. The socket is µWS's too, so `send`, `subscribe`, `publish`, `cork` and `getBufferedAmount` behave exactly as its documentation describes.
531
560
  - **`upgrade(req, res)` is this project's addition.** It runs before the handshake with the same `Request` and `Response` your routes get, so a session, a token or a header decides whether the socket opens. Answering the response, with `res.sendStatus(401)` or any other write, declines the upgrade. Returning a promise holds the handshake until it settles, which is what an authentication lookup needs.
532
561
  - **`ws.req` is that request**, and it outlives the response: the client's address, headers, query and params are readable from any handler for as long as the socket is open. Hanging your own values on it in `upgrade` is how per-connection state gets to `message`.
533
- - **Routers work.** `router.ws("/lobby", …)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
562
+ - **Routers work.** `router.ws("/lobby", ...)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
534
563
  - **Paths are the ones µWS matches**: literal, or with parameters that are a whole segment such as `/room/:id`. Anything else throws where it is written rather than failing to match later.
535
564
  - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
536
565
 
@@ -706,8 +735,10 @@ Two of these keep a compiled form alongside the value, which you can also set di
706
735
  - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
707
736
  - `query parser fn`, likewise for `query parser`.
708
737
 
709
- Fulmine adds six of its own:
738
+ Fulmine adds eight of its own:
710
739
 
740
+ - `body methods`, unset by default. The body is read for POST, PUT, PATCH and QUERY, and this names the methods to read one for as well: `app.set("body methods", ["DELETE"])`. Reading a body no handler asks for costs about 15%, which is why the built-in list is short rather than every method.
741
+ - `native routes`, on by default. Off, every request walks the ordinary chain instead of letting µWS match what it can, which is slower and answers the same. It is a diagnostic rather than a tuning knob: it exists so one application can be served both ways and the two sets of answers compared, which is how the optimizer is tested. A compiled response needs a native registration to hang on, so turning this off turns `declarative responses` off with it.
711
742
  - `etag methods`, unset by default. Express computes the generated ETag for every method, and so does this until told otherwise. `app.set("etag methods", ["GET", "HEAD"])` skips the digest on every other method, where freshness is not defined and the validator can never match: worth 21% here on a 4KB POST answer. An ETag set by hand still goes out whatever the method.
712
743
  - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
713
744
  - `connection headers`, on by default. Express sends `Connection: keep-alive` and `Keep-Alive` on every response, and so does this. Turn it off and neither goes out, while a connection the client asked to close still answers `Connection: close`: it is the advertisement that goes, not the truth. Worth 2% to 3.5% here on a route that is not compiled, plus the bytes.
@@ -848,6 +879,29 @@ Almost all middlewares that are compatible with Express are compatible with Fulm
848
879
  `express` itself, so testing it here needs a dependency override rather than the one-line swap
849
880
  everything else takes.
850
881
 
882
+ ## Tested frameworks
883
+
884
+ The list above is middlewares. A framework built on Express is a much larger user of the Express
885
+ surface than any application is, so those have a suite of their own, in
886
+ [`integrations/`](./integrations): the same application served twice, once on Express and once here,
887
+ with the two outputs compared byte for byte. The four that render pages are built first, by that
888
+ suite, so what is compared is what their own build produces.
889
+
890
+ - ✅ [NestJS](https://nestjs.com) through [`fulmine.js/nest`](#nestjs)
891
+ - ✅ [Next.js](https://nextjs.org) as a custom server, `next().getRequestHandler()`
892
+ - ✅ [Astro](https://astro.build) through `@astrojs/node` in middleware mode
893
+ - ✅ [SvelteKit](https://svelte.dev/docs/kit) through `@sveltejs/adapter-node`
894
+ - ✅ [React Router v7](https://reactrouter.com) through `@react-router/express`
895
+ - ✅ [Apollo Server](https://www.apollographql.com/docs/apollo-server) through
896
+ [`@as-integrations/express5`](https://www.npmjs.com/package/@as-integrations/express5)
897
+ - ✅ [tRPC](https://trpc.io) through `@trpc/server/adapters/express`
898
+ - ✅ [Angular SSR](#angular-ssr), which is an ordinary Express `server.ts` plus one line of build
899
+ configuration
900
+
901
+ Each of these mounts on an ordinary Express application, so there is nothing to install and nothing
902
+ to configure beyond what that framework already asks for. Nest is the exception, and only because
903
+ its adapter decides what to listen on: that one is [`fulmine.js/nest`](#nestjs).
904
+
851
905
  ## Tested view engines
852
906
 
853
907
  Any Express view engine should work. Here's list of engines we include in our test suite:
package/package.json CHANGED
@@ -1,8 +1,20 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.12.3",
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 is framed differently and keeps its connection header",
89
- "A handler simple enough to be read at registration time is answered natively: chunked framing\n" +
90
- "with no Content-Length, and a client that sent Connection: close is still told keep-alive,\n" +
91
- "though the socket does close. A response that would carry a validator is never compiled, so\n" +
92
- 'conditional requests behave as on Express. app.set("declarative responses", false) turns it off.'
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
 
@@ -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, which is a rule about the message and not about the
719
- // application: express drops the body and the framing headers for 204 and 304, and node
720
- // writes a lone Content-Length: 0 for 205. Compiled, the body went out anyway, so
721
- // res.sendStatus(204) answered "No Content" with a Content-Length of ten. A client frames
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
- // µWS answers a declarative response without reading the request, so it cannot answer a
777
- // conditional GET: it used to write an ETag computed over the compiled body at listen and
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
  }
@@ -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 rewrite
185
- * over at its next hop, and until then the field is a hop behind. `req.url = req.url.replace(/^\/+/,
186
- * "/")` reported "//" for the rest of its own route where express reports "/". The field answers
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
- * What a body parser left behind, and undefined until one claims the request.
374
- * @type {any}
375
- */
376
- body;
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>} [statusMessage] the reason phrase, or the headers
594
- * @param {Record<string, any>} [headers]
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.set(header, headers[header]);
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
- return route.bodyMethods === null || !route.bodyMethods.includes(req.method);
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.`