Class-based routing framework for Cloudflare Workers and Durable Objects built on itty-router.
ctx argument with request, env, params, state, and responsenpm install @whi/cf-routing
import { WorkerRouter, RouteHandler, Context } from '@whi/cf-routing';
class HealthHandler extends RouteHandler {
async get(ctx: Context) {
return { status: 'healthy' };
}
}
const router = new WorkerRouter()
.defineRouteHandler('/health', HealthHandler)
.build();
export default {
async fetch(request, env, ctx) {
return router.fetch(request, env, ctx);
},
};
import { DurableObjectRouter, DurableObjectRouteHandler, DurableObjectContext } from '@whi/cf-routing';
class CounterHandler extends DurableObjectRouteHandler {
async get(ctx: DurableObjectContext) {
// Access storage via this.storage (flattened from DurableObjectState)
return { count: await this.storage.get('count') || 0 };
}
async post(ctx: DurableObjectContext) {
const count = await this.storage.get('count') || 0;
await this.storage.put('count', count + 1);
return { count: count + 1 };
}
}
export class Counter {
constructor(state, env) {
this.router = new DurableObjectRouter(state, env, 'counter')
.defineRouteHandler('/count', CounterHandler);
}
async fetch(request) {
return this.router.handle(request);
}
}
Create route handlers by extending the base classes. All handler methods receive a ctx object:
class UserHandler extends RouteHandler<Env, { id: string }> {
async get(ctx: Context<Env, { id: string }>) {
return { userId: ctx.params.id };
}
async post(ctx: Context<Env, { id: string }>) {
const body = await ctx.request.json();
return { userId: ctx.params.id, created: true };
}
}
router.defineRouteHandler('/users/:id', UserHandler);
The ctx object contains:
ctx.request - The incoming Requestctx.env - Environment bindings (Worker handlers only)ctx.params - Route parameters (e.g., { id: '123' })ctx.data - Shared data for middleware communicationctx.response - Response customization (status, headers)ctx.log - Logger instanceFor DurableObject handlers, the handler instance also has:
this.storage - DurableObjectStorage (flattened from state)this.id - DurableObjectIdthis.state - Raw DurableObjectState (for blockConcurrencyWhile, etc.)this.env - Environment bindingsThrow HttpError for proper HTTP status codes:
import { HttpError } from '@whi/cf-routing';
async get(ctx: Context<Env, { id: string }>) {
if (!ctx.params?.id) {
throw new HttpError(400, 'ID required');
}
// Errors automatically become JSON responses
}
Customize status codes and headers via ctx.response:
async post(ctx: Context) {
ctx.response.status = 201;
ctx.response.headers.set('Set-Cookie', 'session=abc123');
return { created: true };
}
Or return a Response directly for full control:
async get(ctx: Context) {
return new Response('<html>...</html>', {
headers: { 'Content-Type': 'text/html' }
});
}
Middleware uses the Koa/Hono-style next() pattern for pre/post processing:
import { Middleware } from '@whi/cf-routing';
const authMiddleware: Middleware<Env> = async (ctx, next) => {
// Pre-processing
const token = ctx.request.headers.get('Authorization');
if (!token) {
throw new HttpError(401, 'Unauthorized');
}
ctx.data.userId = validateToken(token);
// Call next middleware/handler
const response = await next();
// Post-processing (optional)
return response;
};
router
.use(authMiddleware) // Global middleware
.use('/api/*', rateLimitMiddleware) // Path-specific middleware
.defineRouteHandler('/api/users', UserHandler);
For DurableObject middleware, the signature is (ctx, state, next) where state is the DurableObjectState:
import { DurableObjectMiddleware } from '@whi/cf-routing';
const sessionMiddleware: DurableObjectMiddleware = async (ctx, state, next) => {
const session = await state.storage.get('session');
ctx.data.session = session;
return next();
};
Configure CORS at the router level or per-handler with dynamic control:
// Router-level CORS (applies to all handlers without their own cors())
const router = new WorkerRouter<Env>('api', {
cors: { origins: '*' }
});
// Per-handler dynamic CORS
class ApiHandler extends RouteHandler<Env> {
cors(ctx: Context<Env>) {
const origin = ctx.request.headers.get('Origin');
// Allow specific subdomains
if (origin?.endsWith('.myapp.com')) {
return { origins: origin, credentials: true };
}
return undefined; // Use router default
}
async get(ctx: Context<Env>) {
return { data: 'hello' };
}
}
Dynamic Origins from Environment Variables
For Cloudflare Workers, allowed origins are often configured via environment variables. Use a function for origins to access env and middleware-set data:
const router = new WorkerRouter<Env>('api', {
cors: {
origins: ({ request, env, data }) => {
const origin = request.headers.get('Origin');
const allowed = env.ALLOWED_ORIGINS?.split(',') || [];
return origin && allowed.includes(origin) ? origin : null;
},
credentials: true,
}
});
The function receives:
request - The incoming Requestenv - Environment bindings (secrets, KV, etc.)data - Middleware-set data (useful if middleware determines allowed origins)Middleware on OPTIONS Requests
Middleware runs for all requests including OPTIONS preflight. This enables rate limiting, logging, and authentication checks on preflight requests:
router.use(async (ctx, next) => {
// This runs for GET, POST, OPTIONS, etc.
console.log(`${ctx.request.method} ${ctx.request.url}`);
return next();
});
CORS headers are automatically consistent between OPTIONS preflight and actual responses.
The router includes a built-in Logger that integrates with Cloudflare's observability dashboard. Set the log level via the LOG_LEVEL environment variable.
Log Levels (from most to least verbose):
trace - Detailed request flow (incoming request, middleware chain, handler execution)debug - Route matching with paramsinfo - Request completed with status and durationwarn - Warningserror - Errorsfatal - Critical errorsBuilt-in Logging
The router automatically logs at these levels:
[router-name] [TRACE] Incoming request {"method":"GET","path":"/users/123"}
[router-name] Route matched {"path":"/users/:id","params":{"id":"123"}}
[router-name] [TRACE] Middleware chain {"count":2}
[router-name] [TRACE] Executing handler {"method":"get"}
[router-name] Request completed {"method":"GET","path":"/users/123","status":200,"duration":45}
Using the Logger
Access ctx.log in handlers and middleware with structured data:
async get(ctx: Context<Env, { id: string }>) {
ctx.log.info('Fetching user', { userId: ctx.params.id });
const user = await getUser(ctx.params.id);
if (!user) {
ctx.log.warn('User not found', { userId: ctx.params.id });
throw new HttpError(404, 'User not found');
}
return user;
}
Configuration
Set LOG_LEVEL in your wrangler.toml:
[vars]
LOG_LEVEL = "info" # or "debug", "trace", etc.
https://webheroesinc.github.io/js-cf-routing/
API documentation is automatically generated from source code using TypeDoc and deployed on every push to master.
To generate locally:
npm run docs # Generate documentation in docs/
npm run docs:watch # Generate docs in watch mode
See CONTRIBUTING.md for development setup, testing, and contribution guidelines.
npm test # Run all tests
npm run test:unit # Unit tests only
npm run test:integration # Integration tests only
npm run test:coverage # With coverage report
npm run build # Build TypeScript to lib/
npm run format # Format code with Prettier
LGPL-3.0
Built on top of itty-router by Kevin Whitley.