Client

Client Middleware

Send middleware data, read middleware results and share client behaviour.

Every server middleware with params or a result shows up in the client's middlewares object. Use it to send the middleware its data and handle what it returns.

Middleware Hooks

Each middleware in middlewares has three hooks. onRequest sends its data, like a trace id. onResponse gets its result and onError its declared errors.

client.ts
import {HeadersSubset} from '@mionjs/core';
import {initClient} from '@mionjs/client';
import type {MyApi} from './middleware-hooks.routes.ts';

const {middlewares} = initClient<MyApi>({
  baseURL: 'http://localhost:3000',
});

declare function redirectToLogin(): void;

// a fresh id for every request, the server echoes it back
middlewares.trace
  .onRequest((trace) => {
    // the remote middleware call: same params as the server's traceHandler
    trace(new HeadersSubset({'X-Trace-Id': crypto.randomUUID()}));
  })
  .onResponse((info) => {
    console.log('Trace:', info.traceId, 'received at', info.receivedAt);
  });

// auth takes no params, so it needs no onRequest
middlewares.auth
  .onResponse((session) => {
    console.log('Authenticated as:', session.userId);
  })
  .onError('not-authorized', (error) => {
    console.log('Auth failed:', error.errorData?.reason);
    redirectToLogin();
  });
server.routes.ts
import {HeadersSubset, FatalError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import type {CallContext} from '@mionjs/router';
import {getSession, type SessionInfo} from './session.ts';

const mion = createMionRouter();

export type TraceInfo = {traceId: string; receivedAt: Date};
export type NotAuthorizedData = {reason: 'no-session' | 'expired-session'};

// a plain handler, so a client installer can take its type
export function traceHandler(
  ctx: CallContext,
  {headers}: HeadersSubset<'X-Trace-Id'>
): TraceInfo {
  return {traceId: headers['X-Trace-Id'], receivedAt: new Date()};
}

// no params: it reads the HttpOnly session cookie the browser sends
export function authHandler(
  ctx: CallContext
): SessionInfo | FatalError<'not-authorized', NotAuthorizedData> {
  const session = getSession(ctx.request.headers.get('cookie'));
  // a returned FatalError ends the request (no route runs) and stays typed
  if (!session) {
    return new FatalError({
      publicMessage: 'Not Authorized',
      type: 'not-authorized',
      errorData: {reason: 'no-session'},
    });
  }
  return session;
}

const routes = {
  trace: mion.headersMiddleware(traceHandler),
  auth: mion.middleware(authHandler),
  utils: {
    sum: mion.route((ctx, a: number, b: number): number => a + b),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
HookRunsRemoved with
onRequestBefore each request that runs the middleware, a route's first call included. It can be async. Its second argument is the request, to pick data per route. A middleware has one.offRequest()
onResponseAfter each successful middleware result, strongly typed. Several run in the order added, even when one throws.offResponse(handler), only that one
onErrorAfter each declared error of the given type, strongly typed. Several per type run in the order added, even when one throws.offError(type, handler), only that one
onRequest gets a function. Call it with the middleware params, or skip it to send nothing.
If onRequest fails, the request is not sent and the error goes to response['@thrownErrors']. onError only gets the errors the middleware declares.

Reading Middleware Results From Routes

Every call returns a third value, response, with each middleware's answer. It is typed with the middleware's return value, declared errors and ValidationError.

client.ts
import {isRpcError} from '@mionjs/core';
import {initClient} from '@mionjs/client';
import type {MyApi} from './middleware-results.routes.ts';

const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});

const [sum, error, response] = await routes.utils.sum(5, 2).call();

// the auth middleware's answer or declared error, at its path
if (isRpcError(response.auth))
  console.log('Auth error:', response.auth.publicMessage);
else console.log('Session:', response.auth?.userId);
if (!error) console.log(sum); // 7
server.routes.ts
import {FatalError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import {getSession, type SessionInfo} from './session.ts';

const mion = createMionRouter();

const routes = {
  auth: mion.middleware(
    (ctx): SessionInfo | FatalError<'not-authorized'> =>
      getSession(ctx.request.headers.get('cookie')) ??
      new FatalError({type: 'not-authorized', publicMessage: 'Not Authorized'})
  ),
  utils: {
    sum: mion.route((ctx, a: number, b: number): number => a + b),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;

Retrying a Call

onResponse and onError get the call as a second argument. Its retry() sends the whole call again, for example after refreshing the session, and runs every onRequest first. Each middleware can retry once per call.

RouteRetry allowed
query()Always
mutation() or route()Only if the call failed: the route returned an error, or it returned nothing and the response has an error
retry() returns false when it is not allowed, and the call keeps its current result.

Reusable Client Middleware

A middleware is usually written only for the server. With mion it can also ship its client side as an installer, a function that receives middlewares.x and sets its hooks, so every app gets the same behaviour. Type its parameter with ClientMiddlewareOf<typeof handler>, so it works wherever the app places the middleware.

import {HeadersSubset} from '@mionjs/core';
import {initClient} from '@mionjs/client';
import type {ClientMiddlewareOf} from '@mionjs/client';
import type {MyApi, authHandler} from './middleware-hooks.routes.ts';

// only the handler's type crosses over, so this file never loads the router
export function useAuth(
  auth: ClientMiddlewareOf<typeof authHandler>,
  refreshSession: () => Promise<boolean>
) {
  auth.onError('not-authorized', async (error, context) => {
    // the server sets a new session cookie, then the whole call is sent again
    if (await refreshSession()) context.retry();
  });
}

declare function refreshSession(): Promise<boolean>;

const {routes, middlewares} = initClient<MyApi>({
  baseURL: 'http://localhost:3000',
});
useAuth(middlewares.auth, refreshSession);
middlewares.trace.onRequest((trace) =>
  trace(new HeadersSubset({'X-Trace-Id': crypto.randomUUID()}))
);

const [sum] = await routes.utils.sum(5, 2).call();
The build fails with rpc-client-middleware-not-set-up when a called route runs a middleware with required params and the client never sets it up.
Copyright © 2026