Documentation/titan/RPC Middleware
In this section

RPC Middleware

RPC middleware wraps Netron calls over HTTP. It runs on the server side, around dispatch. Use it for cross-cutting concerns that apply uniformly — auth checks, rate limiting, tracing, logging, metrics.

Middleware is an HTTP-transport feature. The pipeline lives in netron/transport/http/middleware/, and the real surface is the MiddlewarePipeline class (MiddlewarePipeline in netron/transport/http/middleware/pipeline.ts) — there is no INetronMiddleware interface and no netron.use() method. The persistent transports (WebSocket / TCP / Unix) do not instantiate this pipeline. They call the same invocation authorization function at packet dispatch that the HTTP authentication middleware uses: registered ACLs, declared method requirements and optional default-deny. The invocation wrapper receives the resulting authority context. So the patterns on this page apply when you serve over HTTP. (The transport-agnostic context type used below, NetronMiddlewareContext, is defined once in NetronMiddlewareContext in netron/transport/http/middleware/types.ts and reused by the HTTP-specific extension.)

Do not confuse with DI middleware, which wraps container resolution. See DI Middleware for the distinction.

The middleware function

A middleware is a plain function, not a class with handle():

import {
  type MiddlewareFunction,
  type NetronMiddlewareContext,
  MiddlewareStage,
} from '@omnitron-dev/titan/netron/transport/http/middleware';

const timing: MiddlewareFunction = async (ctx, next) => {
  const t0 = performance.now();
  await next();                       // returns void; result lives on ctx.result
  record(ctx.serviceName, ctx.methodName, performance.now() - t0);
};

ctx (NetronMiddlewareContext) carries:

  • peer — the LocalPeer | RemotePeer handling the call.

  • serviceName / methodName — what's being invoked.

  • input — the call input; result — the return value (set around next()).

  • error — set if dispatch threw.

  • metadata — a Map<string, unknown> for per-call data; the auth context and authorization header live here (ctx.metadata.get('authContext')).

  • timing — { start, middlewareTimes }.

  • skipRemaining — set true to short-circuit the rest of the stage.

The HTTP-specific HttpMiddlewareContext extends this with request, response, route, params, query, body, cookies.

next() invokes the next middleware (or dispatch). It returns Promise<void> — there is no result to return; read/write ctx.result instead.

Registering middleware

Middleware is registered on the HTTP server's MiddlewarePipeline, not on Netron. The pipeline exposes:

pipeline.use(fn, config?, stage?);                    // global
pipeline.useForService('users@1.0.0', fn, config?);   // one service
pipeline.useForMethod('users@1.0.0', 'findById', fn); // one method

Each registration takes an optional MiddlewareConfig ({ name, priority?, condition?, onError?, services?, methods? }) and a MiddlewareStage. In practice the HTTP server wires its own defaults (request-id, token extraction, the NetronAuthMiddleware); app bootstrap code adds custom middleware where it constructs the transport.

Stages and order

The pipeline runs in five stages (MiddlewareStage), and within a stage middleware is ordered by priority (lower runs first, default 100):

PRE_PROCESS → PRE_INVOKE → [dispatch] → POST_INVOKE → POST_PROCESS
                                                          ↘ ERROR (on throw)
  • PRE_PROCESS — earliest; the HTTP server registers request-id and token extraction here.

  • PRE_INVOKE — before the method runs; auth/authorization (NetronAuthMiddleware), rate limiting, validation belong here.

  • POST_INVOKE / POST_PROCESS — after dispatch; response shaping, compression, logging.

  • ERROR — runs when a stage throws.

Put cheaper checks before expensive ones (rate-limit lookup before a heavy parse) by giving them a lower priority.

A custom middleware — timing

import { type MiddlewareFunction, MiddlewareStage } from '@omnitron-dev/titan/netron/transport/http/middleware';

const timingMiddleware: MiddlewareFunction = async (ctx, next) => {
  const t0 = performance.now();
  try {
    await next();
    metrics.histogram('rpc.duration_ms', {
      service: ctx.serviceName,
      method:  ctx.methodName,
      outcome: 'ok',
    }).observe(performance.now() - t0);
  } catch (e) {
    metrics.histogram('rpc.duration_ms', {
      service: ctx.serviceName,
      method:  ctx.methodName,
      outcome: 'error',
    }).observe(performance.now() - t0);
    throw e;
  }
};

pipeline.use(timingMiddleware, { name: 'timing', priority: 5 }, MiddlewareStage.PRE_INVOKE);

Titan also ships ready-made factories on NetronBuiltinMiddleware (metrics, logging, circuit-breaker, retry, caching, …) and HttpBuiltinMiddleware (CORS, compression, body parsing, security headers).

Per-method policy via decorators

Method decorators (@Auth, @RateLimit, @Cache) are not middleware classes — they stamp reflect-metadata onto the method (the Auth decorator in decorators/core.ts, :805, :822, storing METHOD_AUTH / METHOD_RATE_LIMIT / METHOD_CACHE). The auth middleware (and, on WS/TCP, the inline dispatch path) reads that metadata and enforces it:

@Public()
@Auth({ roles: ['admin'], scopes: ['write:users'] })
@RateLimit({ limit: 5, window: 60_000 })
async dangerousOp() { /* … */ }

The same policy can be expressed inline on @Public itself (@Public({ auth: { roles: ['admin'] }, rateLimit: { limit: 5, window: 60_000 } })); the standalone decorators just keep the concerns visually separate.

Either way, enforcement runs inside the auth/rate-limit step of the pipeline (or inline on WS/TCP) — there is no separate per-method middleware instance. The global step does the heavy lifting (validate token, resolve user); the decorator metadata supplies the specific policy.

Scoping middleware to specific methods

There is no @Skip decorator. To limit (or exclude) middleware, use the registration filters on MiddlewareConfig — services / methods (string list or RegExp) and condition:

pipeline.use(authMiddleware, {
  name: 'auth',
  methods: /^(?!ping$).*/,        // everything except `ping`
});

The built-in auth middleware (createAuthMiddleware / NetronAuthMiddleware) also accepts skipServices / skipMethods options (AuthMiddlewareOptions), and any middleware can set ctx.skipRemaining to short-circuit the rest of its stage (honoured by the pipeline at ctx.skipRemaining in MiddlewarePipeline).

Modifying the request

Middleware can mutate ctx.input before calling next() — for example, normalising input or injecting derived data:

const trim: MiddlewareFunction = async (ctx, next) => {
  if (Array.isArray(ctx.input)) {
    ctx.input = ctx.input.map(a => typeof a === 'string' ? a.trim() : a);
  }
  await next();
};

Mutate sparingly. Per-method validation is a better fit for content rules; middleware should handle cross-cutting transforms (normalisation, trimming, conversion to canonical form).

Modifying the response

next() runs dispatch and populates ctx.result; transform it after:

const stamp: MiddlewareFunction = async (ctx, next) => {
  await next();
  if (typeof ctx.result === 'object' && ctx.result !== null) {
    ctx.result = { ...ctx.result, _serverTimeMs: Date.now() };
  }
};

Useful for response envelopes, version stamping, redaction.

Error interception

Dispatch errors surface as a throw from next() (and are also set on ctx.error). Common pattern:

const mapErrors: MiddlewareFunction = async (ctx, next) => {
  try {
    await next();
  } catch (e) {
    if (e instanceof NotFoundError) {
      // Convert framework error to project-specific shape.
      throw new ProjectNotFoundError(e.message, { /* … */ });
    }
    throw e;
  }
};

Avoid swallowing. Errors that middleware suppresses become silent failures; the client sees a successful response that has no real result.

Anti-patterns

  • Calling next() more than once. The result is undefined. Some middleware in the chain will run twice; client may receive two responses or a malformed one.

  • Skipping next() based on a condition. If you bypass the method, you must produce a return value. Bypass is the right pattern for caching middleware (return a cached value); for guards, throw an error instead.

  • Long-running async work. Every middleware extends the call duration. Keep middleware fast or run it asynchronously after next() returns (e.g. async logging).

  • Sharing state between middlewares via globals. Use ctx.metadata — it's per-call and isolated.