Fulmine.js
August 31, 2026 · View on GitHub
Fulmine.js
Fulmine - means lightning ⚡ in Italian - is a blazing-fast drop-in replacement for Express 5, running on µWebSockets.js instead of node:http. Your existing middleware keeps working.
const express = require("fulmine.js"); // instead of require("express")
ESM and TypeScript work the same way, named imports included:
import express, { Router, json } from "fulmine.js";
import type { Request, Response } from "fulmine.js";
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:
npx fulmine.js verify # can this machine and this image even run it
npx fulmine.js migrate --dry-run # say what it would change, change nothing
npx fulmine.js migrate # do it
npx fulmine.js override # when a framework requires express in its own code, not in yours
npx fulmine.js angular # angular.json's server build, which esbuild would otherwise inline
npx fulmine.js differences # just the list of what to check by hand
npx fulmine.js profile # what listen() decided about each route
npx fulmine.js explain /api/items # what happens when a request for that route arrives
See Migrating for what it handles and what it deliberately does not.
Table of contents
- Why this exists
- Performance
- Public benchmarks
- Difference from similar projects
- Migrating
- Docker
- Behind a private registry
- Differences from Express
- Performance tips
- WebSockets
- HTTP/3
- Behind a proxy
- Versioning
- Compatibility
- Tested middlewares
- Tested frameworks
- Tested view engines
- Examples
- Attribution
- Working on Fulmine
Why this exists
There are several fast HTTP servers for Node built on µWebSockets.js. What is scarce is one you can actually drop into an existing Express application without rewriting it.
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.
It started as a fork of Ultimate Express, which is where the hard part was already done. See Attribution.
Performance
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.
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.
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.
Two things worth knowing before comparing numbers with anyone:
- 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. - 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.
There is no table here on purpose. CI runs the whole benchmark on every push and every pull request
and posts the result where it belongs: as a comment on the commit or the pull request, and as a
benchmark-summary artifact on the run, see benchmark/README.md
to run it yourself.
Public benchmarks
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:
- HttpArena: 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.
- web-frameworks: in the published round, ranked with the other sixty-odd JavaScript entries on their own hardware. Same rule as above, no figures copied here.
More to come as their maintainers take the entries in.
Difference from similar projects
ultimate-expressis 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, useultimate-express.hyper-expresshas 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.uwebsockets-expressis 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.expresson Bun benefits from Bun using µWS for its HTTP module, but performs no µWS-specific optimizations.
Migrating
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:
npx fulmine.js migrate [dir] # defaults to the current directory
npx fulmine.js migrate --dry-run # say what it would rewrite and rewrite nothing
npx fulmine.js differences # print the list below and change nothing
It also names the middlewares it found that have a faster one built in here, compression,
body-parser and serve-static, and leaves them to you: the replacement is reached through the
express import, and no rewrite can know that it is in scope where they are required.
npx fulmine.js verify is the question that comes before all of that: whether this machine, and the
image this will be deployed in, can run it at all. There is a µWebSockets.js binary underneath, and
a binary is built per platform, per architecture and per node ABI, and linked against glibc. An
Alpine base, a node version the pinned build has no binary for, a FROM node:20-alpine written
years ago: each one fails at require time, in a container, with a message about a missing module.
This says so in thirty seconds, and exits non-zero when something would stop the start.
ok Node 22.15.0
ok glibc 2.39
ok µWebSockets.js binary for linux x64, node ABI 127
NO Dockerfile: node:20-alpine
musl, and there is no musl build: node:22-trixie-slim is the closest swap.
note socket.io needs a different API here
attach it with io.attachApp(app.uwsApp), not io.attach(server)
Angular SSR
The server.ts that ng add @angular/ssr generates is an ordinary Express application, so the same
one-line change applies, and @angular/ssr's own AngularNodeAppEngine and
writeResponseToNodeResponse work against Fulmine's request and response unchanged. One extra step
is needed, and it is Angular's build rather than this library: the server bundle is built with
esbuild, which tries to inline every dependency and cannot load µWS's native binary. The two names
have to be declared external in angular.json, which is what this writes:
npx fulmine.js angular # every server build in angular.json
npx fulmine.js angular --dry-run # say what it would write, write nothing
It adds this to each build target that produces a server bundle, and leaves the browser-only ones alone:
"architect": { "build": { "options": {
"externalDependencies": ["fulmine.js", "uWebSockets.js"]
} } }
What it is worth, measured on an Angular 22 application with each server reporting its own CPU per request, nine alternating rounds: static assets 3.29x, and a page served from a cache 1.50x. The render itself is the same JavaScript on both sides and measures the same, so on a cache miss the framework is not what your page is waiting for. Which is the useful way round: an SSR application spends most of its traffic outside the render, and that is where the difference is.
Caching those pages is ng-ssr-caching, a middleware
that runs on Express and here alike, and the same measurement says a page costs 17.2ms to render and
1.9ms to serve from it. It is worth knowing why it keeps the ETag beside the bytes: a cache that
stores only the body makes the server hash the whole document again on every hit, and measures level
with no cache at all on the serving side.
NestJS
@nestjs/platform-express takes an Express instance, so it takes this one, and everything in a Nest
application keeps working. The adapter is in the package, so there is nothing to write:
import { NestFactory } from "@nestjs/core";
import { FulmineExpressAdapter } from "fulmine.js/nest";
const app = await NestFactory.create(AppModule, new FulmineExpressAdapter());
await app.listen(3000);
Pass your own application where it needs options, TLS being the usual reason:
new FulmineExpressAdapter(fulmine({ uwsOptions })). @nestjs/platform-express is an optional peer
dependency, so nothing is installed for anyone who never imports this.
What it changes is one line and two edges. The line: Nest's own adapter wraps whatever instance it
is given in http.createServer() and listens on that, which is the shim, so every request goes
through node:http and the application runs at Express's pace. The app here already answers as an
http.Server, so it is the server rather than being put inside one. The edges: forceCloseConnections
has nothing to destroy, since the sockets belong to µWS and nothing emits connection, so it now
says so instead of quietly doing nothing; and Nest decides whether it has already added its body
parsers by scanning app.router.stack, which is not there, so the adapter remembers instead of
letting a second call add a second pair. httpsOptions is refused rather than silently starting a
plaintext server: TLS is configured on the app, through uwsOptions.
Measured on the same Nest application, controllers, pipes and body parsing unchanged: 1.2x on a
route answering text and 1.9x on one answering JSON with a route parameter. app.close() closes
the port, as it does on the shim.
A Nest application answering the same bytes on both is a case in the integration suite, so this is tested rather than claimed.
When Express is somebody else's dependency
A framework built on Express does not require("express") in your code, it requires it in its own,
so there is nothing for migrate to rewrite. Every package manager can answer express with this
package instead, for your project and everything under it, and this writes the block for whichever
one your project uses:
npx fulmine.js override # read the lockfile, write the block, say what to run next
npx fulmine.js override --dry-run # say what it would write, write nothing
It refuses rather than overwrites where a substitution is already there and is not this package. By hand it is one of these:
// npm and its lockfile, in package.json
{
"overrides": {
"express": "npm:fulmine.js@^5"
}
}
// pnpm, in package.json
{
"pnpm": {
"overrides": {
"express": "npm:fulmine.js@^5"
}
}
}
// yarn 1 and berry, in package.json
{
"resolutions": {
"express": "npm:fulmine.js@^5"
}
}
Then reinstall, so the lockfile is rewritten: rm -rf node_modules and npm install, or the
equivalent for your manager. npm ls express should answer express@npm:fulmine.js.
Two things to know before you do it. The substitution reaches every dependency that asks for
Express, including ones you have never looked at, so run your own tests afterwards and read
the differences: what a framework does with Express is usually more
than what an application does. And a package that reaches into express/lib/... rather than its
public surface will not find what it expects, since the files there are ours.
Bun is not an option: µWebSockets.js is a native Node addon, and Bun does not load it.
Docker
Three things about µWebSockets.js make a Dockerfile that works for Express fail here, and all three have easy answers:
-
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:26andnode:26-slimare Debian bookworm, whose glibc 2.36 fails at startup withGLIBC_2.38' not found. Use the trixie variants:node:26-trixie-slimand up. -
gitmust be there whennpm installruns. µ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 likenode:26-trixiehave git;-slimones do not. -
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 ciputs it back:RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
The clean way to satisfy the first two is a multi-stage build: install with the full image, run with the slim one.
FROM node:26-trixie AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:26-trixie-slim
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
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.
Behind a private registry
µWebSockets.js is not on npm, it is installed from GitHub, and npm allows that by default, so an
ordinary npm install fulmine.js needs nothing from this section. It is here for the builds that
have turned git dependencies off, or that cannot reach github.com at all:
allow-git=none npm error code EALLOWGIT
npm error Fetching packages of type "git" have been disabled
npm error Refusing to fetch "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.69.0"
allow-git=root npm error code EALLOWGIT
npm error Fetching non-root packages of type "git" have been disabled
root is the one that surprises people: it allows a git dependency your own package.json asks
for, and still refuses this one, because it is asked for by fulmine rather than by you.
The answer is to put µWebSockets.js in your own registry and point at it from there. Three steps, and nothing is compiled on the way: the tarball is what uNetworking already publishes on the tag, prebuilt binaries included.
1. Pack the tag. No clone needed, npm takes the git spec directly:
npm pack "github:uNetworking/uWebSockets.js#v20.69.0"
Read the version out of package.json rather than copying the one above, since
it moves with each release of this package.
2. Publish it to your registry.
npm publish uWebSockets.js-20.69.0.tgz --registry https://registry.internal/
The tarball is around 33 MB, because it carries a binary for every Node ABI and platform, and that
is larger than several defaults along the way. Verdaccio refuses it at max_body_size: 10mb,
and an nginx in front of any registry refuses it at client_max_body_size 1m. Both answer
413 Payload Too Large without ever naming µWebSockets.js, so raise them before deciding the
tarball is broken:
# verdaccio config.yaml
max_body_size: 200mb
3. Override the git spec in your application. The version is the same one you packed:
{
"dependencies": { "fulmine.js": "5.17.0" },
"overrides": { "uWebSockets.js": "20.69.0" }
}
npm install and npm ci both work from here with git off, and the lockfile resolves to your
registry with an integrity hash, so nothing reaches for git at install time:
"node_modules/uWebSockets.js": {
"version": "20.69.0",
"resolved": "https://registry.internal/uWebSockets.js/-/uWebSockets.js-20.69.0.tgz",
"integrity": "sha512-kO7bcc/Hy3K6YnAVKBCu9ffYC1tl/ccDzDJqVK/GAO81XRRL+R1VmJP8mReg..."
}
One thing to tell your security team before their scanner does: the lockfile still contains the
line "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0". That is fulmine's declared
range, not a resolution, and a scanner that reads what is declared rather than what was installed
will report a git dependency that the install never used.
Differences from Express
What the two servers answer on the wire, probed from outside, malformed input and smuggling attempts included: fulmine.js on http-probe.com against express.
app.listen()returns the app rather than a separate server object, and the app answers as anhttp.Server:app instanceof http.Serveris 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 thekeepAliveTimeoutfamily. Not there: nothing emitsconnection,requestorupgrade,getConnections()counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set throughuwsOptions.idleTimeout. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wantsapp.uwsApp. Runnable:examples/graceful-shutdown.js.x-powered-byis disabled by default. Express sendsX-Powered-By: Expressunless you turn it off; Fulmine does not send it unless you turn it on withapp.set("x-powered-by", true). The header only tells anyone asking which framework is running.- request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting
body methodsto array with uppercased methods. - 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
400and Fulmine refuses the same ones: a repeatedContent-Length; one that is not a plain count of bytes, an empty value or a count pastNumber.MAX_SAFE_INTEGERincluded; aTransfer-Encodingwhose last coding is notchunked; 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.1is 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 the400completes it, so the choice is between telling the client and stopping the smuggled request. Nothing well behaved sends any of these. - A compiled route answers
connection: keep-aliveto a client that sentConnection: close. A handler simple enough to be read at registration time is answered by µWS from a response written once atlisten(), 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. - Informational responses go nowhere.
res.writeEarlyHints(),res.writeContinue()andres.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 a1xx. 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, andres.setTimeout()andreq.setTimeout()register the listener without changing anything, since µWS runs its own idle timeout throughuwsOptions.idleTimeout. - For HTTPS, instead of doing this:
const https = require("https");
const express = require("express");
const app = express();
https
.createServer(
{
key: fs.readFileSync("path/to/key.pem"),
cert: fs.readFileSync("path/to/cert.pem")
},
app
)
.listen(3000, () => {
console.log("Server is running on port 3000");
});
You have to pass uwsOptions to the express() constructor:
const express = require("fulmine.js");
const app = express({
uwsOptions: {
// https://unetworking.github.io/uWebSockets.js/generated/interfaces/AppOptions.html
key_file_name: "path/to/key.pem",
cert_file_name: "path/to/cert.pem"
}
});
app.listen(3000, () => {
console.log("Server is running on port 3000");
});
Runnable: examples/https.js.
- 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 letssupertest,vhostand anything else that calls an app keep working. But it serves those requests throughnode:httprather than through µWS, so the speed is Express's. It is there for compatibility, not for production. - 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 verifysays 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. - 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_SIZEto max byte count you need. - 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.
Performance tips
Where the speed comes from, before the rules that govern it. Express finds a route by walking its
stack and testing each layer against the path. Fulmine hands every route it can to µWS's own router,
which matches in C++, and works out at listen() which layers stand in front of each one, so
arriving at a handler costs no matching at all:
Express Fulmine
GET /users/42 GET /users/42
| |
v v
+--------------+ +------------------+
| layer 1 | path? no | µWS router | one match, in C++,
| layer 2 | path? no | /users/:id | against every path
| ... | +--------+---------+ registered
| layer 214 | path? yes -+ |
+--------------+ | v
a test per layer, | +------------------+
every request | | the chain, known | the layers in front,
| | since listen() | in order, no matching
v +--------+---------+
handler |
v
handler
That is the whole difference on a large route table: the scan grows with the table and the match does not, which is why a thousand routes measure 10x and a handful measure 3x.
Two more things happen on the way in, and npx fulmine.js profile will tell you which of them your
routes get:
a request arriving at a compiled route
µWS match ──► the chain ──────────────────────────► handler ──► response
| |
| a body parser is stepped over | the Readable is not
| when the request declared no | built unless something
| body and the verb reads none | asks the body for one
| |
| the headers are not copied out | the two internal
| of µWS when the analysis proved | listeners are written
| nothing in the chain reads one | into the event map
v v
work that does not happen work that is not prepared
and when the handler is simple enough to be read at registration time, none of the
above happens either: µWS answers from a response written once, at startup
- Fulmine tries to optimize routing as much as possible, but it's only possible if:
- the path is a plain string, or its parameters are whole segments:
/users/:idand/a/:b/c/:dqualify,/flights/:from-:todoes not, and neither does a*splator 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/listno longer overlaps/users/:id, so both are matched by µWS instead of one of them falling back. - inside a mounted router, nothing registered after the route in that router could match the same path.
/orders/:id,/orders/:id/itemsand/invoices/:idare all optimized together, since no request reaches two of them./users/:idfollowed by/users/meis not: Express answers/users/mewith the first of the two and the native router would answer it with the second, so both go the ordinary way.
Optimized routes can be up to 10 times faster than normal routes, as they're using native uWS router and have pre-calculated path.
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.
Three things are refused whatever the handler does, and all three are the same fact: a response written at startup cannot read the request.
- one that would carry an
ETagor aLast-Modified, since it could never answer with the304 Not Modifiedthat the validator invites.etagis on by default, soapp.set("etag", false)is what puts an ordinary route on this path. - one whose route captures,
/users/:id, since a value that cannot be decoded is a400in Express and nothing runs here to raise it. - a
204,205or304, since the body would go out with the status and a client frames those as bodiless whatever it reads.
Two things then follow from the response being static:
- it carries a
Content-Lengthwhile 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 asTransfer-Encoding: chunked. uWS writes the framing either way, which is why neither header can be set by hand. - it answers
Connection: keep-aliveeven to a request that asked forConnection: close. The connection is still closed, since uWS decides that itself, and a client that asked to close is closing anyway.
app.set("declarative responses", false) turns the whole thing off if you would rather have Express's exact framing than the speed.
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:
npx fulmine.js profile # the file "main" or the start script points at
npx fulmine.js profile server.js # or name it
7 route(s), 4 answered by µWS itself
GET /api/health µWS /api/health (2 in front of it in its chain)
GET /hello µWS /hello (compiled to a response, reads no query)
GET /:anything router: something before it in the same router overlaps its paths
GET /after-the-param router: the parameter route /:anything is written before it
SEARCH /odd router: µWS does not serve SEARCH
What this adds up to
4 of 7 route(s) matched by µWS in C++
1 answered from a response written at startup, running no javascript
layers in front of a compiled handler: 1 at least, 2 at most, 1.8 on average
Worth changing, if these are routes that carry traffic
GET /after-the-param
write it above /:anything. Express answers whichever matches first, so the order is
already what decides, and with the literal first µWS can match it in C++ as well.
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.
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.
const { expectNative, expectDeclarative, routeReport } = require("fulmine.js").testing;
expectNative(app, ["/api/*", "GET /health"]); // throws, naming the route and the reason
expectDeclarative(app, "/health"); // the step past native: no javascript at all
routeReport(app); // the whole list, to assert on however you like
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.
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:
const { workReport, expectLazy } = require("fulmine.js").testing;
app.post("/items", (req, res) => {
expectLazy(req, res, { allow: ["body"] }); // throws naming what else was built
workReport(req, res); // { native, declarative, headers, query, body, requestStream, ... }
res.json(req.body);
});
Asking costs a property read: every field is state the framework already keeps, nothing is counted or wrapped to make it readable.
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.
GET /api/items/:id
route native (µWS matched /api/items/:x and dispatched by method)
headers copied out of µWS (something in the chain reads them)
query parsed when something asks for it
chain 2 layer(s), 1 mounted layer(s) in front of it
logger readable at registration, reads the query
(anonymous) readable at registration
body read for POST, PUT, PATCH and QUERY, when one is declared
The same verdict reaches the browser, per request, with express.serverTiming():
Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
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.
-
Do not use external
serve-staticmodule. Instead use built-inexpress.static()middleware, which is optimized for Fulmine. If your build already writes.brand.gzfiles 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, onestatper request, because the twin is looked for before the file and its ownstatis 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-Encodingis 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. -
Do not use
body-parsermodule. Instead use built-inexpress.text(),express.json()etc. -
Do not use the
compressionmodule.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 everyres.send()andres.json(), is compressed in one call rather than through a transform stream and goes out with aContent-Lengthinstead of chunked; a response written in pieces still streams. The bytes are the same bytes either way.
// the compression module's options, unchanged: threshold, filter, level, brotli, enforceEncoding
app.use(express.compression({ threshold: 1024 }));
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.
// answer gzip even to a client that also accepts br
app.use(express.compression({ level: 1, encodings: ["gzip"] }));
Runnable: examples/compression.js.
-
If a route answers with a JSON shape you know in advance, express-fast-json-stringify compiles that shape into a serializer and
res.fastJson()replacesres.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. -
Do not set
body methodsto read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower. -
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 no304 Not Modifiedreplies 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. -
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
threadsto a different number inexpress(), or set to 0 to disable thread pool (express({ threads: 0 })). Threads are shared between all express() instances, with largestthreadsnumber being used. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both. -
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 isSO_REUSEPORT: every process has its own listening socket and the kernel decides which one gets each connection. Node's ownclustercannot do that with anhttp.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.
// "auto" is one worker per usable core: the cgroup quota is read first, so a 2-core container
// on a 64-core host forks 2 and not 64. A number instead of "auto" says how many.
const app = express({ cluster: "auto" });
app.get("/", (req, res) => res.send("hello"));
// The whole file runs again in every worker, which is how cluster works: the code above this
// line runs once per process. The primary only forks, so the callback runs once per worker too,
// and a worker that dies is replaced.
app.listen(3000, () => console.log(`worker ${process.pid} listening`));
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.
WebSockets
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.
app.ws("/room/:id", {
upgrade(req, res) {
// runs before the handshake, with a real request and response.
// Answering the response declines the socket:
if (!req.query.token) return res.sendStatus(401);
// and anything left on the request is there for the socket's whole life:
req.room = req.params.id;
},
open(ws) {
ws.subscribe(ws.req.room);
},
message(ws, message, isBinary) {
ws.publish(ws.req.room, message, isBinary);
},
close(ws, code, message) {}
});
- The behavior object is µWS's, settings included:
maxPayloadLength,idleTimeout,compression,maxBackpressure,sendPingsAutomaticallyand the rest are passed through untouched, as are theopen,message,drain,close,ping,pong,droppedandsubscriptionhandlers. The socket is µWS's too, sosend,subscribe,publish,corkandgetBufferedAmountbehave exactly as its documentation describes. upgrade(req, res)is this project's addition. It runs before the handshake with the sameRequestandResponseyour routes get, so a session, a token or a header decides whether the socket opens. Answering the response, withres.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.ws.reqis 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 inupgradeis how per-connection state gets tomessage.- 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.abortedsays whether there is still anybody to answer. Writing to a response that was aborted does nothing rather than throwing. - Routers work.
router.ws("/lobby", ...)mounted withapp.use("/chat", router)serves/chat/lobby. - 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. - Broadcasting from outside a socket:
app.publish(topic, message)andapp.numSubscribers(topic).
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.
If you would rather use the ws module's API, 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.
socket.io
socket.io normally takes over the upgrade on a node http.Server. The upgrade here never reaches
node, so hand it the µWS app instead, which socket.io supports natively through attachApp():
const express = require("fulmine.js");
const { Server } = require("socket.io");
const app = express();
const io = new Server();
app.listen(3000);
io.attachApp(app.uwsApp);
io.on("connection", (socket) => {
socket.on("message", (data) => socket.emit("reply", data));
});
attachApp() works before or after app.listen(). What does not work is new Server(app) on the
app itself, or on what app.listen() returns, which is the same object: socket.io refuses it with
"You are trying to attach socket.io to an express request handler function", because it checks for a
function before it checks for a server, and an app here is callable. That refusal is the useful
answer. Even if it accepted the object, there is no node socket behind it to take an upgrade over,
so it would have failed later and more quietly. Plain HTTP keeps serving either way. This is covered
by tests/tests/middlewares/socket-io.js, which runs the same file against Express and against
Fulmine and compares the output. Runnable: examples/socket-io.js.
HTTP/3
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.
// what it would look like, once µWS's H3 support actually works
const app = express({
http3: true,
uwsOptions: {
key_file_name: "/path/to/example.key",
cert_file_name: "/path/to/example.crt"
}
});
Behind a proxy
trust proxy works as it does in Express: set it and req.ip, req.ips, req.protocol and
req.hostname are read from X-Forwarded-* when the connection comes from a peer you trust.
Fulmine adds the other way of being told, the one that does not use headers at all. HAProxy, AWS
NLB, nginx with proxy_protocol and Envoy can prepend a PROXY protocol preamble to the
connection, and µWebSockets.js parses it. Off by default, and one line turns it on:
app.set("trust proxy protocol", true);
// req.ip, req.socket.remoteAddress and everything reading them are now the address the proxy
// declared, and fall back to the socket's own on a connection that sent no preamble
Warning
Only turn this on when nothing but the proxy can reach the server. µWS reads the preamble
from whoever sends it. There is no way to say which peers may use it, so on a port open to the
internet the first sixteen bytes of any connection are enough for a client to become 10.0.0.1
for your rate limiter, your allow list and your audit log. Bind to the private interface, or
keep this off.
trust proxy and this can both be on. The preamble decides what the connection's address is, and
trust proxy then peels X-Forwarded-For off that, so a proxy that sends both is read the way it
meant. It is the binary v2 preamble that µWS reads, not the v1 text line, so a connection starting
with PROXY TCP4 ... is answered as a malformed request. Runnable, with a client that writes one:
examples/proxy-protocol.js.
Versioning
The major number tracks Express, not semver. Fulmine 5.x follows Express 5. If Express 6 arrives, Fulmine goes to 6, and that is the only reason the major ever moves.
Read the rest of the number normally: minor for new behaviour, patch for fixes.
What this costs you: a breaking change can land in a minor. It will be in the changelog under its own heading, because commits still mark breaking changes the usual way, but the version number alone will not warn you. If you pin, pin the minor.
Compatibility
In general, basically all features and options are supported. Use the Express 5.x documentation for API reference. Anything Express 5 removed is removed here too, so the list below covers only where this differs from Express 5 itself.
✅ - Full support (all features and options are supported)
🚧 - Partial support (some options are not supported)
❌ - Not supported
express
- ✅ express()
- ✅ express.Router()
- ✅ express.json()
- ✅ express.urlencoded()
- ✅ express.static()
-
- ✅ options.index, options.redirect, options.fallthrough, options.extensions
-
- ✅ 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
- ✅ options.dotfiles, plus
-
- ✅ options.setHeaders, options.headers
-
- ✅ options.etag, options.lastModified, options.maxAge, options.immutable, options.cacheControl, options.acceptRanges
-
- ✅ options.preCompressed, Fulmine's own: serve the
.bror.gztwin on disk, described under Performance tips
- ✅ options.preCompressed, Fulmine's own: serve the
- ✅ express.text()
- ✅ express.raw()
- ✅ express.serverTiming(). Fulmine's own: Server-Timing carrying how the request was routed, described under Performance tips.
- ✅ express.testing. Fulmine's own:
expectNative,expectDeclarative,routeReport,expectLazyandworkReport, described under Performance tips. - ✅ express.compression(). Fulmine's own, since Express has none: it is the compression module's options and behaviour built in, described under Performance tips.
- 🚧 express.request (this is not a constructor but a prototype for replacing methods)
- 🚧 express.response (this is not a constructor but a prototype for replacing methods)
- 🚧 express.application (likewise: a method added here is on every app)
- ✅ express.Route. Both
app.route("/path").get(...).post(...)and the class itself, for building a route by hand and dispatching to it.
Application
- ✅ app.listen(port[, host][, callback])
- ✅ app.listen(unix_socket[, callback])
- ✅ app.METHOD() (app.get, app.post, etc.)
- ✅ app.route()
- ✅ app.all()
- ✅ app.use()
- ✅ app.mountpath
- ✅ app.set()
- ✅ app.get()
- ✅ app.enable()
- ✅ app.disable()
- ✅ app.enabled()
- ✅ app.disabled()
- ✅ app.path()
- ✅ app.param(name, callback)
- ✅ app.engine()
- ✅ app.render()
- ✅ app.locals
- ✅ app.settings
- ✅ app.engines
- ✅ app.on("mount")
- ✅ HEAD method
- ✅ OPTIONS method
- ✅ QUERY method
What listen() hands back is the app, and it answers as an http.Server so the shutdown wrappers
recognise it: app.close(), app.address(), app.listening, app.getConnections(), app.ref(),
app.unref(), app.setTimeout() and the keepAliveTimeout family. See
Differences from Express for what is behind them and what is not.
Application settings
- ✅ case sensitive routing
- ✅ env
- ✅ etag
- ✅ jsonp callback name
- ✅ json escape
- ✅ json replacer
- ✅ json spaces
- ✅ query parser
- ✅ strict routing
- ✅ subdomain offset
- ✅ trust proxy
- ✅ views
- ✅ view cache
- ✅ view engine
- ✅ x-powered-by
Two of these keep a compiled form alongside the value, which you can also set directly:
etag fn, the function that produces an ETag. Settingetagcompiles one; setting this replaces it.query parser fn, likewise forquery parser.
Fulmine adds eight of its own:
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.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 turnsdeclarative responsesoff with it.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.declarative responses, on by default. Lets a simple enough handler be compiled into a native uWS response, described under Performance tips.connection headers, on by default. Express sendsConnection: keep-aliveandKeep-Aliveon every response, and so does this. Turn it off and neither goes out, while a connection the client asked to close still answersConnection: 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.file cache, on by default. Small files served byres.sendFilecome from a bounded in-process cache, checked against the file'sstaton 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.stat cache, off by default. Takes a duration,app.set("stat cache", "1s"). The size and mtime of a file served byres.sendFileorexpress.staticare 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 thefile cachekeeps: inside the window an edited file is served as it was, so keep the window shorter than you would notice.trust proxy protocol, off by default. Takesreq.ipfrom a PROXY protocol preamble, described under Behind a proxy. Read the warning there before turning it on.
Request
- ✅ implements Readable stream
- ✅ req.app
- ✅ req.baseUrl
- ✅ req.body
- ✅ req.cookies
- ✅ req.fresh
- ✅ req.hostname
- ✅ req.header
- ✅ req.headers
- ✅ req.headersDistinct
- ✅ req.rawHeaders
- ✅ req.ip
- ✅ req.ips
- ✅ req.method
- ✅ req.url
- ✅ req.originalUrl
- ✅ req.params
- ✅ req.path
- ✅ req.protocol
- ✅ req.query
- ✅ req.res
- ✅ req.secure
- ✅ req.signedCookies
- ✅ req.stale
- ✅ req.subdomains
- ✅ req.xhr
- 🚧 req.route (route implementation is different from Express)
- 🚧 req.connection, req.socket (only
end(),encrypted,remoteAddress,remotePortandlocalPortare supported) - ✅ req.accepts()
- ✅ req.acceptsCharsets()
- ✅ req.acceptsEncodings()
- ✅ req.acceptsLanguages()
- ✅ req.get()
- ✅ req.is()
- ✅ req.range()
Response
- ✅ implements Writable stream
- ✅ res.app
- ✅ res.headersSent
- ✅ res.req
- ✅ res.locals
- ✅ res.append()
- ✅ res.attachment()
- ✅ res.cookie()
- ✅ res.clearCookie()
- ✅ res.download()
- ✅ res.end()
- ✅ res.format()
- ✅ res.getHeader(), res.get()
- ✅ res.json()
- ✅ res.jsonp()
- ✅ res.links()
- ✅ res.location()
- ✅ res.redirect()
- ✅ res.render()
- ✅ res.send()
- ✅ res.sendFile()
-
- ✅ options.maxAge
-
- ✅ options.root
-
- ✅ options.lastModified
-
- ✅ options.headers
-
- ✅ options.dotfiles
-
- ✅ options.acceptRanges
-
- ✅ options.cacheControl
-
- ✅ options.immutable
-
- ✅ Range header
-
- ✅ Setting ETag header
-
- ✅ If-Match header
-
- ✅ If-Modified-Since header
-
- ✅ If-Unmodified-Since header
-
- ✅ If-Range header
- ✅ res.sendStatus()
- ✅ res.header(), res.setHeader(), res.set()
- ✅ res.status()
- ✅ res.type()
- ✅ res.vary()
- ✅ res.removeHeader()
- ✅ res.write()
- ✅ res.writeHead()
- ✅ res.flushHeaders()
Router
- ✅ router.all()
- ✅ router.METHOD() (router.get, router.post, etc.)
- ✅ router.route()
- ✅ router.use()
- ✅ router.param(name, callback)
- ✅ options.caseSensitive
- ✅ options.strict
- ✅ options.mergeParams
Tested middlewares
Almost all middlewares that are compatible with Express are compatible with Fulmine. Here's list of middlewares that we test for compatibility:
- ✅ express-fast-json-stringify
- ✅ socket.io (via
io.attachApp(app.uwsApp), see WebSockets above) - ✅ body-parser (use
express.text()etc instead for better performance) - ✅ cookie-parser
- ✅ cookie-session
- ✅ compression (use
express.compression()instead for better performance) - ✅ serve-static (use
express.static()instead for better performance) - ✅ serve-index
- ✅ cors
- ✅ errorhandler
- ✅ method-override
- ✅ multer
- ✅ response-time
- ✅ express-fileupload
- ✅ express-session
- ✅ express-rate-limit
- ✅ express-subdomain
- ✅ vhost
- ✅ http-proxy-middleware
- ✅ express-http-proxy
- ✅ express-mongo-sanitize
- ✅ helmet
- ✅ passport
- ✅ morgan
- ✅ swagger-ui-express
- ✅ graphql-http
- ✅ better-sse
- ✅ supertest
tsoa works too, but it is not in the suite above: it resolves
express itself, so testing it here needs a dependency override rather than the one-line swap
everything else takes.
Tested frameworks
The list above is middlewares. A framework built on Express is a much larger user of the Express
surface than any application is, so those have a suite of their own, in
integrations/: the same application served twice, once on Express and once here,
with the two outputs compared byte for byte. The four that render pages are built first, by that
suite, so what is compared is what their own build produces.
- ✅ NestJS through
fulmine.js/nest - ✅ Next.js as a custom server,
next().getRequestHandler() - ✅ Astro through
@astrojs/nodein middleware mode - ✅ SvelteKit through
@sveltejs/adapter-node - ✅ React Router v7 through
@react-router/express - ✅ Apollo Server through
@as-integrations/express5 - ✅ tRPC through
@trpc/server/adapters/express - ✅ Angular SSR, which is an ordinary Express
server.tsplus one line of build configuration
Each of these mounts on an ordinary Express application, so there is nothing to install and nothing
to configure beyond what that framework already asks for. Nest is the exception, and only because
its adapter decides what to listen on: that one is fulmine.js/nest.
Tested view engines
Any Express view engine should work. Here's list of engines we include in our test suite:
- ✅ ejs
- ✅ pug
- ✅ express-dot-engine
- ✅ express-art-template
- ✅ express-handlebars
- ✅ swig
Examples
examples/ has one runnable file per thing this does that Express does not:
the cluster option, app.ws(), socket.io through attachApp, the pre-compressed twins,
express.compression(), express.serverTiming(), TLS through uwsOptions, the PROXY protocol,
what listen() decided about each route, and the app answering as an http.Server. What an
Express application already does is documented by Express and is not repeated there.
cd examples
npm install
node websocket.js
Attribution
Fulmine is a derivative work of Ultimate Express by @dimdenGD, used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
Special thanks to @dimdenGD. Ultimate Express is the hard part of this project, and it was already done before Fulmine existed. Everything here stands on that work.
Fulmine is not affiliated with, endorsed by, or maintained by the authors of Ultimate Express. See NOTICE for the list of significant changes.
It is likewise not affiliated with the OpenJS Foundation or the Express.js project. Express is a trademark of the OpenJS Foundation.
Working on Fulmine
How to run the suites, what each of them is for, and how to write a comparison test:
CONTRIBUTING.md. What is expected of everyone taking part:
CODE_OF_CONDUCT.md.
Found something exploitable? Report it privately rather than in an issue, and see
SECURITY.md for what is in scope and what to expect.