This is a framework for the modern web; small, slick, elegant and fast.
I built Rayo after spending too much time trying to fix the problems I encountered with other frameworks.
I needed something that could be an almost out-of-the-box replacement for what most systems were built upon, without sacrificing productivity or performance.
Your server will feel like it got hit by a lightning bolt...
ยน
Rayois not intended to be an Express replacement, thus the API is similar, inspired-by, but not identical.
ยฒ Some middleware rely on Express-specific features, whichRayomay or may not implement.
There are examples ๐ throughout the read.
Requires Node.js 24 or newer. The same minimum applies to @rayo/send, @rayo/compress, and @rayo/storm.
npm install rayo
The examples use ES modules: save them as .mjs files or set "type": "module" in your application's
package.json. Install optional helpers when needed with npm install @rayo/send @rayo/compress.
import rayo from 'rayo';
rayo({ port: 5050 })
.get('/hello/:user', (req, res) => res.end(`Hello ${req.params.user}`))
.start();
import rayo from 'rayo';
// "age" handler
const age = (req, res, step) => {
req.age = 21;
step();
};
// "name" handler
const name = (req, res, step) => {
req.name = `Super ${req.params.user}`;
step();
};
rayo({ port: 5050 })
.get('/hello/:user', age, name, (req, res) => {
res.end(
JSON.stringify({
age: req.age,
name: req.name
})
);
})
.start();
Handlers receive an IncomingMessage (req), a ServerResponse (res), and a continuation function (step, often named next). Call step() to run the next handler, or finish the response without calling it.
Pass a truthy error to step(error) to invoke error handling.
Note: An error will be thrown if
step()is called on an empty stack.
End responses with res.end() or an installed response helper. Call the continuation at most once, and do not
call it after finishing a response. Rayo does not automatically advance middleware when a handler returns a
promise, and it does not catch thrown errors or rejected promises. Catch asynchronous failures and pass them
to step(error) explicitly.
If you need an easier and more convenient way to deal with your responses, take a look at @rayo/send.
/**
* @param {object} req
* @param {object} res
* @param {function} [step]
*/
const fn = (req, res, step) => {
// Your logic.
};
Calling step(error) with a truthy value invokes onError(error, req, res, handler), when configured.
The fourth argument is the handler that reported the error. Without onError, Rayo sends a 400 response
with error.message for an Error, or String(error) for other values, as UTF-8 plain text.
import rayo from 'rayo';
const options = {
port: 5050,
onError: (error, req, res) => {
res.statusCode = 500;
res.setHeader('content-type', 'text/plain; charset=utf-8');
res.end(error instanceof Error ? error.message : String(error));
}
};
rayo(options)
.get('/', (req, res, step) => step(new Error('Thunderstruck!')))
.start();
The example returns Thunderstruck! with status 500 at http://localhost:5050.
Custom error handlers set their own status, headers, and response body.
The rayo, @rayo/send, @rayo/compress, and @rayo/storm packages include declarations.
Install TypeScript and @types/node in a TypeScript application, choosing the Node type major that matches your
runtime. Use NodeNext or Bundler module resolution. The example below also requires @rayo/send and @rayo/compress.
import rayo, { type Request } from 'rayo';
import send, { type SendResponse } from '@rayo/send';
import compress from '@rayo/compress';
interface AppRequest extends Request {
accountId: string;
}
const app = rayo<AppRequest, SendResponse>({ port: 5050 });
app.through(send(), compress(), (req, res, next) => {
req.accountId = 'example';
next();
});
app.get('/hello/:name', (req, res) => {
res.json({ name: req.params.name, account: req.accountId });
});
app.start();
Request and response extensions describe what your middleware installs; install that middleware before handlers
that use those fields. Without a response subtype, send helpers are optional on Node's ServerResponse.
Rayo populates these fields before middleware runs:
| Field | Value |
|---|---|
req.pathname |
The parsed pathname used for routing. |
req.query |
Decoded query keys and values; repeated keys produce arrays. |
req.params |
Raw, URL-encoded route parameters; an empty object on a route miss. |
req.ip |
The nonempty x-forwarded-for header when supplied, otherwise the connection's remote address. |
The forwarded header is used as supplied; req.ip does not apply a trusted-proxy policy. Rayo does not parse
request bodies automatically.
Rayo extracts the pathname and query directly from ordinary HTTP request targets. Encoded separators, backslashes,
and dot segments remain unchanged for routing. Unusual forms use Node's legacy URL parser to preserve existing
behavior. Query values retain Node's querystring.parse decoding, repeated keys, and default 1,000-key limit.
Every dispatch reads req.url again; Rayo does not read or overwrite middleware's private req._parsedUrl cache.
Routes are indexed by method and path segments. Matches retain registration order, optional parameters, wildcards, trailing-slash handling, and raw URL-encoded parameter values. A static route does not override an earlier parameter route. Bridges are checked before direct routes, with later bridges checked first.
Rayo compiles route patterns during registration using its own parser. The supported syntax remains /users/:id,
/users/:id?, /files/:name.json, and /files/*; existing parsing behavior is checked against recorded compatibility
fixtures in the test suite. Pattern compilation happens before request handling and does not decode path segments.
Optional parameters can be absent. A wildcard matches deeper paths, but its req.params['*'] value contains
only the first matched segment: /files/* matches /files/a/b with '*': 'a'.
Global middleware and route handlers are combined once during preparation. Registration through .get(), .route(),
.through(), and bridges automatically invalidates the index; the next request rebuilds it. .prepare() (or .through()
with no handlers) prepares eagerly and is safe to repeat. Do not mutate prepared arrays returned by .fetch().
Bridge middleware runs once before its handlers. When multiple bridges and direct registrations use the same literal method/path, their handlers are combined in bridge precedence order followed by the direct handlers.
@param {object} [options]
@returns {Rayo}
options.host {string}
options.port {number}
options.storm {object}
os.availableParallelism();
set workers to choose a different count.monitor: false or keepAlive: false to disable them.Default: null (no clustering)options.server {http.Server}
.start() attaches
Rayo's request handler and calls listen() on this server.Default: A new http.Server created when .start() runs.options.notFound {function}
Invoked when no route matches the request's method and pathname.
/**
* @param {object} req
* @param {object} res
*/
const fn = (req, res) => {
// Your logic.
};
Global middleware runs before this handler. Default: a 404 response with UTF-8 plain text such as
GET /missing is undefined.. A custom notFound handler sets its own response status and body.
options.onError {function}
Invoked when
step(error)receives a truthy value.
/**
* @param {*} error
* @param {object} req
* @param {object} res
* @param {function} [handler] - The handler that reported the error, not a continuation.
*/
const fn = (error, req, res, handler) => {
// Your logic.
};
Default: a 400 response containing the error message as UTF-8 plain text.
@param {string} path
@param {function} handlers - Any number, separated by a comma.
@returns {rayo}
Convenience methods are .get(), .head(), .post(), .put(), .delete(), .connect(), .options(),
.trace(), and .patch(). HEAD handlers must be registered explicitly; a GET route does not supply one.
Rayo listens to Node's request event. HTTP tunnelling and protocol upgrades require handling Node's separate
connect and upgrade events on the server.
Requests that match the given verb and path will be routed through the specified handlers.
This method is basically an alias of the .route method, with the difference that the verb is defined by the method name itself.
import rayo from 'rayo';
/**
* Setup a path ('/') on the specified HTTP verbs.
*/
rayo({ port: 5050 })
.get('/', (req, res) => res.end('Thunderstruck, GET'))
.head('/', (req, res) => res.end('Thunderstruck, HEAD'))
.start();
@param {string} path
@param {function} handlers - Any number, comma separated.
@returns {rayo}
Registers the given path for the nine convenience methods listed above. To use another method, register it
explicitly with .route().
import rayo from 'rayo';
/**
* Setup a path ('/') on the nine supported convenience methods.
*/
rayo({ port: 5050 })
.all('/', (req, res) => res.end('Thunderstruck, all verbs.'))
.start();
@param {function} handlers - Any number, comma separated.
@returns {rayo}
On the app, these handlers run before route handlers and also run for unmatched paths. On a bridge, they run
only for that bridge's registered routes. Calling .through() without handlers is equivalent to .prepare().
import rayo from 'rayo';
// "age" handler
const age = (req, res, step) => {
req.age = 21;
step();
};
// "name" handler
const name = (req, res, step) => {
req.name = 'Rayo';
step();
};
rayo({ port: 5050 })
.through(age, name)
.get('/', (req, res) => res.end(`${req.age} | ${req.name}`))
.start();
@param {string} verb
@param {string} path
@param {function} handlers - Any number, comma separated.
@returns {rayo}
Requests which match the given verb and path will be routed through the specified handlers.
Method names are case-sensitive; use uppercase names such as 'GET'. Repeated registration of the same
literal method and path appends handlers. This method always takes an explicit path, including on a bound bridge.
import rayo from 'rayo';
rayo({ port: 5050 })
.route('GET', '/', (req, res) => res.end('Thunderstruck, GET'))
.start();
@param {string} path - The URL path to which verbs should be mapped.
@returns {bridge}
Group handlers for one route pattern. This binds the shortcut methods and .all() to that pattern;
it does not mount a path prefix.
A bound bridge exposes .through, .route,
the verb methods, .all, .prepare(), and .fetch().
Use .get(handler) on a bound bridge; on the app use .get(path, handler).
Calling .bridge() without a path creates a group whose methods take explicit paths and which can contain
further bridges. Bound bridges do not expose .bridge().
Bridge middleware can enable @rayo/compress or other behavior for selected routes.
import rayo from 'rayo';
const server = rayo({ port: 5050 });
/**
* Bridge the `/home` path to the `GET` and `HEAD` verbs.
*/
server
.bridge('/home')
.get((req, res) => res.end('You are home, GET'))
.head((req, res) => res.end('You are home, HEAD'));
/**
* Bridge the `/game` path to the `POST` and `PUT` verbs.
*/
server
.bridge('/game')
.post((req, res) => res.end('You are at the game, POST'))
.put((req, res) => res.end('You are at the game, PUT'));
const auth = (req, res, step) => {
req.isAuthenticated = true;
step();
};
const session = (req, res, step) => {
req.hasSession = true;
step();
};
/**
* Bridge the `/account` path to the `GET`, `POST` and `PUT` verbs
* and through two handlers.
*/
server
.bridge('/account')
.through(auth, session)
.get((req, res) => res.end('You are at the account, GET'))
.post((req, res) => res.end('You are at the account, POST'))
.put((req, res) => res.end('You are at the account, PUT'));
server.start();
Build the route indexes and reusable middleware stacks eagerly. Returns the app or bridge and is safe to call
repeatedly. .start() and .fetch() prepare automatically. Further registrations invalidate preparation and
are picked up by the next lookup.
Look up a route without executing its handlers. Use an uppercase method and a pathname without its query string.
Returns null on a miss, or { params, stack, dispatchStack }: stack includes route and contributing bridge
handlers; dispatchStack also includes the current app or bridge's global middleware. Treat both arrays as
immutable because they are shared between requests.
@param {function} [callback] - Invoked on the server's `listening` event.
@returns {http.Server|null}
Prepare routes and start listening. The method returns the server immediately; use the optional callback to
know when it is listening. The callback receives { address, family, port, workerPid }.
This is useful when the operating system assigns the port.
With Storm, the callback runs separately in each worker. The primary process normally returns null because
it does not create an HTTP server locally.
import rayo from 'rayo';
rayo({ port: 5050 })
.get('/', (req, res) => res.end('Thunderstruck'))
.start((address) => {
console.log(`Rayo is up on port ${address.port}`);
});
res.send(), res.text(), res.json(), and res.jsonString() response helpers.@rayo/benchmarks measures complete HTTP requests, with route-count, middleware, query, payload, and streaming workloads. It also supports paired comparisons between checkouts. Its README includes published comparison results and a guide to reproducing measurements; results depend on the workload, hardware, and Node version.
The September 2026 dependency report records the parser replacement measurements and their limitations. Those historical results predate the latest dependency upgrades and do not establish a current framework ranking.
Can be found here.
Run npm ci and npm test from the repository root. The npm workspaces share the root package-lock.json;
individual package lockfiles are not used. Parser compatibility tests keep fixed expectations from the former
dependencies, so neither matchit nor parseurl is a direct dependency of this project.
See our contributing notes.
Useful commands from the repository root:
npm ci
npm test # Lint, strict type checks, tests, and coverage
npm run unit # Runtime tests only; fails if no tests are selected
npm run test:types # NodeNext and Bundler consumer checks
npm run bench --workspace @rayo/benchmarks -- --list
CI runs on Node 24 and sends its Cobertura coverage report to Codacy. Before packaging, npm run copies
synchronizes the root README into packages/rayo and the root license into all five package directories.
The release guide covers publishing all five packages from GitHub Actions.
๐ Thank you to everyone who has made Node.js possible and to all community members actively contributing to it.
๐ Most of Rayo was written in chunks of 90 minutes per day and on the train while commuting to work.
MIT. Copyright (c) 2018 - 2026 Stefan Aichholzer.
โก