Router
The core of Expediate is a zero-dependency HTTP router built on top of Node.js http/https. It supports named parameters, glob wildcards, regular expressions, nested sub-routers, signed cookies, per-request timeouts, and graceful shutdown.
Creating a router
import { createRouter } from 'expediate';
const app = createRouter();
// or with options:
const app = createRouter({
secret: process.env.COOKIE_SECRET, // for signed cookies
timeout: 30_000, // ms; 0 = disabled
trustProxy: true, // trust X-Forwarded-* headers
});createRouter() returns a Router which is itself a middleware function (router.listener), so it can be nested inside any other router.
Route registration
HTTP method routes
router.get(path, ...middleware)
router.post(path, ...middleware)
router.put(path, ...middleware)
router.delete(path, ...middleware)
router.patch(path, ...middleware)
router.all(path, ...middleware) // matches any HTTP methodMethod routes use endpoint matching: the pattern must match the entire path (up to an optional trailing slash). get('/users') matches /users and /users/ but not /users/42.
Prefix / mount routes
router.use(path, ...middleware)use() uses prefix matching: the pattern is tested as a prefix, and the matched portion is stripped from req.path before the middleware is called. After the middleware calls done(), req.path is restored so sibling layers see the original path.
This makes use() the correct mount mechanism for sub-routers and global middleware.
Middleware argument shapes
Each slot accepts a Middleware function, a Router instance, or an array of either:
app.get('/admin', authGuard, adminHandler);
app.get('/multi', [guard, logger], handler);
app.use('/api', apiRouter); // Router instance
app.use('/api', apiRouter.listener); // equivalentPath patterns
Plain strings with :param segments
app.get('/users/:id', handler); // req.params.id
app.get('/orgs/:org/repos/:repo', handler); // req.params.org, .repoInline regex constraints
Append a constraint in parentheses to restrict what a parameter segment matches. Only requests whose segment passes the constraint reach the handler — others fall through:
app.get('/items/:id(\\d+)', handler); // digits only
app.get('/files/:name([\\w-]+)', handler); // word chars + hyphens
app.get('/v:ver(\\d+)/api', handler); // literal suffix after constraintNamed capture groups inside constraints are not allowed (they conflict with the outer (?<name>…) wrapper).
Glob patterns
app.use('/static/**', handler); // any depth under /static/
app.get('/api/*', handler); // exactly one path segment
app.get('/**/*.php', handler); // PHP files anywhere
app.get('/v?/status', handler); // one character wildcardGlob rules: ? → one non-slash character; * → any non-slash characters; ** → any characters including slashes (cross-segment).
Regular expressions
Named capture groups become req.params entries:
app.get(/^\/users\/(?<id>\d+)/, handler);Restriction: RegExp patterns with the
g(global) ory(sticky) flag are rejected at registration time with aTypeError. These flags makeexec()stateful, causing intermittent routing failures across requests.
Request fields
The router augments every incoming request before any middleware runs. The augmentation is idempotent — safe across nested routers sharing the same request object.
| Field | Type | Description |
|---|---|---|
req.originalUrl |
string |
Raw URL string, never modified |
req.path |
string |
Pathname; rewritten by use() layers for sub-routers |
req.params |
Record<string, string> |
Merged URL query params + named route params (flat, first-value for repeated keys) |
req.query |
Record<string, string | string[]> |
URL query parameters; repeated keys produce arrays. Alias for req.queries.url |
req.queries.url |
same as above | Structured query string bucket |
req.queries.route |
Record<string, string> |
Named route parameters from the matched pattern |
req.cookies |
Record<string, unknown> |
Parsed Cookie header. j: values are JSON-decoded; s: values are HMAC-verified (requires secret) |
req.ip |
string |
Remote client IP. With trustProxy: true: first X-Forwarded-For value |
req.ips |
string[] |
Full X-Forwarded-For chain (oldest first). Empty when trustProxy is disabled |
req.hostname |
string |
Host header with port stripped. With trustProxy: true: taken from X-Forwarded-Host |
req.protocol |
string |
'http' or 'https'. With trustProxy: true: taken from X-Forwarded-Proto |
req.secure |
boolean |
true when req.protocol === 'https' |
req.baseUrl |
string |
Accumulated path prefix stripped by parent use() mounts. '' at the root level |
req.json(opts?) |
Promise<unknown|null> |
Parse request body as JSON. Returns cached value if body already parsed |
req.text(opts?) |
Promise<string|null> |
Decode request body as plain text |
req.formData(opts?) |
Promise<FormPart[]|null> |
Parse request body as multipart/form-data |
Cookies
Cookie values are decoded automatically:
- Plain strings are returned as-is.
j:<json>prefixed values are JSON-parsed.s:<sig>.<value>prefixed values are HMAC-SHA256 verified; cookies that fail verification are silently dropped. Requiressecretoption oncreateRouter().
Response helpers
Every response is augmented with convenience methods. All chainable helpers return this.
res.send('Hello'); // write string body and end
res.send(); // end with no body
res.json({ ok: true }); // set Content-Type: application/json and end
res.status(404).send('Not Found'); // set status code (validated 100–999)
res.status(201, { 'X-Id': '42' }).end(); // set status + headers
res.redirect('/new-path'); // 302 Found
res.type('text/csv').send(data); // set Content-Type (chainable)
res.etag('v1').json(payload); // weak ETag W/"v1" (chainable)
res.etag(sha256, true).send(buf); // strong ETag "sha256"
res.cookie('session', 'abc', {
maxAge: 3_600_000, // ms; also sets Expires
path: '/api',
httpOnly: true,
secure: true,
sameSite: 'Strict',
signed: true, // HMAC-sign (requires router secret)
});
res.clearCookie('session'); // Max-Age=0, Expires=epoch
res.clearCookie('tok', { path: '/admin' }); // match original options
res.download('/path/file.pdf'); // Content-Disposition: attachment
res.download('/path/file.pdf', 'invoice.pdf'); // custom filename
res.attachment('report.pdf').send(buf); // set disposition + Content-Type, no file I/O
res.append('X-Custom', 'v1'); // append to header (comma-joins; Set-Cookie accumulates)
res.vary('Accept'); // add to Vary header, deduplicating
res.vary(['Accept', 'Accept-Encoding']);
res.location('/new-path'); // set Location header
res.sendStatus(200); // set status + send standard text body
res.locals['user'] = currentUser; // request-scoped storage for middleware chainsres.status() validation
The code must be an integer in the range 100–999. Passing a non-integer or out-of-range value throws a RangeError at call time, which is caught by the router error handler.
Cookies — writing
Setting a cookie with signed: true produces an s:<hmac>.<value> string. The router's secret is required. Objects are serialised with a j: prefix and JSON-decoded on read.
Error handling
Global error handler
app.onError((err, req, res) => {
const status = (err as any)?.status ?? 500;
res.status(status).json({ error: String(err) });
});Receives all: synchronous throws, async rejections, and next(err) calls. Without a registered handler, the default sends 500 and logs to console.warn.
Custom 404 handler
app.setNotFound((_req, res) => res.status(404).json({ error: 'Not Found' }));Passing errors through middleware
app.use('/protected', (req, _res, next) => {
if (!req.headers.authorization) return next(new Error('Unauthorized'));
next();
});Calling next(err) skips remaining route layers and invokes the error handler directly.
Sub-routers and req.baseUrl
Routers are fully nestable. The matched prefix is stripped from req.path when mounted with use():
const api = createRouter();
api.get('/users', listUsers);
api.get('/users/:id', getUser);
const app = createRouter();
app.use('/api/v1', api); // /api/v1/users → api sees req.path = '/users'
// req.baseUrl = '/api/v1'req.baseUrl accumulates the stripped prefix as the request descends through nested use() mounts. It is '' at the root level and is restored for sibling layers after a sub-router calls done().
Prefix router creation
const v1 = createRouter('/api/v1'); // implicitly strips this prefix
app.use(v1);Starting the server
router.listen() returns the underlying http.Server (or https.Server, or http2.Http2SecureServer) instance:
// Plain HTTP
const server = app.listen(3000, () => console.log('Ready'));
// HTTPS
app.listen(443, {
key: readFileSync('server.key'),
cert: readFileSync('server.crt'),
});
// HTTP/2 (requires TLS)
app.listen(443, { key, cert, http2: true });
// Ephemeral port (useful in tests)
const server = app.listen(0, () => {
const { port } = server.address() as AddressInfo;
console.log(`Listening on :${port}`);
});Graceful shutdown
process.on('SIGTERM', () => app.shutdown(10_000)); // 10 s drain windowRequest timeout
const app = createRouter({ timeout: 30_000 }); // 408 after 30 s of no responseIntrospecting registered routes
console.log(app.routes());
// [{ method: 'GET', path: '/users', stripPath: false }, ...]method is null for use() / all() layers. stripPath: true identifies use() layers.
CookieOptions reference
| Option | Type | Default | Description |
|---|---|---|---|
signed |
boolean |
false |
HMAC-sign the value (requires router secret) |
expires |
Date |
— | Expiry date |
maxAge |
number |
— | Max age in milliseconds (also derives Expires) |
path |
string |
'/' |
Cookie path |
httpOnly |
boolean |
false |
Mark HttpOnly |
secure |
boolean |
false |
Mark Secure |
sameSite |
'Strict'|'Lax'|'None' |
— | SameSite attribute |
RouterOptions reference
| Option | Type | Default | Description |
|---|---|---|---|
secret |
string |
— | Cookie-signing secret. Required for signed cookies |
timeout |
number |
0 (disabled) |
Request timeout in milliseconds |
trustProxy |
boolean |
false |
Trust X-Forwarded-* headers |