Middleware & Context
Vyuh Server uses onion middleware — each layer wraps the next, with the handler at the center. The outermost layer sees the request first and the response last; the innermost runs closest to the handler.
The Stack
When runtime.start() builds the server, layers install in this order (innermost first):
MiddlewareSpec
Plugins and features contribute middleware through MiddlewareSpec:
final spec = MiddlewareSpec(
path: '/api',
middleware: rateLimitMiddleware(maxPerMinute: 1000),
order: 50,
);Three fields control where the middleware sits:
path— the path prefix the middleware applies to./means every request.middleware— the RelicMiddlewarefunction.order— numeric priority. Larger order = outermost. Plugins and features merge into one stack sorted descending. Ties break by declaration order, with plugins first.
FeatureDescriptor(
name: 'rate_limit',
middlewares: [
MiddlewareSpec(
path: '/api',
middleware: rateLimitMiddleware(maxPerMinute: 1000),
order: 50,
),
],
);Reasoning About Order
Concrete numbers don't matter; the relative order does. A useful convention:
| Order range | Layer |
|---|---|
1000+ | Cross-cutting (rate limit, CORS, logging) |
500–999 | Tenancy, request enrichment |
100–499 | Domain-specific (audit, idempotency keys) |
0–99 | Pre-handler validators |
If unsure, run with GlobalMiddleware.requestLogger() and verify the layering by reading the per-request log.
Auth Middleware
Auth has two layers. Apps include GlobalMiddleware.auth() in runtime.start(middleware: [...]) so every request gets req.actor. The framework then installs GlobalMiddleware.requireAuthenticated() on every protected path declared by your routes. There are two ways to declare a path as protected:
1. Via RouteModule.protectedPaths
RouteModule(
name: 'admin',
basePath: '/admin',
protectedPaths: ['/'],
setup: (scope) { ... },
);Every path under /admin requires an authenticated Actor.
2. Via scope.protect(...) in extend
extend: (scope) {
scope.protect(['/internal', '/admin']);
scope.get('/internal/flush', _flushHandler);
}The framework walks the merged protected-path set and installs the authenticated gate once per path. Inside a protected handler:
Future<Response> getMe(Request req) async {
final actor = req.actor;
return jsonResponse(body: {'success': true, 'data': {'user_id': actor.id}});
}If no auth strategy resolved an Actor, the framework returns the fallback actor (default AnonymousActor) — or, if AnonymousActor is hitting a protected path, a 401.
Per-Request Context Providers
A ContextProvider runs once per request, derives a typed value, and registers it on req.di. Handlers downstream resolve via req.di.get<T>().
FeatureDescriptor(
name: 'tenancy',
context: [
ContextProvider.of<TenantId>((req) {
final id = req.headers.value('x-tenant-id');
if (id == null) throw MissingTenantException();
return TenantId(id);
}),
],
);Future<Response> listOrders(Request req) async {
final tenant = req.di.get<TenantId>();
final actor = req.actor;
// …
}Context providers run inside the auth layer — auth happens first, then context. So a provider can rely on req.actor already being resolved.
Provider order: plugin providers first (infrastructure), then feature providers. Within each, declaration order.
User-Supplied Global Middleware
The standard middleware stack ships with the framework, but you wire the actual instances yourself in runtime.start:
await runtime.start(
port: 8080,
middleware: [
GlobalMiddleware.cors(),
GlobalMiddleware.errorHandler(),
GlobalMiddleware.requestLogger(),
],
);This list is installed in reverse order, so the last one (GlobalMiddleware.requestLogger()) becomes outermost — the convention matches "logging wraps everything else."
Built-in Middleware
The framework's built-in middleware factories live on the GlobalMiddleware namespace. IDE autocomplete on GlobalMiddleware. reveals the full menu — no guessing function names.
| Factory | Purpose |
|---|---|
GlobalMiddleware.cors() | Standard CORS headers. Reads ALLOWED_ORIGINS / ALLOWED_ORIGIN_SUFFIXES; pass allowAllIfNotConfigured: true for service-to-service |
GlobalMiddleware.errorHandler() | Catches uncaught exceptions, formats via vyuh.errors (ErrorCodeRegistry), returns structured JSON. Optional errorHandler: for domain-specific shapes |
GlobalMiddleware.requestLogger() | One log line per request: method, path, status, duration, request id. Substitute sink: for OTel-style logging |
GlobalMiddleware.auth() | Resolves req.actor; include it in runtime.start(middleware: [...]) when routes use auth |
GlobalMiddleware.clientContext() | Mounted automatically by runtime.start as the outermost layer. Populates req.clientContext |
The error handler reads vyuh.errors (the merged ErrorCodeRegistry) to format thrown exceptions. Plugins and features contribute codes via ErrorCodesDescriptor.
Client Context
The outermost layer is clientContextMiddleware, mounted automatically by runtime.start(clientContext: true) (the default). It populates a typed ClientContext on every request:
final ctx = req.clientContext;
ctx.requestId; // X-Request-Id (created if absent)
ctx.ipAddress; // From X-Forwarded-For, X-Real-IP, or socket
ctx.userAgent; //
ctx.deviceHints; // Parsed UA — device type, OS family
ctx.productCode; // Optional X-Product-Code headerThe framework echoes the request id back on the response, so traces correlate end-to-end. Pass clientContext: false if you wire your own equivalent.
Where to Go Next
- The
vyuhAccessor — typed verbs from handlers - Auth Plugins — strategy chains, JWT, API keys
- Error Handling Guide — structured errors end-to-end