@vyriy/router
v0.8.9
Published
Router utility for Vyriy projects
Readme
@vyriy/router
Part of Vyriy - a calm architecture toolkit for TypeScript, React, SSR, SSG, APIs, and cloud-ready apps.
Full documentation: https://vyriy.dev/docs/router/
Router utility for Vyriy projects.
Purpose
This package provides a small router for Lambda handlers and native Node HTTP servers:
createRouter()for API Gateway eventscreateStreamRouter()for Lambda response streamingcreateHttpRouter()for native NodeIncomingMessage/ServerResponsehandlers
It is intentionally kept small:
- matches by HTTP method and exact path through an O(1) hash lookup
- matches
:paramsegments (for example/api/tenants/:tenantId) and exposes the captured values - matches any HTTP method on a path through
all(path, handler) - passes API Gateway event data into handlers (or raw Node request/response for the HTTP router)
- returns a Lambda-friendly response shape (the stream and HTTP routers write the response instead)
Matching stays fast: exact static paths are resolved first through the hash lookup, and :param routes are only scanned when that lookup misses, so static traffic never pays for parameter matching. Resolution runs from the most specific match to the most general: exact path, dynamic path, all path, fallback. Parameter matching is segment-based (no regular expressions), and static segments win over :param segments from left to right.
It still does not try to be a general dispatcher. If you need regular expressions, wildcards, or more advanced dispatch rules, that logic should live in a separate package or layer.
Install
With npm:
npm install @vyriy/routerWith Yarn:
yarn add @vyriy/routerBasic Router
import { createRouter } from '@vyriy/router';
const router = createRouter();
router.get('/health', async ({ event, query, headers, pathParameters, body }) => ({
body: JSON.stringify({
ok: true,
method: event.httpMethod,
query,
headers,
pathParameters,
body,
}),
}));
router.fallback(async ({ event }) => ({
statusCode: 404,
body: JSON.stringify({
message: 'Not Found',
path: event.path,
}),
}));
export const handler = router.handle();Route Parameters
Any segment that starts with : is a named parameter. Captured values are merged
into pathParameters (alongside any values API Gateway already provided), and
captured values win on key conflicts:
router.get('/api/tenants/:tenantId/runs/:runId', async ({ pathParameters }) => ({
body: JSON.stringify({
tenantId: pathParameters?.tenantId,
runId: pathParameters?.runId,
}),
}));Values are URL-decoded (/users/john%20doe yields john doe). When two dynamic
routes could match the same path, the one whose static segment appears earlier
from left to right wins, so /a/b/:y is preferred over /a/:x/c for /a/b/c.
Registering two routes that resolve to the same pattern (such as /a/:x and
/a/:y), reusing a parameter name, or using an empty : name throws at
registration time.
The native HTTP router exposes captured values on request.params instead, since
its handlers receive the raw request and response:
import type { RequestWithParams } from '@vyriy/router/http';
httpRouter.get('/api/tenants/:tenantId', (request, response) => {
const { tenantId } = (request as RequestWithParams).params ?? {};
response.writeHead(200).end(JSON.stringify({ tenantId }));
});All Methods
Every variant supports all(path, handler) for routes that accept any HTTP
method, which suits transports that multiplex methods on one path (such as MCP
Streamable HTTP). Method-specific routes take precedence over all routes on the
same path, and all paths may use :param segments too:
router.all('/webhook/:provider', async ({ pathParameters, event }) => ({
body: JSON.stringify({
provider: pathParameters?.provider,
method: event.httpMethod,
}),
}));Stream Router
Use the streaming router when handlers write directly to a Lambda response stream. The stream is passed as the second handler argument, and stream handlers do not return a response object.
Import it from the @vyriy/router/stream subpath (clean createRouter/router names), or use the aggregated createStreamRouter alias from the root entry.
import { createRouter as createStreamRouter } from '@vyriy/router/stream';
const streamRouter = createStreamRouter();
streamRouter.get('/events', ({ event, query }, responseStream) => {
responseStream.setContentType?.('text/plain');
responseStream.write(`path: ${event.path}\n`);
responseStream.write(`cursor: ${query?.cursor ?? 'start'}\n`);
responseStream.end('done');
});
streamRouter.fallback(({ event }, responseStream) => {
responseStream.setContentType?.('application/json');
responseStream.end(
JSON.stringify({
message: 'Not Found',
path: event.path,
}),
);
});
export const handler = streamRouter.handle();HTTP Router
Use createHttpRouter() when handlers work directly with native Node IncomingMessage and ServerResponse objects. Handlers own the response lifecycle and write the response themselves, which fits transports such as MCP Streamable HTTP.
In addition to the method helpers, the HTTP router supports all(path, handler) for routes that accept any HTTP method. Method-specific routes take precedence over all routes on the same path.
Import it from the @vyriy/router/http subpath (clean createRouter/router names), or use the aggregated createHttpRouter alias from the root entry.
import { createRouter as createHttpRouter } from '@vyriy/router/http';
const httpRouter = createHttpRouter();
httpRouter.get('/health', (request, response) => {
response
.writeHead(200, {
'content-type': 'application/json',
})
.end(JSON.stringify({ ok: true }));
});
// transports that handle multiple HTTP methods on one path, such as MCP Streamable HTTP
httpRouter.all('/mcp', async (request, response) => {
await transport.handleRequest(request, response);
});
httpRouter.fallback((request, response) => {
response
.writeHead(404, {
'content-type': 'application/json',
})
.end(
JSON.stringify({
message: 'Not Found',
path: request.url,
}),
);
});
export const handler = httpRouter.handle();Calm Composition
The router keeps request matching separate from handler wrappers and local server adapters. A small API can stay as a plain composition of focused packages:
import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';
import { server } from '@vyriy/server';
const router = createRouter();
router.get('/health', () => ({
body: JSON.stringify({
ok: true,
}),
}));
const handler = api(router.handle());
server(handler);The same shape works for Lambda response streaming:
import { streamApi } from '@vyriy/handler';
import { createStreamRouter } from '@vyriy/router';
import { streamServer } from '@vyriy/server';
const router = createStreamRouter();
router.get('/events', (_params, responseStream) => {
responseStream.setContentType?.('text/plain');
responseStream.end('ok');
});
const handler = streamApi(router.handle());
streamServer(handler);And for native Node HTTP handlers:
import { httpApi } from '@vyriy/handler';
import { createHttpRouter } from '@vyriy/router';
import { httpServer } from '@vyriy/server';
const router = createHttpRouter();
router.get('/health', (request, response) => {
response
.writeHead(200, {
'content-type': 'application/json',
})
.end(JSON.stringify({ ok: true }));
});
const handler = httpApi(router.handle());
httpServer(handler);For a Lambda-only entrypoint, keep the same composition and export the handler:
import { api } from '@vyriy/handler';
import { createRouter } from '@vyriy/router';
const router = createRouter();
router.get('/health', () => ({
body: JSON.stringify({
ok: true,
}),
}));
export const handler = api(router.handle());Exports
The classic Lambda router lives at the root entry, while the streaming and native HTTP variants each have their own subpath with clean, unprefixed names:
import { createRouter, router } from '@vyriy/router';
import { createRouter, router } from '@vyriy/router/http';
import { createRouter, router } from '@vyriy/router/stream';The root entry also re-exports every variant under prefixed names, so a single import keeps working:
import { createHttpRouter, createRouter, createStreamRouter } from '@vyriy/router';
import { HttpRouter, Router, StreamRouter } from '@vyriy/router';The low-level Router classes are exported from each entry: Router from @vyriy/router,
@vyriy/router/http, and @vyriy/router/stream, plus the aggregated HttpRouter and
StreamRouter names from the root entry.
API
createRouter()returns a chainable router API.createStreamRouter()returns a chainable response streaming router API.createHttpRouter()returns a chainable native HTTP router API.- Paths may contain
:paramsegments on every method helper,all, andon. router.all(path, handler)registers a handler for any HTTP method on a path.router.get(path, handler)registers aGEThandler.router.post(path, handler)registers aPOSThandler.router.put(path, handler)registers aPUThandler.router.delete(path, handler)registers aDELETEhandler.router.patch(path, handler)registers aPATCHhandler.router.fallback(handler)registers a handler for unmatched requests.router.handle()returns(event) => router.route(event)for API Gateway wrappers.router.route(event)resolves the matching route and returns an API Gateway response.streamRouter.all(path, handler)registers a stream handler for any HTTP method on a path.streamRouter.handle()returns(event, responseStream) => streamRouter.route(event, responseStream)for stream wrappers.streamRouter.route(event, responseStream)resolves the matching route and writes to the stream.httpRouter.all(path, handler)registers a handler for any HTTP method on an exact path.httpRouter.handle()returns(request, response) => httpRouter.route(request, response)for native HTTP wrappers.httpRouter.route(request, response)resolves the matching route and lets the handler write the response.
Route handlers may omit statusCode; the router normalizes missing status codes to 200 before returning from router.route(event).
The low-level Router, StreamRouter, and HttpRouter classes are also available from @vyriy/router (each variant additionally exports its class as Router from its own subpath):
router.on(method, path, handler)router.all(path, handler)router.fallback(handler)router.route(event)streamRouter.on(method, path, handler)streamRouter.all(path, handler)streamRouter.fallback(handler)streamRouter.route(event, responseStream)httpRouter.on(method, path, handler)httpRouter.all(path, handler)httpRouter.fallback(handler)httpRouter.route(request, response)
Route handlers receive:
type HandlerParams = {
query?: APIGatewayProxyEventQueryStringParameters;
body?: string;
headers?: APIGatewayProxyEvent['headers'];
pathParameters?: APIGatewayProxyEvent['pathParameters'];
event: APIGatewayProxyEvent;
};Stream route handlers receive the same HandlerParams as the first argument and ResponseStream as the second argument:
type StreamHandler = (params: HandlerParams, responseStream: ResponseStream) => void | Promise<void>;HTTP route handlers receive the native Node request and response objects directly:
type HttpHandler = (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;When no HTTP route matches and no fallback is registered, the HTTP router writes a JSON 404 response itself.
