@malobre/bihan
v0.3.0
Published
A tiny, type-safe router built on URLPattern with zero dependencies.
Readme
🤏 Bihan
A tiny, type-safe router built on URLPattern with zero dependencies.
⚠️ Experimental Package
This package is experimental and under active development. Routing performance is sub-optimal (routes are matched linearly). The primary goal of this experiment is to develop a type-safe routing API.
Installation
npm install @malobre/bihanUsage
import { route } from '@malobre/bihan';
await route(
({ on }) => [
// Simple route returning data
on('GET', '/health').pipe(() => ({ status: 'ok' })),
// Route with path parameters
on('GET', '/users/:id').pipe((ctx) => {
const userId = ctx.urlPatternResult.pathname.groups['id'];
return Response.json({ userId });
}),
// Middleware with context augmentation
on('GET', '/api/user')
.pipe((ctx) => ctx.with({user: "John"}))
.pipe((ctx) => {
// ctx.user is properly typed
return Response.json({ message: `Hello ${ctx.user}` });
}),
],
request
);API
route(createRoutes, request, ctxData?)
Routes an incoming request to the first matching handler.
Parameters:
createRoutes- Factory function that returns an array of routesrequest- The incomingRequestobjectctxData- Optional initial context data available to all handlers
Returns: The handler result, or undefined if no route matched.
Behavior:
- Routes are matched in order - first match wins
- Errors from handlers propagate to the caller
on(method, pattern)
Registers a route and returns a pipe builder.
Parameters:
method- an HTTP method, an array of methods, orAnyMethodsymbolpattern- URL pattern as:- String (interpreted as pathname):
'/users/:id' - URLPattern object:
new URLPattern({ pathname: '/users/:id' }) - URLPatternInit:
{ pathname: '/users/:id', search: '*' }
- String (interpreted as pathname):
Returns: A pipe builder with a .pipe(handler) method for adding handlers.
Pipe Behavior:
Handlers are added using .pipe(handler) and receive a Context<T>. They can return:
Contextobject - pass the context to the next handlerundefined- keep the current context for the next handlerNoMatch- tells the router to try other routes- Any other value - Terminates the route and returns that value
Best Practices
Route Organization
Group related routes together and order from most specific to least specific:
await route(({ on }) => [
// Specific routes first
on('GET', '/api/users/:id').pipe(getUserById),
on('POST', '/api/users').pipe(createUser),
// Wildcards last
on('GET', '/api/*').pipe(catchAllApi),
], request);Reusable Middleware
Create composable middleware by defining handler functions:
import type { Context } from '@malobre/bihan';
// Validation middleware - generic over context type
const validateBody = async <TCtxData>(ctx: Context<TCtxData>) => {
const body = await ctx.request.json();
if (!body.name) {
return Response.json({ error: 'Name required' }, { status: 400 });
}
return ctx.with({ body });
};Advanced Features
Custom Context Data
Pass initial context to all handlers via the third parameter:
const appContext = {
db: database,
config: appConfig,
};
await route(
({ on }) => [
on('GET', '/users').pipe(async (ctx) => {
// ctx.db and ctx.config are available and properly typed
const users = await ctx.db.query('SELECT * FROM users');
return Response.json(users);
}),
],
request,
appContext
);Full URLPattern Support
Use any URLPattern features, not just pathname:
on('GET', {
pathname: '/api/:version/*',
search: 'key=:apiKey',
}).pipe((ctx) => {
const { version } = ctx.urlPatternResult.pathname.groups;
const { apiKey } = ctx.urlPatternResult.search.groups;
return Response.json({ version, apiKey });
})
// Or use URLPattern directly
const pattern = new URLPattern({
protocol: 'https',
hostname: 'api.example.com',
pathname: '/v:version/*',
});
on('GET', pattern).pipe((ctx) => {
// Full control over matching
})Helper Utilities
Bihan provides helper utilities for common middleware tasks:
withHeader
Validates that a header is present or has a specific value:
import { withHeader } from '@malobre/bihan/with-header.js';
on('POST', '/api/data')
.pipe(withHeader('Content-Type', 'application/json'))
.pipe((ctx) => {
// Content-Type is validated
return Response.json({ success: true });
})
// Or just check for presence
on('POST', '/api/data')
.pipe(withHeader('X-API-Key'))
.pipe((ctx) => {
// X-API-Key header is present
return Response.json({ success: true });
})withContentType
Validates the Content-Type header (case-insensitive):
import { withContentType } from '@malobre/bihan/with-content-type.js';
on('POST', '/api/data')
.pipe(withContentType('application/json'))
.pipe((ctx) => {
// Content-Type is validated
return Response.json({ success: true });
})withAuthorization
Parses and validates the Authorization header:
import { withAuthorization } from '@malobre/bihan/with-authorization.js';
on('GET', '/api/protected')
.pipe(withAuthorization((value, ctx) => {
if (value === null) {
return new Response('missing `Authorization` header', { status: 401 });
}
const { scheme, credentials } = value;
if (scheme !== 'Bearer' || !isValidToken(credentials)) {
return new Response('Invalid token', { status: 401 });
}
return ctx.with({ token: credentials });
}))
.pipe((ctx) => {
// ctx.token is available
return Response.json({ data: 'protected' });
})