Skip to content

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):

Onion middleware diagram showing seven concentric layers from outermost to innermost: Client Context, user-supplied global middleware, plugin and feature middlewares, auth on protected paths, per-request context providers, route-local middleware, and the Handler at the center

MiddlewareSpec

Plugins and features contribute middleware through MiddlewareSpec:

dart
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 Relic Middleware function.
  • order — numeric priority. Larger order = outermost. Plugins and features merge into one stack sorted descending. Ties break by declaration order, with plugins first.
dart
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 rangeLayer
1000+Cross-cutting (rate limit, CORS, logging)
500–999Tenancy, request enrichment
100–499Domain-specific (audit, idempotency keys)
0–99Pre-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

dart
RouteModule(
  name: 'admin',
  basePath: '/admin',
  protectedPaths: ['/'],
  setup: (scope) { ... },
);

Every path under /admin requires an authenticated Actor.

2. Via scope.protect(...) in extend

dart
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:

dart
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>().

dart
FeatureDescriptor(
  name: 'tenancy',
  context: [
    ContextProvider.of<TenantId>((req) {
      final id = req.headers.value('x-tenant-id');
      if (id == null) throw MissingTenantException();
      return TenantId(id);
    }),
  ],
);
dart
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:

dart
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.

FactoryPurpose
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:

dart
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 header

The 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