ADR-008: Middleware Support (Global and Per-Route)
Status: Final Date: 2025-10-29 Related: ADR-005 (builder pattern), ADR-007 (export-with-helpers)
Context
Current implementation only supports message handlers and lifecycle callbacks (onOpen, onClose). Cross-cutting concerns like authentication, logging, rate limiting, and validation require duplicating code across handlers or using external patterns (context mutation, helper functions).
// Current approach: Duplicated auth check in every handler
router.on(LoginMessage, (ctx) => {
// No auth check needed
});
router.on(SecureMessage, (ctx) => {
if (!ctx.data?.userId) {
ctx.send(ErrorMessage, { code: "UNAUTHENTICATED" });
return;
}
// Handle secure message
});
router.on(AnotherSecureMessage, (ctx) => {
if (!ctx.data?.userId) {
ctx.send(ErrorMessage, { code: "UNAUTHENTICATED" });
return;
}
// Handle another secure message
});This pattern:
- Duplicates logic across handlers
- Breaks DRY principle — Same auth check repeated everywhere
- Harder to test — Can't test auth logic independently
- Scales poorly — Adding new cross-cutting concern requires touching all handlers
Decision
Introduce middleware similar to Express/Hono:
- Global middleware via
router.use((ctx, next) => { ... }) - Per-route middleware via
router.route(schema).use((ctx, next) => { ... }).on(handler)— builder pattern - Standard
(ctx, next)signature — Familiar pattern from Express/Hono - Synchronous and async support — Both
syncandasyncmiddleware work - Execution order — Global middleware first, then per-route, then handler
Type Signature
type Middleware<TContext extends ConnectionData = ConnectionData> = (
ctx: MinimalContext<TContext>,
next: () => Promise<void>,
) => Promise<void>;
interface RouterCore<TContext extends ConnectionData = ConnectionData> {
/**
* Register global middleware (runs for all messages).
* Executed before per-route middleware and handlers.
*/
use(middleware: Middleware<TContext>): this;
/**
* Fluent builder to register per-route middleware.
* Returns a RouteBuilder for chaining `.use()` calls and `.on(handler)`.
*/
route<S extends MessageSchema>(schema: S): RouteBuilder<S, TContext>;
}
interface RouteBuilder<
S extends MessageSchema,
TContext extends ConnectionData = ConnectionData,
> {
/**
* Register per-route middleware (runs only for this message type).
* Can be chained multiple times. Executed after global middleware, before handler.
*/
use(middleware: Middleware<TContext>): this;
/**
* Register handler for this route. Executes after all middleware.
*/
on(
handler: (ctx: MessageContext<S, TContext>) => void | Promise<void>,
): RouterCore<TContext>;
}Key design choice: Per-route middleware is payload-blind — it sees MinimalContext<TContext> with only connection data, not the specific message type. This keeps middleware composable and router-generic-only. Handler .on() provides the specific schema type for full payload access.
Implementation Pattern
export class WebSocketRouter<TContext extends ConnectionData = ConnectionData> {
private globalMiddleware: Array<Middleware<TContext>> = [];
private routes: Map<string, RouteEntry<any, TContext>> = new Map();
use(middleware: Middleware<TContext>): this {
this.globalMiddleware.push(middleware);
return this;
}
route<S extends MessageSchema>(schema: S): RouteBuilder<S, TContext> {
return new RouteBuilderImpl(this, schema);
}
private async executeMiddlewareChain(
ctx: MinimalContext<TContext>,
middleware: Middleware<TContext>[],
handler: () => Promise<void>,
): Promise<void> {
let index = -1;
const dispatch = async (i: number): Promise<void> => {
if (i <= index) throw new Error("next() called multiple times");
index = i;
const mw = middleware[i];
if (!mw) return handler();
await mw(ctx, () => dispatch(i + 1));
};
await dispatch(0);
}
async handleMessage(ctx: MessageContext<any, TContext>): Promise<void> {
const route = this.routes.get(ctx.type);
if (!route) return;
const allMiddleware = [...this.globalMiddleware, ...route.middlewares];
await this.executeMiddlewareChain(ctx, allMiddleware, () =>
route.handler(ctx),
);
}
}Usage Examples
Global Authentication Middleware
router.use((ctx, next) => {
if (!ctx.data?.userId && ctx.type !== "LOGIN") {
ctx.error("UNAUTHENTICATED", "Not authenticated");
return; // Skip handler by not calling next()
}
return next();
});
router.on(LoginMessage, (ctx) => {
// Authenticate and set userId
ctx.assignData({ userId: "123" });
});
router.on(SecureMessage, (ctx) => {
// Auth already checked by middleware
// ctx.data.userId is guaranteed to exist
console.log(`Secure message from ${ctx.data.userId}`);
});Global Logging Middleware
router.use((ctx, next) => {
const start = performance.now();
const result = next();
const duration = performance.now() - start;
if (result instanceof Promise) {
return result.then((r) => {
console.log(`[${ctx.type}] ${duration.toFixed(2)}ms`);
return r;
});
}
console.log(`[${ctx.type}] ${duration.toFixed(2)}ms`);
return result;
});Per-Route Rate Limiting
const rateLimiter = new Map<string, { count: number; resetAt: number }>();
router
.route(SendMessage)
.use((ctx, next) => {
const userId = ctx.data?.userId || "anon";
const now = Date.now();
const state = rateLimiter.get(userId);
if (state && state.resetAt > now && state.count >= 10) {
ctx.error("RESOURCE_EXHAUSTED", "Too many messages");
return; // Skip handler
}
if (!state || state.resetAt <= now) {
rateLimiter.set(userId, { count: 1, resetAt: now + 60000 });
} else {
state.count++;
}
return next();
})
.on((ctx) => {
// Rate limit already checked
processMessage(ctx.payload);
});Per-Route Validation Enrichment
// Enrich context with validated data before handler
router
.route(QueryMessage)
.use((ctx, next) => {
try {
ctx.assignData({
query: parseQuery(ctx.payload.q),
});
} catch (err) {
ctx.error("INVALID_ARGUMENT", "Invalid query syntax");
return; // Skip handler
}
return next();
})
.on((ctx) => {
const query = (ctx.data as any)?.query; // Pre-validated by middleware
const results = database.search(query);
ctx.send(QueryResultsMessage, { results });
});Async Middleware (External Service Calls)
router.use((ctx, next) => {
return fetch(`/check-feature/${ctx.type}`)
.then((res) => res.json())
.then(({ enabled }) => {
if (!enabled) {
ctx.error("UNAVAILABLE", "Feature is disabled");
return;
}
return next();
})
.catch((err) => {
console.error("Feature check failed:", err);
ctx.error("INTERNAL", "Feature check failed");
});
});Middleware Semantics
Execution Order
- Global middleware (in registration order)
- Per-route middleware (in registration order)
- Handler (if all middleware called
next())
Control Flow
Calling next(): Proceeds to next middleware or handler
router.use((ctx, next) => {
console.log("Before handler");
const result = next();
console.log("After handler");
return result;
});Skipping handler: Return without calling next()
router.use((ctx, next) => {
if (!isAllowed(ctx)) {
ctx.error("PERMISSION_DENIED", "Access denied");
return; // Handler won't run
}
return next();
});Async middleware: Return Promise that resolves when done
router.use(async (ctx, next) => {
const allowed = await checkPermission(ctx.data?.userId);
if (!allowed) {
ctx.error("PERMISSION_DENIED", "Access denied");
return;
}
return next();
});Context Visibility
Middleware is payload-blind — it sees MinimalContext<TContext> with only connection data:
type Middleware<TContext extends ConnectionData = ConnectionData> = (
ctx: MinimalContext<TContext>, // Only connection data (TContext), not message payload
next: () => Promise<void>,
) => Promise<void>;Why payload-blind? Middleware operates at the router level, above any specific message type. Per-route middleware doesn't need the specific message schema — it works with generic connection data (e.g., userId, roles). Payload access happens only in handlers via .on(), which provides the full MessageContext<S, TContext> with typed payload.
This design keeps middleware reusable and router-generic-only, avoiding tight coupling between middleware and message schemas.
Modifying Context
Middleware can modify ctx.data for handlers to access:
router.use((ctx, next) => {
// Compute derived data for handlers
ctx.assignData({
startedAt: Date.now(),
});
return next();
});
router.on(SomeMessage, (ctx) => {
const elapsed = Date.now() - (ctx.data as any).startedAt;
console.log(`Handler took ${elapsed}ms`);
});Error Handling in Middleware
Synchronous errors in middleware:
router.use((ctx, next) => {
try {
const userId = JSON.parse(ctx.data?.rawUserId);
ctx.assignData({ userId });
} catch (err) {
ctx.error("INVALID_ARGUMENT", "Invalid user ID format");
return; // Skip handler
}
return next();
});Unhandled async errors:
router.use(async (ctx, next) => {
try {
const allowed = await checkPermission(ctx.data?.userId);
if (!allowed) {
ctx.error("PERMISSION_DENIED", "Access denied");
return;
}
} catch (err) {
// Log but don't expose internal error
console.error("Permission check failed:", err);
ctx.error("INTERNAL", "Could not verify permissions");
return;
}
return next();
});If middleware throws an unhandled error:
- Caught by router
onErrorhook called (if registered inserve()options)- Handler is NOT executed
- Connection stays open (error sent via
ctx.error()if available)
API Shape: Builder vs Overload
The Decision: Builder Pattern
Per-route middleware uses the builder pattern: router.route(schema).use(mw).on(handler)
This was chosen over adding a router.use(schema, mw) overload.
Why Builder Pattern?
- Single registration path — All per-route middleware flows through one entry point (the builder), making code easier to trace and maintain
- Minimal API surface — No overload ambiguity (is this global or per-route middleware?)
- Fluent, composable — Chain multiple
.use()calls before.on()with clean syntax - Aligns with router structure — Mirrors how handlers are registered:
router.route(schema).on(handler) - Forces explicit ordering — Builder makes it clear what's global vs per-route
Why Not Overload?
Adding router.use(schema, mw) overload would:
- Create ambiguity:
use(param1, param2)— is this global with mw param, or per-route? - Bloat the surface: developers must remember both
use(mw)anduse(schema, mw) - Make per-route middleware look like global (different semantics, same call site)
- Reintroduce overload complexity that builder pattern explicitly avoids
Trade-off: DX
Builder pattern is slightly more verbose than Express/Hono style:
// Express/Hono style (hypothetical)
router.use(SendMessage, (ctx, next) => { ... });
// WS-Kit builder style (actual)
router.route(SendMessage).use((ctx, next) => { ... }).on(handler);Why acceptable: The builder pattern is still concise, instantly clear on intent, and aligns with the broader API philosophy (route composition > function overloading). Examples show it's readable and natural once familiar.
Consequences
Benefits
✅ DRY principle — Write auth, logging, validation once, apply everywhere ✅ Express/Hono familiarity — Similar API to industry-standard frameworks ✅ Clear execution order — Global → per-route → handler ✅ Flexible — Both sync and async supported ✅ Can skip handler — Middleware can prevent handler execution by not calling next() ✅ Can enrich context — Middleware can set data for handlers via assignData() ✅ Testable — Middleware can be tested independently from handlers
Trade-offs
⚠️ Execution overhead — Each middleware adds a function call (negligible) ⚠️ Cognitive complexity — Requires understanding middleware execution order ⚠️ Error handling — Unhandled errors in middleware should be caught and logged ⚠️ Payload-blind middleware — Middleware doesn't see the specific message type/payload (intentional design; see "API Shape" section)
Alternatives Considered
1. Decorator-Based Middleware
Use decorators (TypeScript experimental feature):
@Authenticated
@RateLimited
router.on(SecureMessage, (ctx) => { ... });Why rejected:
- Requires TypeScript experimental decorators (not standard)
- Less composable (hard to conditionally apply)
- Harder to order (decorators are "stacked" but order not always clear)
- Smaller ecosystem compared to middleware pattern
2. Event Emitter Pattern
Emit events before/after handlers:
router.on("before:*", (ctx) => { ... });
router.on("after:*", (ctx) => { ... });Why rejected:
- Less control (can't prevent handler execution as naturally)
- Weaker type safety (before/after events have different context)
- Less familiar pattern in JS/TS community
3. Handler Composition (Wrapper Functions)
Wrap handlers in middleware:
const withAuth = (handler) => (ctx) => {
if (!ctx.data?.userId) {
ctx.error("UNAUTHENTICATED", "Not authenticated");
return;
}
return handler(ctx);
};
router.on(
SecureMessage,
withAuth((ctx) => {
// Handle secure message
}),
);Why rejected:
- Boilerplate on every protected handler
- Doesn't scale (multiple middleware require nested composition)
- Harder to test
References
- ADR-005: Builder Pattern (router structure supporting middleware)
- ADR-007: Export-with-Helpers (uses middleware in examples)
- ADR-009: Error Handling and Lifecycle Hooks (related to error flow)
- Implementation:
packages/core/src/router.ts— Router middleware chain implementationpackages/core/test/features/middleware.test.ts— Comprehensive middleware tests
- Examples:
examples/quick-start/index.ts— Authentication middleware exampleexamples/*/middleware/*.ts— Domain-specific middleware examples
- Related: CLAUDE.md — Middleware patterns documented in Quick Start