fulmine.js 5.19.9 → 5.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,36 +1,19 @@
1
1
  <img src="./assets/logo-mark.svg" alt="" width="88" align="right">
2
2
 
3
- # Fulmine.js
3
+ # Fulmine.js: the drop-in Express 5 replacement, up to 20x faster
4
4
 
5
- Fulmine - means lightning ⚡ in Italian - is a blazing-fast drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
5
+ **Fulmine** (lightning in Italian ⚡) is an Express 5 compatible web framework for Node.js, built on
6
+ [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Same API, same
7
+ middleware, same tests. Change one line and your Express application runs faster.
8
+
9
+ **Docs: [fulmine.sndesign.it](https://fulmine.sndesign.it)**
6
10
 
7
11
  ```js
8
12
  const express = require("fulmine.js"); // instead of require("express")
9
13
  ```
10
14
 
11
- ESM and TypeScript work the same way, named imports included:
12
-
13
- ```ts
14
- import express, { Router, json } from "fulmine.js";
15
- import type { Request, Response } from "fulmine.js";
16
- ```
17
-
18
- There is a command that does that replacing for you, across a whole project, and then tells you the handful of things that behave differently:
19
-
20
- ```sh
21
- npx fulmine.js verify # can this machine and this image even run it
22
- npx fulmine.js migrate --dry-run # say what it would change, change nothing
23
- npx fulmine.js migrate # do it
24
- npx fulmine.js 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
26
- npx fulmine.js differences # just the list of what to check by hand
27
- npx fulmine.js profile # what listen() decided about each route
28
- npx fulmine.js explain /api/items # what happens when a request for that route arrives
29
- ```
30
-
31
- See [Migrating](#migrating) for what it handles and what it deliberately does not.
32
-
33
15
  [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
16
+ [![npm downloads](https://img.shields.io/npm/dm/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
34
17
  [![Node.js 22 | 24 | 26](https://img.shields.io/badge/Node.js-22%20%7C%2024%20%7C%2026-green)](https://nodejs.org)
35
18
  [![HTTP Arena](https://img.shields.io/endpoint?url=https://www.http-arena.com/badge/fulmine.js/h1.json)](https://www.http-arena.com/#tuned=0)
36
19
  [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/fulmine.js/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
@@ -39,1008 +22,155 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
39
22
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14089/badge)](https://www.bestpractices.dev/projects/14089)
40
23
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
41
24
 
42
- ## Table of contents
43
-
44
- - [Why this exists](#why-this-exists)
45
- - [Performance](#performance)
46
- - [Public benchmarks](#public-benchmarks)
47
- - [Difference from similar projects](#difference-from-similar-projects)
48
- - [Migrating](#migrating)
49
- - [Angular SSR](#angular-ssr)
50
- - [NestJS](#nestjs)
51
- - [When Express is somebody else's dependency](#when-express-is-somebody-elses-dependency)
52
- - [Docker](#docker)
53
- - [Behind a private registry](#behind-a-private-registry)
54
- - [Differences from Express](#differences-from-express)
55
- - [Performance tips](#performance-tips)
56
- - [WebSockets](#websockets)
57
- - [socket.io](#socketio)
58
- - [HTTP/3](#http3)
59
- - [Behind a proxy](#behind-a-proxy)
60
- - [Versioning](#versioning)
61
- - [Compatibility](#compatibility)
62
- - [express](#express)
63
- - [Application](#application)
64
- - [Application settings](#application-settings)
65
- - [Request](#request)
66
- - [Response](#response)
67
- - [Router](#router)
68
- - [Tested middlewares](#tested-middlewares)
69
- - [Tested frameworks](#tested-frameworks)
70
- - [Tested view engines](#tested-view-engines)
71
- - [Examples](./examples/README.md)
72
- - [Attribution](#attribution)
73
- - [Working on Fulmine](./CONTRIBUTING.md)
74
-
75
- ## Why this exists
76
-
77
- There are several fast HTTP servers for Node built on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js). What is scarce is one you can actually drop into an existing Express application without rewriting it.
78
-
79
- Compatibility here is not a claim, it is a test suite. Every test runs against real Express first and then against Fulmine, and the outputs have to match byte for byte. That is what makes `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of the ecosystem work rather than "mostly work". Express 5's own test suite runs against Fulmine too, and passes whole: 1130 passing, 0 failing at the pinned Express version.
80
-
81
- It started as a fork of [Ultimate Express](https://github.com/dimdenGD/ultimate-express), which is where the hard part was already done. See [Attribution](#attribution).
82
-
83
- ## Performance
84
-
85
- 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.
86
-
87
- **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. The spreads below are the last twelve CI runs, which landed on four different runner shapes, all on Node 26. Plain routing lands between 1.3x and 4.9x: hello-world 1.3x to 3.2x, an API endpoint with params and a query 1.9x to 4.9x, five route shapes served by one process 1.6x to 4.1x, nested routers 1.5x to 3.6x, a urlencoded body 2.1x to 5.6x, a thousand concurrent connections 2.2x to 3.6x. Route tables are where the native router shows: a thousand routes 7.5x to 16.4x, with a parameter in every one of them 7.7x to 19.9x, a parameterised route in a mounted router 4.3x 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.8x to 2.5x after the per-request work of August 2026. Those spreads are wider than they were: the newest runners are much faster for Express, which moves the ratio without either server changing, and the low end of every row now comes from one of them.
88
-
89
- **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.
90
-
91
- Two things worth knowing before comparing numbers with anyone:
92
-
93
- - **Node 24 moved the baseline.** Express got roughly 3x faster on the routing benchmarks between Node 22 and Node 24, while a µWS-based server barely moved, because the gain came from `node:http`. Any comparison published before mid-2026 overstates the current gap.
94
- - **Ratios are not portable across runs.** GitHub's runners vary enough that the same code measures 15k or 28k req/sec on the same row. Only compare figures produced in the same run.
95
-
96
- There is no table here on purpose. CI runs the whole benchmark on every push and every pull request
97
- and posts the result where it belongs: as a comment on the commit or the pull request, and as a
98
- `benchmark-summary` artifact on the run, see [`benchmark/README.md`](./benchmark/README.md)
99
- to run it yourself.
100
-
101
- ## Public benchmarks
102
-
103
- Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
104
-
105
- - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)**: thirty profiles on 64-core dedicated hardware, same conditions for every entry, rerun whenever one of them changes. The link lands filtered on the JavaScript entries. No figures are copied here on purpose: the board is the current one and this page would not be.
106
- - **[web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)**: in the published round, ranked with the other sixty-odd JavaScript entries on their own hardware. Same rule as above, no figures copied here.
107
-
108
- More to come as their maintainers take the entries in.
109
-
110
- ## Difference from similar projects
111
-
112
- - **`ultimate-express`** is what Fulmine is derived from, and is the closest relative by far. It targets Express 4, keeps the v4 API surface and its deprecations. Fulmine targets Express 5 only, which removes the compatibility layer for everything v5 dropped, and is typed. If you are on Express 4, use `ultimate-express`.
113
- - **`hyper-express`** has a similar API but is not a drop-in replacement. It implements much of the functionality differently, which produces quirks that make switching an existing application difficult, and most Express middleware is unsupported.
114
- - **`uwebsockets-express`** is closer to a drop-in replacement, but misses a lot of the API, depends on Express by calling its methods under the hood, and does not use the native µWS router.
115
- - **`express` on Bun** benefits from Bun using µWS for its HTTP module, but performs no µWS-specific optimizations.
116
-
117
- ## Migrating
118
-
119
- In a lot of cases, replacing `require("express")` with `require("fulmine.js")` is the whole migration. `npx fulmine.js migrate` does that across a project:
120
-
121
- ```sh
122
- npx fulmine.js migrate [dir] # defaults to the current directory
123
- npx fulmine.js migrate --dry-run # say what it would rewrite and rewrite nothing
124
- npx fulmine.js differences # print the list below and change nothing
125
- ```
126
-
127
- It also names the middlewares it found that have a faster one built in here, `compression`,
128
- `body-parser` and `serve-static`, and leaves them to you: the replacement is reached through the
129
- `express` import, and no rewrite can know that it is in scope where they are required.
130
-
131
- `npx fulmine.js verify` is the question that comes before all of that: whether this machine, and the
132
- image this will be deployed in, can run it at all. There is a µWebSockets.js binary underneath, and
133
- a binary is built per platform, per architecture and per node ABI, and linked against glibc. An
134
- Alpine base, a node version the pinned build has no binary for, a `FROM node:20-alpine` written
135
- years ago: each one fails at require time, in a container, with a message about a missing module.
136
- This says so in thirty seconds, and exits non-zero when something would stop the start.
137
-
138
- ```text
139
- ok Node 22.15.0
140
- ok glibc 2.39
141
- ok µWebSockets.js binary for linux x64, node ABI 127
142
- NO Dockerfile: node:20-alpine
143
- musl, and there is no musl build: node:22-trixie-slim is the closest swap.
144
- note socket.io needs a different API here
145
- attach it with io.attachApp(app.uwsApp), not io.attach(server)
146
- ```
147
-
148
- ### Angular SSR
149
-
150
- The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express application, so the same
151
- one-line change applies, and `@angular/ssr`'s own `AngularNodeAppEngine` and
152
- `writeResponseToNodeResponse` work against Fulmine's request and response unchanged. One extra step
153
- is needed, and it is Angular's build rather than this library: the server bundle is built with
154
- esbuild, which tries to inline every dependency and cannot load µWS's native binary. The two names
155
- have to be declared external in `angular.json`, which is what this writes:
25
+ ## Why Fulmine
26
+
27
+ - **Faster than Express, measured.** 1.3x to 4.9x on plain routing, 2x to 5x on a request with a body,
28
+ 7x to 20x on a large route table, on every CI run. Routes are matched in C++ by µWS's own router,
29
+ and a simple enough handler is answered without running any JavaScript at all.
30
+ - **Zero rewrite.** `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of
31
+ the Express ecosystem keep working. Not "mostly": every test runs against real Express first and the
32
+ output must match byte for byte, and Express 5's own test suite passes whole, 1130 of 1130.
33
+ - **Your framework works too.** NestJS, Next.js, Astro, SvelteKit, React Router, Angular SSR, Apollo
34
+ Server, tRPC, tsoa, MCP servers: each one is served twice in CI, on Express and on Fulmine, and compared.
35
+ - **Ranked in public.** See [HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js) and
36
+ [web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript), run on their
37
+ hardware with their rules. No figure is copied here, the boards are the current ones.
38
+ - **More than Express, when you want it.** Multi-core cluster on one port, native WebSockets, built-in
39
+ compression and pre-compressed static files, Server-Timing, PROXY protocol, TLS. All optional.
40
+ - **Typed, TypeScript first.** ESM, CommonJS, named imports and the Express types you already use.
41
+
42
+ ## Quick start
156
43
 
157
44
  ```sh
158
- npx fulmine.js angular # every server build in angular.json
159
- npx fulmine.js angular --dry-run # say what it would write, write nothing
45
+ npx fulmine.js create my-app # a server, a package.json and a Dockerfile that works, --ts for TypeScript
46
+ cd my-app && npm install && npm run dev
160
47
  ```
161
48
 
162
- It adds this to each build target that produces a server bundle, and leaves the browser-only ones
163
- alone:
164
-
165
- ```json
166
- "architect": { "build": { "options": {
167
- "externalDependencies": ["fulmine.js", "uWebSockets.js"]
168
- } } }
169
- ```
170
-
171
- What it is worth, measured on an Angular 22 application with each server reporting its own CPU per
172
- request, nine alternating rounds: **static assets 3.29x**, and **a page served from a cache 1.50x**.
173
- The render itself is the same JavaScript on both sides and measures the same, so on a cache miss the
174
- framework is not what your page is waiting for. Which is the useful way round: an SSR application
175
- spends most of its traffic outside the render, and that is where the difference is.
176
-
177
- Caching those pages is [`ng-ssr-caching`](https://www.npmjs.com/package/ng-ssr-caching), a middleware
178
- that runs on Express and here alike, and the same measurement says a page costs 17.2ms to render and
179
- 1.9ms to serve from it. It is worth knowing why it keeps the ETag beside the bytes: a cache that
180
- stores only the body makes the server hash the whole document again on every hit, and measures level
181
- with no cache at all on the serving side.
182
-
183
- ### NestJS
184
-
185
- `@nestjs/platform-express` takes an Express instance, so it takes this one, and everything in a Nest
186
- application keeps working. The adapter is in the package, so there is nothing to write:
187
-
188
- ```ts
189
- import { NestFactory } from "@nestjs/core";
190
- import { FulmineExpressAdapter } from "fulmine.js/nest";
191
-
192
- const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
193
- await app.listen(3000);
194
- ```
195
-
196
- Pass your own application where it needs options, TLS being the usual reason:
197
- `new FulmineExpressAdapter(fulmine({ uwsOptions }))`. `@nestjs/platform-express` is an optional peer
198
- dependency, so nothing is installed for anyone who never imports this.
199
-
200
- What it changes is one line and two edges. The line: Nest's own adapter wraps whatever instance it
201
- is given in `http.createServer()` and listens on that, which is the shim, so every request goes
202
- through `node:http` and the application runs at Express's pace. The app here already answers as an
203
- `http.Server`, so it is the server rather than being put inside one. The edges: `forceCloseConnections`
204
- has nothing to destroy, since the sockets belong to µWS and nothing emits `connection`, so it now
205
- says so instead of quietly doing nothing; and Nest decides whether it has already added its body
206
- parsers by scanning `app.router.stack`, which is not there, so the adapter remembers instead of
207
- letting a second call add a second pair. `httpsOptions` is refused rather than silently starting a
208
- plaintext server: TLS is configured on the app, through `uwsOptions`.
209
-
210
- Measured on the same Nest application, controllers, pipes and body parsing unchanged: **1.2x on a
211
- route answering text and 1.9x on one answering JSON with a route parameter**. `app.close()` closes
212
- the port, as it does on the shim.
213
-
214
- A Nest application answering the same bytes on both is [a case in the integration
215
- suite](./integrations/cases/nest.js), so this is tested rather than claimed.
216
-
217
- ### When Express is somebody else's dependency
218
-
219
- A framework built on Express does not `require("express")` in your code, it requires it in its own,
220
- so there is nothing for `migrate` to rewrite. Every package manager can answer `express` with this
221
- package instead, for your project and everything under it, and this writes the block for whichever
222
- one your project uses:
49
+ Or in a project you already have:
223
50
 
224
51
  ```sh
225
- npx fulmine.js override # read the lockfile, write the block, say what to run next
226
- npx fulmine.js override --dry-run # say what it would write, write nothing
52
+ npm install fulmine.js
227
53
  ```
228
54
 
229
- It refuses rather than overwrites where a substitution is already there and is not this package. By
230
- hand it is one of these:
231
-
232
- ```jsonc
233
- // npm and its lockfile, in package.json
234
- {
235
- "overrides": {
236
- "express": "npm:fulmine.js@^5"
237
- }
238
- }
239
-
240
- // pnpm, in package.json
241
- {
242
- "pnpm": {
243
- "overrides": {
244
- "express": "npm:fulmine.js@^5"
245
- }
246
- }
247
- }
248
-
249
- // yarn 1 and berry, in package.json
250
- {
251
- "resolutions": {
252
- "express": "npm:fulmine.js@^5"
253
- }
254
- }
255
- ```
256
-
257
- Then reinstall, so the lockfile is rewritten: `rm -rf node_modules` and `npm install`, or the
258
- equivalent for your manager. `npm ls express` should answer `express@npm:fulmine.js`.
259
-
260
- Two things to know before you do it. The substitution reaches **every** dependency that asks for
261
- Express, including ones you have never looked at, so run your own tests afterwards and read
262
- [the differences](#differences-from-express): what a framework does with Express is usually more
263
- than what an application does. And a package that reaches into `express/lib/...` rather than its
264
- public surface will not find what it expects, since the files there are ours.
265
-
266
- Bun is not an option: µWebSockets.js is a native Node addon, and Bun does not load it.
267
-
268
- ## Docker
269
-
270
- Three things about µWebSockets.js make a Dockerfile that works for Express fail here, and all three have easy answers:
271
-
272
- - **No Alpine, and no Debian bookworm either.** µWebSockets.js ships prebuilt binaries linked against glibc 2.38 or newer. Alpine images use musl, so the binary does not load at all; `node:26` and `node:26-slim` are Debian bookworm, whose glibc 2.36 fails at startup with `GLIBC_2.38' not found`. Use the trixie variants: `node:26-trixie-slim` and up.
273
- - **`git` must be there when `npm install` runs.** µWebSockets.js is not on npm; it is installed straight from GitHub (`github:uNetworking/uWebSockets.js`), and npm uses git to fetch it. Full images like `node:26-trixie` have git; `-slim` ones do not.
274
- - **git must be allowed to speak https.** Where the build environment rewrites GitHub URLs to ssh, which some CI images and company-wide git configs do, the fetch asks for a key the image does not have and the install dies on a permission denied that never names µWebSockets.js. One line before `npm ci` puts it back:
275
-
276
- ```dockerfile
277
- RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
278
- ```
279
-
280
- The clean way to satisfy the first two is a multi-stage build: install with the full image, run with the slim one.
281
-
282
- ```dockerfile
283
- FROM node:26-trixie AS build
284
- WORKDIR /app
285
- COPY package*.json ./
286
- RUN npm ci --omit=dev
287
-
288
- FROM node:26-trixie-slim
289
- WORKDIR /app
290
- COPY --from=build /app/node_modules ./node_modules
291
- COPY . .
292
- EXPOSE 3000
293
- CMD ["node", "server.js"]
294
- ```
295
-
296
- A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y git ca-certificates` before `npm ci`. Prebuilt binaries exist for x64 and arm64 on Linux, macOS and Windows, so nothing is compiled at install time either way.
297
-
298
- ## Behind a private registry
299
-
300
- µWebSockets.js is not on npm, it is installed from GitHub, and npm allows that by default, so an
301
- ordinary `npm install fulmine.js` needs nothing from this section. It is here for the builds that
302
- have turned git dependencies off, or that cannot reach github.com at all:
303
-
304
- ```
305
- allow-git=none npm error code EALLOWGIT
306
- npm error Fetching packages of type "git" have been disabled
307
- npm error Refusing to fetch "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.69.0"
308
-
309
- allow-git=root npm error code EALLOWGIT
310
- npm error Fetching non-root packages of type "git" have been disabled
311
- ```
312
-
313
- `root` is the one that surprises people: it allows a git dependency your own `package.json` asks
314
- for, and still refuses this one, because it is asked for by fulmine rather than by you.
315
-
316
- The answer is to put µWebSockets.js in your own registry and point at it from there. Three steps,
317
- and nothing is compiled on the way: the tarball is what uNetworking already publishes on the tag,
318
- prebuilt binaries included.
319
-
320
- **1. Pack the tag.** No clone needed, npm takes the git spec directly:
321
-
322
- ```sh
323
- npm pack "github:uNetworking/uWebSockets.js#v20.69.0"
324
- ```
325
-
326
- Read the version out of [`package.json`](./package.json) rather than copying the one above, since
327
- it moves with each release of this package.
328
-
329
- **2. Publish it to your registry.**
330
-
331
- ```sh
332
- npm publish uWebSockets.js-20.69.0.tgz --registry https://registry.internal/
333
- ```
334
-
335
- The tarball is around 33 MB, because it carries a binary for every Node ABI and platform, and that
336
- is larger than several defaults along the way. Verdaccio refuses it at `max_body_size: 10mb`,
337
- and an nginx in front of any registry refuses it at `client_max_body_size 1m`. Both answer
338
- `413 Payload Too Large` without ever naming µWebSockets.js, so raise them before deciding the
339
- tarball is broken:
340
-
341
- ```yaml
342
- # verdaccio config.yaml
343
- max_body_size: 200mb
344
- ```
345
-
346
- **3. Override the git spec in your application.** The version is the same one you packed:
347
-
348
- ```json
349
- {
350
- "dependencies": { "fulmine.js": "5.17.0" },
351
- "overrides": { "uWebSockets.js": "20.69.0" }
352
- }
353
- ```
354
-
355
- `npm install` and `npm ci` both work from here with git off, and the lockfile resolves to your
356
- registry with an integrity hash, so nothing reaches for git at install time:
357
-
358
- ```json
359
- "node_modules/uWebSockets.js": {
360
- "version": "20.69.0",
361
- "resolved": "https://registry.internal/uWebSockets.js/-/uWebSockets.js-20.69.0.tgz",
362
- "integrity": "sha512-kO7bcc/Hy3K6YnAVKBCu9ffYC1tl/ccDzDJqVK/GAO81XRRL+R1VmJP8mReg..."
363
- }
364
- ```
365
-
366
- One thing to tell your security team before their scanner does: the lockfile still contains the
367
- line `"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0"`. That is fulmine's declared
368
- range, not a resolution, and a scanner that reads what is declared rather than what was installed
369
- will report a git dependency that the install never used.
370
-
371
- ## Differences from Express
372
-
373
- What the two servers answer on the wire, probed from outside, malformed input and smuggling
374
- attempts included: [fulmine.js on http-probe.com](https://www.http-probe.com/servers/fulmine-js.html)
375
- against [express](https://www.http-probe.com/servers/express.html).
376
-
377
- - `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
378
- - `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
379
- - request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
380
- - **A request whose framing cannot be trusted is refused by hanging up, with no answer at all.** Node's parser refuses each of these with a `400` and Fulmine refuses the same ones: a repeated `Content-Length`; one that is not a plain count of bytes, an empty value or a count past `Number.MAX_SAFE_INTEGER` included; a `Transfer-Encoding` whose last coding is not `chunked`; and a method nobody defines, which includes a lowercase one, since methods are case sensitive. µWS accepts all of them. It frames the request on the first length, or on no body at all, and it takes any token as a method, so `{"a":1}GET /path HTTP/1.1` is a request line to it. What the client sent as a body is then read as the next request on the connection: that is request smuggling, and a proxy in front disagreeing about the framing is all it takes. The answer differs from Express because it cannot be helped. µWS only skips the request it has already queued when the response is closed rather than completed, and writing the `400` completes it, so the choice is between telling the client and stopping the smuggled request. Nothing well behaved sends any of these.
381
- - **A compiled route answers `connection: keep-alive` to a client that sent `Connection: close`.** A handler simple enough to be read at registration time is answered by µWS from a response written once at `listen()`, and that response cannot read the request. The socket still closes, so what is wrong is the header and not the transport. A response that would carry a validator is never compiled, so conditional requests behave as on Express; `app.set("declarative responses", false)` turns compiling off.
382
- - **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
383
- - For HTTPS, instead of doing this:
384
-
385
- ```js
386
- const https = require("https");
387
- const express = require("express");
388
-
389
- const app = express();
390
-
391
- https
392
- .createServer(
393
- {
394
- key: fs.readFileSync("path/to/key.pem"),
395
- cert: fs.readFileSync("path/to/cert.pem")
396
- },
397
- app
398
- )
399
- .listen(3000, () => {
400
- console.log("Server is running on port 3000");
401
- });
402
- ```
403
-
404
- You have to pass `uwsOptions` to the `express()` constructor:
405
-
406
55
  ```js
407
56
  const express = require("fulmine.js");
57
+ const app = express();
408
58
 
409
- const app = express({
410
- uwsOptions: {
411
- // https://unetworking.github.io/uWebSockets.js/generated/interfaces/AppOptions.html
412
- key_file_name: "path/to/key.pem",
413
- cert_file_name: "path/to/cert.pem"
414
- }
415
- });
416
-
417
- app.listen(3000, () => {
418
- console.log("Server is running on port 3000");
419
- });
420
- ```
421
-
422
- Runnable: [`examples/https.js`](./examples/https.js).
423
-
424
- - This also applies to non-SSL HTTP too. Use `app.listen()` rather than creating a server by hand. `http.createServer(app)` does work, because the app is a request listener like Express's and answers node's requests through a shim, which is what lets `supertest`, `vhost` and anything else that calls an app keep working. But it serves those requests through `node:http` rather than through µWS, so the speed is Express's. It is there for compatibility, not for production.
425
- - **Node 22, 24 and 26, not every version above 22.** µWebSockets.js ships one prebuilt binary per Node ABI and skips the odd lines, so Node 23 and 25 have no binary to load and fail at `require`. `npx fulmine.js verify` says which binary this machine wants and whether it is there. The odd/even model ends with Node 26, so the gap closes on its own.
426
- - Node.JS max header size is 16384 bytes, while uWebSockets by default is 4096 bytes, so if you need longer headers set the env variable `UWS_HTTP_MAX_HEADERS_SIZE` to max byte count you need.
427
- - uWebSockets drops a request whose body arrives slower than 16KB/s, and the timeout is not reachable from JavaScript, while Node.JS waits as long as the client needs. Uploads over very slow connections can therefore fail here and succeed on Express. A body stalled for 5 seconds still completes; one stalled for 12 seconds gets its socket reset at around 11.8 seconds.
428
-
429
- ## Performance tips
430
-
431
- Where the speed comes from, before the rules that govern it. Express finds a route by walking its
432
- stack and testing each layer against the path. Fulmine hands every route it can to µWS's own router,
433
- which matches in C++, and works out at `listen()` which layers stand in front of each one, so
434
- arriving at a handler costs no matching at all:
59
+ app.use(express.json());
60
+ app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
435
61
 
436
- ```text
437
- Express Fulmine
438
- GET /users/42 GET /users/42
439
- | |
440
- v v
441
- +--------------+ +------------------+
442
- | layer 1 | path? no | µWS router | one match, in C++,
443
- | layer 2 | path? no | /users/:id | against every path
444
- | ... | +--------+---------+ registered
445
- | layer 214 | path? yes -+ |
446
- +--------------+ | v
447
- a test per layer, | +------------------+
448
- every request | | the chain, known | the layers in front,
449
- | | since listen() | in order, no matching
450
- v +--------+---------+
451
- handler |
452
- v
453
- handler
62
+ app.listen(3000);
454
63
  ```
455
64
 
456
- That is the whole difference on a large route table: the scan grows with the table and the match
457
- does not, which is why a thousand routes measure 10x and a handful measure 3x.
458
-
459
- Two more things happen on the way in, and `npx fulmine.js profile` will tell you which of them your
460
- routes get:
461
-
462
- ```text
463
- a request arriving at a compiled route
464
-
465
- µWS match ──► the chain ──────────────────────────► handler ──► response
466
- | |
467
- | a body parser is stepped over | the Readable is not
468
- | when the request declared no | built unless something
469
- | body and the verb reads none | asks the body for one
470
- | |
471
- | the headers are not copied out | the two internal
472
- | of µWS when the analysis proved | listeners are written
473
- | nothing in the chain reads one | into the event map
474
- v v
475
- work that does not happen work that is not prepared
65
+ ESM and TypeScript work the same way, named imports included:
476
66
 
477
- and when the handler is simple enough to be read at registration time, none of the
478
- above happens either: µWS answers from a response written once, at startup
67
+ ```ts
68
+ import express, { Router, json } from "fulmine.js";
69
+ import type { Request, Response } from "fulmine.js";
479
70
  ```
480
71
 
481
- 1. Fulmine tries to optimize routing as much as possible, but it's only possible if:
482
-
483
- - the path is a plain string, or its parameters are whole segments: `/users/:id` and `/a/:b/c/:d` qualify, `/flights/:from-:to` does not, and neither does a `*splat` or a `{}` group. Routing is case-insensitive by default, as in Express; a request in the registered case is still served natively, any other case takes the ordinary path, and a route whose overlap with an earlier one leans on a cased literal goes the ordinary way for every request. That last one is worth knowing about: `app.set("case sensitive routing", true)` is Express's own setting, and with it `/Users/list` no longer overlaps `/users/:id`, so both are matched by µWS instead of one of them falling back.
484
- - inside a mounted router, nothing registered after the route in that router could match the same path. `/orders/:id`, `/orders/:id/items` and `/invoices/:id` are all optimized together, since no request reaches two of them. `/users/:id` followed by `/users/me` is not: Express answers `/users/me` with the first of the two and the native router would answer it with the second, so both go the ordinary way.
485
-
486
- Optimized routes can be up to 10 times faster than normal routes, as they're using native uWS router and have pre-calculated path.
487
-
488
- 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.
489
-
490
- Three things are refused whatever the handler does, and all three are the same fact: a response written at startup cannot read the request.
491
-
492
- - 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.
493
- - 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.
494
- - a `204`, `205` or `304`, since the body would go out with the status and a client frames those as bodiless whatever it reads.
495
-
496
- Two things then follow from the response being static:
72
+ Requirements: Node 22, 24 or 26, on Linux, macOS or Windows, x64 or arm64. Not Alpine (glibc 2.38+
73
+ is needed) and not Bun; pnpm refuses the install (`ERR_PNPM_EXOTIC_SUBDEP` on uWebSockets.js) until
74
+ [one command](./docs/deployment.md#pnpm) is run. `npx fulmine.js verify` tells
75
+ you in thirty seconds whether this machine, your package manager and your Docker image can run it,
76
+ and [Deploying](./docs/deployment.md) has the Dockerfile that works.
497
77
 
498
- - 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.
499
- - 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.
78
+ ## Migrate an existing Express app
500
79
 
501
- `app.set("declarative responses", false)` turns the whole thing off if you would rather have Express's exact framing than the speed.
502
-
503
- None of that is guesswork you have to do from the outside. `listen()` decides it all, and `npx fulmine.js profile` prints what it decided:
80
+ One command rewrites the imports across a whole project, and then tells you the handful of things
81
+ that behave differently:
504
82
 
505
83
  ```sh
506
- npx fulmine.js profile # the file "main" or the start script points at
507
- npx fulmine.js profile server.js # or name it
508
- ```
509
-
510
- ```text
511
- 7 route(s), 4 answered by µWS itself
512
-
513
- GET /api/health µWS /api/health (2 in front of it in its chain)
514
- GET /hello µWS /hello (compiled to a response, reads no query)
515
- GET /:anything router: something before it in the same router overlaps its paths
516
- GET /after-the-param router: the parameter route /:anything is written before it
517
- SEARCH /odd router: µWS does not serve SEARCH
518
-
519
- What this adds up to
520
-
521
- 4 of 7 route(s) matched by µWS in C++
522
- 1 answered from a response written at startup, running no javascript
523
- layers in front of a compiled handler: 1 at least, 2 at most, 1.8 on average
524
-
525
- Worth changing, if these are routes that carry traffic
526
-
527
- GET /after-the-param
528
- write it above /:anything. Express answers whichever matches first, so the order is
529
- already what decides, and with the literal first µWS can match it in C++ as well.
530
- ```
531
-
532
- It loads the application with `listen()` replaced by the half that compiles the routes, so nothing binds a port and the listen callback does not run: profiling a running service does not start a second copy of it. There is no score, on purpose. A percentage of routes is not a percentage of traffic, and an application with a thousand cold routes and one hot one that fell back would score well and serve badly.
533
-
534
- The same verdicts are readable from a test, which is where they belong for the routes that carry the traffic. A route stays on the fast path only while it stays eligible, and nothing complains when it stops: the answer is still correct, only slower, and the commit that did it is found weeks later.
535
-
536
- ```js
537
- const { expectNative, expectDeclarative, routeReport } = require("fulmine.js").testing;
538
-
539
- expectNative(app, ["/api/*", "GET /health"]); // throws, naming the route and the reason
540
- expectDeclarative(app, "/health"); // the step past native: no javascript at all
541
- routeReport(app); // the whole list, to assert on however you like
542
- ```
543
-
544
- A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening. Runnable: [`examples/fast-routes.js`](./examples/fast-routes.js).
545
-
546
- A route can stay native and still slow down request by request, because most of what makes this fast is work that does not happen: the request is not turned into a `Readable`, the response is not turned into a `Writable`, `req.headers` is not folded into an object, the query is not parsed, no socket stand-in is allocated. A middleware that reads `req.headers.host` puts one of those back on every request, and nothing fails. `expectLazy` is the assertion for that half, asked from inside a handler:
547
-
548
- ```js
549
- const { workReport, expectLazy } = require("fulmine.js").testing;
550
-
551
- app.post("/items", (req, res) => {
552
- expectLazy(req, res, { allow: ["body"] }); // throws naming what else was built
553
- workReport(req, res); // { native, declarative, headers, query, body, requestStream, ... }
554
- res.json(req.body);
555
- });
556
- ```
557
-
558
- Asking costs a property read: every field is state the framework already keeps, nothing is counted or wrapped to make it readable.
559
-
560
- `npx fulmine.js explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
561
-
562
- ```text
563
- GET /api/items/:id
564
-
565
- route native (µWS matched /api/items/:x and dispatched by method)
566
- headers copied out of µWS (something in the chain reads them)
567
- query parsed when something asks for it
568
- chain 2 layer(s), 1 mounted layer(s) in front of it
569
- logger readable at registration, reads the query
570
- (anonymous) readable at registration
571
- body read for POST, PUT, PATCH and QUERY, when one is declared
572
- ```
573
-
574
- The same verdict reaches the browser, per request, with `express.serverTiming()`:
575
-
576
- ```text
577
- Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
578
- ```
579
-
580
- `route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine.js profile` is where those are counted. The same middleware writes `work;desc="headers, query"` when the request built something a fast one does not, the fields `expectLazy` checks, and writes nothing when it built none of them. `serverTiming({ work: false })` turns that off. Runnable: [`examples/server-timing.js`](./examples/server-timing.js).
581
-
582
- 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag. Runnable: [`examples/static-precompressed.js`](./examples/static-precompressed.js).
583
-
584
- 3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
585
-
586
- 4. Do not use the `compression` module. `express.compression()` takes the same options and decides the same way, and it served about 50% more requests per second on an 8KB JSON body here, gzip and brotli alike. A response that arrives whole, which is every `res.send()` and `res.json()`, is compressed in one call rather than through a transform stream and goes out with a `Content-Length` instead of chunked; a response written in pieces still streams. The bytes are the same bytes either way.
587
-
588
- ```js
589
- // the compression module's options, unchanged: threshold, filter, level, brotli, enforceEncoding
590
- app.use(express.compression({ threshold: 1024 }));
591
- ```
592
-
593
- One option is Fulmine's own, `encodings`: the list of what the middleware may answer with, out of `"br"`, `"gzip"` and `"deflate"`. What is not named is never used, however the client ranks it, and an uncompressed answer is always on offer. It exists because the preferred encoding is a cost decision, not only a size one: brotli compresses smaller but what it costs per response depends on the machine, and on a CPU where it runs expensive `encodings: ["gzip"]` buys the cheaper call for every client that accepts both.
594
-
595
- ```js
596
- // answer gzip even to a client that also accepts br
597
- app.use(express.compression({ level: 1, encodings: ["gzip"] }));
598
- ```
599
-
600
- Runnable: [`examples/compression.js`](./examples/compression.js).
601
-
602
- 5. If a route answers with a JSON shape you know in advance, [express-fast-json-stringify](https://www.npmjs.com/package/express-fast-json-stringify) compiles that shape into a serializer and `res.fastJson()` replaces `res.json()`. `JSON.stringify()` has to walk an object it knows nothing about; a compiled serializer does not. It is worth reaching for, and a CPU profile says why: on a route answering 3.6KB of JSON, serialising it is about 25% of the time that is not spent waiting, ahead of the ETag at 19% and of everything the framework does to route the request and build its request and response objects.
603
-
604
- 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.
605
-
606
- 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.
607
-
608
- 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.
609
-
610
- 9. One node process uses one core, and this is the setting that changes it. `express({ cluster: "auto" })` forks one process per core and each of them binds the same port with µWS's shared flag, which is `SO_REUSEPORT`: every process has its own listening socket and the kernel decides which one gets each connection. Node's own `cluster` cannot do that with an `http.Server`, so the primary holds the socket and passes each accepted connection to a worker over IPC; here the primary is not in the path at all. On a 16-core machine that is close to 16 times the throughput, and no other setting comes near it.
611
-
612
- ```js
613
- // "auto" is one worker per usable core: the cgroup quota is read first, so a 2-core container
614
- // on a 64-core host forks 2 and not 64. A number instead of "auto" says how many.
615
- const app = express({ cluster: "auto" });
616
-
617
- app.get("/", (req, res) => res.send("hello"));
618
-
619
- // The whole file runs again in every worker, which is how cluster works: the code above this
620
- // line runs once per process. The primary only forks, so the callback runs once per worker too,
621
- // and a worker that dies is replaced.
622
- app.listen(3000, () => console.log(`worker ${process.pid} listening`));
623
- ```
624
-
625
- Anything held per process is now held per worker: an in-memory cache, a rate-limit counter, a session store or a `Map` of connected sockets is not shared, and needs Redis or something like it to be. `app.close()` in the primary stops the workers, and a `SIGTERM` or `SIGINT` that reaches only the primary, which is what a container sends, is passed on to them. Runnable: [`examples/cluster.js`](./examples/cluster.js).
626
-
627
- 10. `app.set("connection headers", false)` stops `Connection: keep-alive` and `Keep-Alive: timeout=10` going out on every response. Express sends both, so Fulmine sends both by default. An HTTP/1.1 connection stays open without being told, so to an HTTP/1.1 client the two headers say nothing it does not know already, and they cost 46 bytes and two header writes per response. A request that asked for `Connection: close` still gets `Connection: close`, and the connection is closed. Turn it off for an API behind a proxy or serving HTTP/1.1 clients; keep the default where a client or a proxy relies on the header to keep the connection open. Worth 2% to 3.5% here on a route that is not compiled, plus the bytes.
628
-
629
- ## WebSockets
630
-
631
- `app.ws()` registers a WebSocket route, served by µWS itself. The upgrade never reaches node, so `server.on("upgrade")` and the libraries built on it have nothing to hear; this is the replacement.
632
-
633
- ```js
634
- app.ws("/room/:id", {
635
- upgrade(req, res) {
636
- // runs before the handshake, with a real request and response.
637
- // Answering the response declines the socket:
638
- if (!req.query.token) return res.sendStatus(401);
639
- // and anything left on the request is there for the socket's whole life:
640
- req.room = req.params.id;
641
- },
642
- open(ws) {
643
- ws.subscribe(ws.req.room);
644
- },
645
- message(ws, message, isBinary) {
646
- ws.publish(ws.req.room, message, isBinary);
647
- },
648
- close(ws, code, message) {}
649
- });
84
+ npx fulmine.js verify # can this machine and this image even run it
85
+ npx fulmine.js migrate --dry-run # say what it would change, change nothing
86
+ npx fulmine.js migrate # do it
87
+ npx fulmine.js override # when a framework requires express in its own code, not in yours
88
+ npx fulmine.js angular # angular.json's server build, one line of config
89
+ npx fulmine.js pnpm # the two lines a pnpm project needs before it installs this
90
+ npx fulmine.js differences # just the list of what to check by hand
650
91
  ```
651
92
 
652
- - **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.
653
- - **`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.
654
- - **`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`.
655
- - **A hook that awaits can be left holding a dead request.** The client may go while a token is being checked, and µWS frees the response when it does, so `res.aborted` says whether there is still anybody to answer. Writing to a response that was aborted does nothing rather than throwing.
656
- - **Routers work.** `router.ws("/lobby", ...)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
657
- - **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.
658
- - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
93
+ NestJS is one import, `FulmineExpressAdapter` from `fulmine.js/nest`. Angular SSR's `server.ts` is
94
+ an ordinary Express application and takes the one-line change. A framework that requires Express in
95
+ its own code, not in yours, is answered with a package manager override, and `override` writes it.
96
+ The whole guide: [Migrating](./docs/migrating.md).
659
97
 
660
- A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing. Runnable, with a page that opens the socket: [`examples/websocket.js`](./examples/websocket.js).
98
+ ## Where the speed comes from
661
99
 
662
- If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/dimdenGD/ultimate-ws) is a drop-in replacement for it written against Ultimate Express, and Fulmine still exposes the mechanism it hooks into, but that combination is not covered by this project's tests. `app.uwsApp` also remains available for anything µWS offers that this does not.
100
+ Express finds a route by walking its stack and testing each layer against the path, on every request.
101
+ Fulmine hands every route it can to µWS's router, which matches in C++, and works out at `listen()`
102
+ which middlewares stand in front of each one. Arriving at a handler costs no matching at all, and the
103
+ gap grows with the route table instead of shrinking.
663
104
 
664
- ### socket.io
105
+ On top of that, most of what makes a request expensive is work that simply does not happen: the body
106
+ is not read unless a handler asks, the headers are not copied out of µWS unless something reads them,
107
+ the request is not turned into a stream unless something streams it. A handler simple enough to be
108
+ read at registration time is compiled into a static response and answered by µWS itself.
665
109
 
666
- socket.io normally takes over the upgrade on a node `http.Server`. The upgrade here never reaches
667
- node, so hand it the µWS app instead, which socket.io supports natively through `attachApp()`:
110
+ `npx fulmine.js profile` prints what `listen()` decided about each of your routes, and
111
+ `npx fulmine.js explain /api/items` tells the story of one request. Ten measured tips, from
112
+ `express.compression()` to `cluster: "auto"`, are in [Performance](./docs/performance.md).
668
113
 
669
- ```js
670
- const express = require("fulmine.js");
671
- const { Server } = require("socket.io");
114
+ ## Beyond Express
672
115
 
673
- const app = express();
674
- const io = new Server();
116
+ Everything Express does, plus these. Each one is a runnable file in [`examples/`](./examples/README.md).
675
117
 
676
- app.listen(3000);
677
- io.attachApp(app.uwsApp);
118
+ | Feature | One line |
119
+ | --------------------------------------------------------------------- | ------------------------------------------------------------ |
120
+ | One process per core, one port, no primary in the path | `express({ cluster: "auto" })` |
121
+ | Native WebSockets, with an `upgrade(req, res)` hook for auth | `app.ws("/room/:id", { open, message, close })` |
122
+ | socket.io | `io.attachApp(app.uwsApp)` |
123
+ | Compression built in, 50% more requests per second than `compression` | `app.use(express.compression())` |
124
+ | Serve the `.br` and `.gz` twins your build already wrote | `express.static(dir, { preCompressed: true })` |
125
+ | Server-Timing with how the request was routed | `app.use(express.serverTiming())` |
126
+ | Assert in a test that a route stays on the fast path | `express.testing.expectNative(app, ["/api/*"])` |
127
+ | HTTPS without `https.createServer` | `express({ uwsOptions: { key_file_name, cert_file_name } })` |
128
+ | PROXY protocol from HAProxy, AWS NLB, nginx, Envoy | `app.set("trust proxy protocol", true)` |
678
129
 
679
- io.on("connection", (socket) => {
680
- socket.on("message", (data) => socket.emit("reply", data));
681
- });
682
- ```
130
+ Details in [WebSockets](./docs/websockets.md), [Performance](./docs/performance.md) and
131
+ [Deploying](./docs/deployment.md).
683
132
 
684
- `attachApp()` works before or after `app.listen()`. What does not work is `new Server(app)` on the
685
- app itself, or on what `app.listen()` returns, which is the same object: socket.io refuses it with
686
- "You are trying to attach socket.io to an express request handler function", because it checks for a
687
- function before it checks for a server, and an app here is callable. That refusal is the useful
688
- answer. Even if it accepted the object, there is no node socket behind it to take an upgrade over,
689
- so it would have failed later and more quietly. Plain HTTP keeps serving either way. This is covered
690
- by `tests/tests/middlewares/socket-io.js`, which runs the same file against Express and against
691
- Fulmine and compares the output. Runnable: [`examples/socket-io.js`](./examples/socket-io.js).
692
-
693
- ## HTTP/3
694
-
695
- There is an `http3: true` option, inherited from Ultimate Express, that asks µWebSockets.js for its experimental HTTP/3 app. **It is guarded off with the currently pinned µWS build**: asking for it throws a clear error, because the underlying `H3App` segfaults during construction on Linux, verified with µWS alone before a single request is served. On Windows the listener does come up, but nothing answers over QUIC that we could verify, and shipping an option that works on no deployable platform helps nobody. A skipped canary test probes `H3App` on every CI run and will turn red the day µWS ships working QUIC in its prebuilt binaries, which is when the guard goes and this section changes.
696
-
697
- ```js
698
- // what it would look like, once µWS's H3 support actually works
699
- const app = express({
700
- http3: true,
701
- uwsOptions: {
702
- key_file_name: "/path/to/example.key",
703
- cert_file_name: "/path/to/example.crt"
704
- }
705
- });
706
- ```
133
+ ## Compatibility
707
134
 
708
- ## Behind a proxy
135
+ Use the [Express 5 documentation](https://expressjs.com/en/5x/api.html) as the reference: the
136
+ application, request, response and router APIs are all there, settings included. The full checklist,
137
+ the tested middlewares, frameworks and view engines, and the eight settings Fulmine adds, are in
138
+ [Compatibility](./docs/compatibility.md).
709
139
 
710
- `trust proxy` works as it does in Express: set it and `req.ip`, `req.ips`, `req.protocol` and
711
- `req.hostname` are read from `X-Forwarded-*` when the connection comes from a peer you trust.
140
+ A few things answer differently because there is no `node:http` underneath: `app.listen()` returns
141
+ the app, which also answers as an `http.Server`; TLS is configured through `express()`; the body is
142
+ read for POST, PUT, PATCH and QUERY unless told otherwise; `x-powered-by` is off. The complete list,
143
+ with the reason for each: [Differences from Express](./docs/differences.md).
712
144
 
713
- Fulmine adds the other way of being told, the one that does not use headers at all. HAProxy, AWS
714
- NLB, nginx with `proxy_protocol` and Envoy can prepend a **PROXY protocol** preamble to the
715
- connection, and µWebSockets.js parses it. Off by default, and one line turns it on:
145
+ ## Compared with similar projects
716
146
 
717
- ```js
718
- app.set("trust proxy protocol", true);
719
- // req.ip, req.socket.remoteAddress and everything reading them are now the address the proxy
720
- // declared, and fall back to the socket's own on a connection that sent no preamble
721
- ```
147
+ - **`ultimate-express`** is what Fulmine is derived from, and is the closest relative by far. It targets Express 4, keeps the v4 API surface and its deprecations. Fulmine targets Express 5 only, which removes the compatibility layer for everything v5 dropped, and is typed. If you are on Express 4, use `ultimate-express`.
148
+ - **`hyper-express`** has a similar API but is not a drop-in replacement. It implements much of the functionality differently, which produces quirks that make switching an existing application difficult, and most Express middleware is unsupported.
149
+ - **`uwebsockets-express`** is closer to a drop-in replacement, but misses a lot of the API, depends on Express by calling its methods under the hood, and does not use the native µWS router.
150
+ - **`express` on Bun** benefits from Bun using µWS for its HTTP module, but performs no µWS-specific optimizations.
722
151
 
723
- > [!WARNING]
724
- > **Only turn this on when nothing but the proxy can reach the server.** µWS reads the preamble
725
- > from whoever sends it. There is no way to say which peers may use it, so on a port open to the
726
- > internet the first sixteen bytes of any connection are enough for a client to become `10.0.0.1`
727
- > for your rate limiter, your allow list and your audit log. Bind to the private interface, or
728
- > keep this off.
152
+ ## Documentation
729
153
 
730
- `trust proxy` and this can both be on. The preamble decides what the connection's address is, and
731
- `trust proxy` then peels `X-Forwarded-For` off that, so a proxy that sends both is read the way it
732
- meant. It is the binary v2 preamble that µWS reads, not the v1 text line, so a connection starting
733
- with `PROXY TCP4 ...` is answered as a malformed request. Runnable, with a client that writes one:
734
- [`examples/proxy-protocol.js`](./examples/proxy-protocol.js).
154
+ - [Why Fulmine](./docs/why.md): what it is, what it costs, who is behind it
155
+ - [Migrating](./docs/migrating.md): the CLI, Angular SSR, NestJS, and when Express is somebody else's dependency
156
+ - [Deploying](./docs/deployment.md): Docker, pnpm, a private npm registry, behind a proxy
157
+ - [Performance](./docs/performance.md): the numbers, the tips, `profile`, `explain` and the testing helpers
158
+ - [Differences from Express](./docs/differences.md): what answers differently and why
159
+ - [WebSockets](./docs/websockets.md): `app.ws()` and socket.io
160
+ - [Compatibility](./docs/compatibility.md): the API checklist, tested middlewares, frameworks and view engines
161
+ - [Compared with the others](./docs/compare.md): ultimate-express, hyper-express, Fastify, Bun, raw µWS
162
+ - [Examples](./examples/README.md): one runnable file per feature
163
+ - [Attribution](./docs/attribution.md), [Contributing](./CONTRIBUTING.md), [Security](./SECURITY.md), [Changelog](./CHANGELOG.md)
735
164
 
736
165
  ## Versioning
737
166
 
738
167
  **The major number tracks Express, not semver.** Fulmine 5.x follows Express 5. If Express 6
739
- arrives, Fulmine goes to 6, and that is the only reason the major ever moves.
740
-
741
- Read the rest of the number normally: minor for new behaviour, patch for fixes.
742
-
743
- What this costs you: a breaking change can land in a minor. It will be in the changelog under its
744
- own heading, because commits still mark breaking changes the usual way, but the version number
745
- alone will not warn you. If you pin, pin the minor.
746
-
747
- ## Compatibility
748
-
749
- In general, basically all features and options are supported. Use the [Express 5.x documentation](https://expressjs.com/en/5x/api.html) for API reference. Anything Express 5 removed is removed here too, so the list below covers only where this differs from Express 5 itself.
750
-
751
- ✅ - Full support (all features and options are supported)
752
- 🚧 - Partial support (some options are not supported)
753
- ❌ - Not supported
754
-
755
- ### express
756
-
757
- - ✅ express()
758
- - ✅ express.Router()
759
- - ✅ express.json()
760
- - ✅ express.urlencoded()
761
- - ✅ express.static()
762
- - - ✅ options.index, options.redirect, options.fallthrough, options.extensions
763
- - - ✅ options.dotfiles, plus `"ignore_files"`, which is Fulmine's own: it hides a dotfile that is the last segment while letting a dotted directory through
764
- - - ✅ options.setHeaders, options.headers
765
- - - ✅ options.etag, options.lastModified, options.maxAge, options.immutable, options.cacheControl, options.acceptRanges
766
- - - ✅ options.preCompressed, Fulmine's own: serve the `.br` or `.gz` twin on disk, described under [Performance tips](#performance-tips)
767
- - ✅ express.text()
768
- - ✅ express.raw()
769
- - ✅ express.serverTiming(). Fulmine's own: Server-Timing carrying how the request was routed, described under [Performance tips](#performance-tips).
770
- - ✅ express.testing. Fulmine's own: `expectNative`, `expectDeclarative`, `routeReport`, `expectLazy` and `workReport`, described under [Performance tips](#performance-tips).
771
- - ✅ express.compression(). Fulmine's own, since Express has none: it is the [compression](https://npmjs.com/package/compression) module's options and behaviour built in, described under [Performance tips](#performance-tips).
772
- - 🚧 express.request (this is not a constructor but a prototype for replacing methods)
773
- - 🚧 express.response (this is not a constructor but a prototype for replacing methods)
774
- - 🚧 express.application (likewise: a method added here is on every app)
775
- - ✅ express.Route. Both `app.route("/path").get(...).post(...)` and the class itself, for building a route by hand and dispatching to it.
776
-
777
- ### Application
778
-
779
- - ✅ app.listen(port[, host][, callback])
780
- - ✅ app.listen(unix_socket[, callback])
781
- - ✅ app.METHOD() (app.get, app.post, etc.)
782
- - ✅ app.route()
783
- - ✅ app.all()
784
- - ✅ app.use()
785
- - ✅ app.mountpath
786
- - ✅ app.set()
787
- - ✅ app.get()
788
- - ✅ app.enable()
789
- - ✅ app.disable()
790
- - ✅ app.enabled()
791
- - ✅ app.disabled()
792
- - ✅ app.path()
793
- - ✅ app.param(name, callback)
794
- - ✅ app.engine()
795
- - ✅ app.render()
796
- - ✅ app.locals
797
- - ✅ app.settings
798
- - ✅ app.engines
799
- - ✅ app.on("mount")
800
- - ✅ HEAD method
801
- - ✅ OPTIONS method
802
- - ✅ QUERY method
803
-
804
- What `listen()` hands back is the app, and it answers as an `http.Server` so the shutdown wrappers
805
- recognise it: `app.close()`, `app.address()`, `app.listening`, `app.getConnections()`, `app.ref()`,
806
- `app.unref()`, `app.setTimeout()` and the `keepAliveTimeout` family. See
807
- [Differences from Express](#differences-from-express) for what is behind them and what is not.
808
-
809
- ### Application settings
810
-
811
- - ✅ case sensitive routing
812
- - ✅ env
813
- - ✅ etag
814
- - ✅ jsonp callback name
815
- - ✅ json escape
816
- - ✅ json replacer
817
- - ✅ json spaces
818
- - ✅ query parser
819
- - ✅ strict routing
820
- - ✅ subdomain offset
821
- - ✅ trust proxy
822
- - ✅ views
823
- - ✅ view cache
824
- - ✅ view engine
825
- - ✅ x-powered-by
826
-
827
- Two of these keep a compiled form alongside the value, which you can also set directly:
828
-
829
- - `etag fn`, the function that produces an ETag. Setting `etag` compiles one; setting this replaces it.
830
- - `query parser fn`, likewise for `query parser`.
831
-
832
- Fulmine adds eight of its own:
833
-
834
- - `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.
835
- - `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.
836
- - `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.
837
- - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
838
- - `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.
839
- - `file cache`, on by default. Small files served by `res.sendFile` come from a bounded in-process cache, checked against the file's `stat` on every request, so an edited file is never served stale. Turn it off where every request has to reach the disk, which is what a public benchmark asks of a standard entry: it was worth about 4% on a 4KB file here, so the cost of turning it off is small.
840
- - `stat cache`, off by default. Takes a duration, `app.set("stat cache", "1s")`. The size and mtime of a file served by `res.sendFile` or `express.static` are remembered for that long, so a file that is asked for again inside the window costs no syscall at all. It was worth 15% on a 3KB file and 3% on a 200KB one, where the bytes are the work. What it costs is the one promise the `file cache` keeps: inside the window an edited file is served as it was, so keep the window shorter than you would notice.
841
- - `trust proxy protocol`, off by default. Takes `req.ip` from a PROXY protocol preamble, described under [Behind a proxy](#behind-a-proxy). Read the warning there before turning it on.
842
-
843
- ### Request
844
-
845
- - ✅ implements Readable stream
846
- - ✅ req.app
847
- - ✅ req.baseUrl
848
- - ✅ req.body
849
- - ✅ req.cookies
850
- - ✅ req.fresh
851
- - ✅ req.hostname
852
- - ✅ req.header
853
- - ✅ req.headers
854
- - ✅ req.headersDistinct
855
- - ✅ req.rawHeaders
856
- - ✅ req.ip
857
- - ✅ req.ips
858
- - ✅ req.method
859
- - ✅ req.url
860
- - ✅ req.originalUrl
861
- - ✅ req.params
862
- - ✅ req.path
863
- - ✅ req.protocol
864
- - ✅ req.query
865
- - ✅ req.res
866
- - ✅ req.secure
867
- - ✅ req.signedCookies
868
- - ✅ req.stale
869
- - ✅ req.subdomains
870
- - ✅ req.xhr
871
- - 🚧 req.route (route implementation is different from Express)
872
- - 🚧 req.connection, req.socket (only `end()`, `encrypted`, `remoteAddress`, `remotePort` and `localPort` are supported)
873
- - ✅ req.accepts()
874
- - ✅ req.acceptsCharsets()
875
- - ✅ req.acceptsEncodings()
876
- - ✅ req.acceptsLanguages()
877
- - ✅ req.get()
878
- - ✅ req.is()
879
- - ✅ req.range()
880
-
881
- ### Response
882
-
883
- - ✅ implements Writable stream
884
- - ✅ res.app
885
- - ✅ res.headersSent
886
- - ✅ res.req
887
- - ✅ res.locals
888
- - ✅ res.append()
889
- - ✅ res.attachment()
890
- - ✅ res.cookie()
891
- - ✅ res.clearCookie()
892
- - ✅ res.download()
893
- - ✅ res.end()
894
- - ✅ res.format()
895
- - ✅ res.getHeader(), res.get()
896
- - ✅ res.json()
897
- - ✅ res.jsonp()
898
- - ✅ res.links()
899
- - ✅ res.location()
900
- - ✅ res.redirect()
901
- - ✅ res.render()
902
- - ✅ res.send()
903
- - ✅ res.sendFile()
904
- - - ✅ options.maxAge
905
- - - ✅ options.root
906
- - - ✅ options.lastModified
907
- - - ✅ options.headers
908
- - - ✅ options.dotfiles
909
- - - ✅ options.acceptRanges
910
- - - ✅ options.cacheControl
911
- - - ✅ options.immutable
912
- - - ✅ Range header
913
- - - ✅ Setting ETag header
914
- - - ✅ If-Match header
915
- - - ✅ If-Modified-Since header
916
- - - ✅ If-Unmodified-Since header
917
- - - ✅ If-Range header
918
- - ✅ res.sendStatus()
919
- - ✅ res.header(), res.setHeader(), res.set()
920
- - ✅ res.status()
921
- - ✅ res.type()
922
- - ✅ res.vary()
923
- - ✅ res.removeHeader()
924
- - ✅ res.write()
925
- - ✅ res.writeHead()
926
- - ✅ res.flushHeaders()
927
-
928
- ### Router
929
-
930
- - ✅ router.all()
931
- - ✅ router.METHOD() (router.get, router.post, etc.)
932
- - ✅ router.route()
933
- - ✅ router.use()
934
- - ✅ router.param(name, callback)
935
- - ✅ options.caseSensitive
936
- - ✅ options.strict
937
- - ✅ options.mergeParams
938
-
939
- ## Tested middlewares
940
-
941
- Almost all middlewares that are compatible with Express are compatible with Fulmine. Here's list of middlewares that we test for compatibility:
942
-
943
- - ✅ [express-fast-json-stringify](https://npmjs.com/package/express-fast-json-stringify)
944
- - ✅ [socket.io](https://npmjs.com/package/socket.io) (via `io.attachApp(app.uwsApp)`, see WebSockets above)
945
- - ✅ [body-parser](https://npmjs.com/package/body-parser) (use `express.text()` etc instead for better performance)
946
- - ✅ [cookie-parser](https://npmjs.com/package/cookie-parser)
947
- - ✅ [cookie-session](https://npmjs.com/package/cookie-session)
948
- - ✅ [compression](https://npmjs.com/package/compression) (use `express.compression()` instead for better performance)
949
- - ✅ [serve-static](https://npmjs.com/package/serve-static) (use `express.static()` instead for better performance)
950
- - ✅ [serve-index](https://npmjs.com/package/serve-index)
951
- - ✅ [cors](https://npmjs.com/package/cors)
952
- - ✅ [errorhandler](https://npmjs.com/package/errorhandler)
953
- - ✅ [method-override](https://npmjs.com/package/method-override)
954
- - ✅ [multer](https://npmjs.com/package/multer)
955
- - ✅ [response-time](https://npmjs.com/package/response-time)
956
- - ✅ [express-fileupload](https://npmjs.com/package/express-fileupload)
957
- - ✅ [express-session](https://npmjs.com/package/express-session)
958
- - ✅ [express-rate-limit](https://npmjs.com/package/express-rate-limit)
959
- - ✅ [express-subdomain](https://npmjs.com/package/express-subdomain)
960
- - ✅ [vhost](https://npmjs.com/package/vhost)
961
- - ✅ [http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware)
962
- - ✅ [express-http-proxy](https://www.npmjs.com/package/express-http-proxy)
963
- - ✅ [express-mongo-sanitize](https://www.npmjs.com/package/express-mongo-sanitize)
964
- - ✅ [helmet](https://www.npmjs.com/package/helmet)
965
- - ✅ [passport](https://www.npmjs.com/package/passport)
966
- - ✅ [morgan](https://www.npmjs.com/package/morgan)
967
- - ✅ [swagger-ui-express](https://www.npmjs.com/package/swagger-ui-express)
968
- - ✅ [graphql-http](https://www.npmjs.com/package/graphql-http)
969
- - ✅ [better-sse](https://www.npmjs.com/package/better-sse)
970
- - ✅ [supertest](https://www.npmjs.com/package/supertest)
971
-
972
- [tsoa](https://github.com/lukeautry/tsoa) works too, but it is not in the suite above: it resolves
973
- `express` itself, so testing it here needs a dependency override rather than the one-line swap
974
- everything else takes.
975
-
976
- ## Tested frameworks
977
-
978
- The list above is middlewares. A framework built on Express is a much larger user of the Express
979
- surface than any application is, so those have a suite of their own, in
980
- [`integrations/`](./integrations): the same application served twice, once on Express and once here,
981
- with the two outputs compared byte for byte. The four that render pages are built first, by that
982
- suite, so what is compared is what their own build produces.
983
-
984
- - ✅ [NestJS](https://nestjs.com) through [`fulmine.js/nest`](#nestjs)
985
- - ✅ [Next.js](https://nextjs.org) as a custom server, `next().getRequestHandler()`
986
- - ✅ [Astro](https://astro.build) through `@astrojs/node` in middleware mode
987
- - ✅ [SvelteKit](https://svelte.dev/docs/kit) through `@sveltejs/adapter-node`
988
- - ✅ [React Router v7](https://reactrouter.com) through `@react-router/express`
989
- - ✅ [Apollo Server](https://www.apollographql.com/docs/apollo-server) through
990
- [`@as-integrations/express5`](https://www.npmjs.com/package/@as-integrations/express5)
991
- - ✅ [tRPC](https://trpc.io) through `@trpc/server/adapters/express`
992
- - ✅ [MCP](https://modelcontextprotocol.io) through
993
- [`@modelcontextprotocol/sdk`](https://www.npmjs.com/package/@modelcontextprotocol/sdk) on the
994
- Streamable HTTP transport, with the body read off the stream or handed over by `express.json()`.
995
- Runnable: [`examples/mcp.js`](./examples/mcp.js)
996
- - ✅ [Angular SSR](#angular-ssr), which is an ordinary Express `server.ts` plus one line of build
997
- configuration
998
-
999
- Each of these mounts on an ordinary Express application, so there is nothing to install and nothing
1000
- to configure beyond what that framework already asks for. Nest is the exception, and only because
1001
- its adapter decides what to listen on: that one is [`fulmine.js/nest`](#nestjs).
1002
-
1003
- ## Tested view engines
1004
-
1005
- Any Express view engine should work. Here's list of engines we include in our test suite:
1006
-
1007
- - ✅ [ejs](https://npmjs.com/package/ejs)
1008
- - ✅ [pug](https://npmjs.com/package/pug)
1009
- - ✅ [express-dot-engine](https://npmjs.com/package/express-dot-engine)
1010
- - ✅ [express-art-template](https://npmjs.com/package/express-art-template)
1011
- - ✅ [express-handlebars](https://npmjs.com/package/express-handlebars)
1012
- - ✅ [swig](https://npmjs.com/package/swig)
1013
-
1014
- ## Examples
1015
-
1016
- [`examples/`](./examples/README.md) has one runnable file per thing this does that Express does not:
1017
- the cluster option, `app.ws()`, socket.io through `attachApp`, an MCP server on the official SDK,
1018
- the pre-compressed twins, `express.compression()`, `express.serverTiming()`, TLS through
1019
- `uwsOptions`, the PROXY protocol, what `listen()` decided about each route, and the app answering as
1020
- an `http.Server`. What an Express application already does is documented by Express and is not
1021
- repeated there.
1022
-
1023
- ```sh
1024
- cd examples
1025
- npm install
1026
- node websocket.js
1027
- ```
1028
-
1029
- ## Attribution
1030
-
1031
- Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
1032
-
1033
- **Special thanks to [@dimdenGD](https://github.com/dimdenGD).** Ultimate Express is the hard part of this project, and it was already done before Fulmine existed. Everything here stands on that work.
1034
-
1035
- Fulmine is not affiliated with, endorsed by, or maintained by the authors of Ultimate Express. See [`NOTICE`](./NOTICE) for the list of significant changes.
1036
-
1037
- It is likewise not affiliated with the OpenJS Foundation or the Express.js project. Express is a trademark of the OpenJS Foundation.
1038
-
1039
- ## Working on Fulmine
168
+ arrives, Fulmine goes to 6, and that is the only reason the major ever moves. Minor is for new
169
+ behaviour, patch for fixes, so a breaking change can land in a minor: it is in the changelog under
170
+ its own heading, but the version number alone will not warn you. If you pin, pin the minor.
1040
171
 
1041
- How to run the suites, what each of them is for, and how to write a comparison test:
1042
- [`CONTRIBUTING.md`](./CONTRIBUTING.md). What is expected of everyone taking part:
1043
- [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md).
172
+ ## License
1044
173
 
1045
- Found something exploitable? Report it privately rather than in an issue, and see
1046
- [`SECURITY.md`](./SECURITY.md) for what is in scope and what to expect.
174
+ [Apache-2.0](./LICENSE), with the credits and the other licences in
175
+ [Attribution](./docs/attribution.md). Found something exploitable? Report it privately, see
176
+ [`SECURITY.md`](./SECURITY.md).