Client

Batch Requests

Run several routes in one HTTP request and feed one route's output into another.

A batch runs several routes in one HTTP request. You get one [result, error, response] per route, in the order you listed them, the same as a single call().

Batches need your client and server in one build, one program. The build reads your client code to find the batches the server may run.
client.ts
import {initClient, batch} from '@mionjs/client';
import type {MyApi} from './hello-sum.routes.ts';

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

const [[sum, sumError, response], [greeting, greetingError, sameResponse]] =
  await batch([routes.utils.sum(5, 2), routes.sayHello('John')]).call();

console.log(response === sameResponse); // true, one response per batch

if (sumError) console.log('Sum error:', sumError.publicMessage);
else if (sum !== undefined) console.log('Sum:', sum); // 7

if (greetingError) console.log('Hello error:', greetingError.publicMessage);
else if (greeting !== undefined) console.log(greeting); // Hello John

// both slots are empty when the error happened outside the routes
if (response['@thrownErrors'])
  console.log('Batch failed:', response['@thrownErrors'][0].publicMessage);
server.routes.ts
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

const routes = {
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
  utils: {
    sum: mion.route((ctx, a: number, b: number): number => a + b),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
Each entry has the response only to match the shape of a single call(). Read it from any entry.

Chaining Routes

Use inputFrom() to feed one route's output into another route's input. The mapping runs on the server, in the same HTTP request, so it adds no round trip.

GraphQL alternative:
Instead of a schema and resolvers, compose your existing routes from the client with batch and inputFrom. You get related data in one request, fully typed, with no boilerplate.
client.ts
import {initClient, batch, inputFrom} from '@mionjs/client';
import type {MyApi} from './batch-chain.routes.ts';

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

const orderReq = routes.orders.getById('ORDER-123');
// feeds order.userId into the user route's input
const mapping = inputFrom(orderReq, (order) => order!.userId);
// a typed placeholder for the value the server maps in
const userReq = routes.users.getById(mapping.asArg());

const [[orderData], [userData]] = await batch([orderReq, userReq]).call();
if (orderData && userData) {
  console.log(`Order ${orderData.id} placed by ${userData.name}`);
}
server.routes.ts
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

type User = {id: string; name: string; email: string};
// each order belongs to a user
type Order = {id: string; userId: string; totalUSD: number};

// stand-in for your database
declare const db: {
  orders: {findById(id: string): Promise<Order>};
  users: {findById(id: string): Promise<User>};
};

const routes = {
  orders: {
    getById: mion.route(
      (ctx, id: string): Promise<Order> => db.orders.findById(id)
    ),
  },
  users: {
    // in the chain, id is the order's userId, picked by the client's inputFrom mapper on the server
    getById: mion.route(
      (ctx, id: string): Promise<User> => db.users.findById(id)
    ),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;

How Batched Requests Are Compiled

Batches are compiled at build time. The build reads your client code and finds every batch([...]) call and every inputFrom mapper in it:

  1. Each batch([...]) call gets an id from its routes, in order, and its mappers. The client and the server get the same id. A different route list or mapper makes a new batch.
  2. Each inline inputFrom mapper is copied into the server build.
  3. The server build compiles a table of batch ids, each with its routes and mappers.
  4. At runtime the client sends only the id, POST /mion-batch?id=<batchId>, with the route inputs in the body.
  5. The server looks up the id, runs the routes and mappers, and answers in one round trip.

Why Only Compiled Batches Run

The server runs only the batches its build compiled in.

  • Safer. A caller cannot make the server run a mix of routes you never wrote.
  • Faster. The server works out each batch's run order once, not again for every new batch a client sends.

Writing a Batch the Build Can Read

Write each route call inline in the array, or in a const in the same file. Anything else fails the build, because a batch the build cannot read can never be sent.

// ✅ Inline route calls
batch([routes.users.getById('U1'), routes.orders.list()]).call();

// ✅ Route calls bound to a const in the same file
const user = routes.users.getById('U1');
const orders = routes.orders.list();
batch([user, orders]).call();

// ❌ The build cannot read a spread or a parameter
function run(extra: RouteSubRequest[]) {
  return batch([routes.users.getById('U1'), ...extra]).call();
}
List each route once. A route you map from with inputFrom must be in the same batch, before the route it feeds.

Writing a Mapper

Write the mapper inline, in the call. The build copies it into the server, so there is nothing to register or keep in sync.

It must follow the RunTypes pure function rules: no closures, no outside variables, no side effects. The server runs it without your client-side scope.

// ✅ Pure: only uses its input
inputFrom(order, (o) => o!.userId)

// ✅ Pure: inline computation is fine
inputFrom(order, (o) => o!.totalUSD * 100)

// ❌ Not pure: references an outside variable
const multiplier = 2;
inputFrom(order, (o) => o!.totalUSD * multiplier)
A mapper the server cannot copy and run fails the build, with an error naming the problem. The linter shows the same error in your editor as you type.

Unknown or Missing Batch Ids

An id the server does not know is refused with batch-unknown-id (404) before the body is read. Only middleware that declares alwaysRun runs for it.

Without the mion build plugin, batch() throws batch-missing-id and nothing is sent. Single route calls still work. When asked for every method, the metadata middleware also lists the registered batch ids, so a hand-written client can call a batch by id. It lists neither when the API has more methods than the getAllRemoteMethodsMaxNumber router option (100 by default).

Batch vs Single Route

call() and batch([...]).call() differ in the response shape:

client.ts
import {initClient, batch} from '@mionjs/client';
import type {MyApi} from './batch-orders.routes.ts';

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

// returns [result, error, response]
const [user, error] = await routes.users.getById('USER-123').call();

if (error?.type === 'user-not-found')
  console.log('User not found:', error.errorData?.requestedId);
else console.log('User:', user?.name);

// returns one [result, error, response] per route
const [[user2, userError], [order, orderError]] = await batch([
  routes.users.getById('USER-123'),
  routes.orders.getById('ORDER-1'),
]).call();

if (userError) console.log('User error:', userError.publicMessage);
else console.log('User:', user2?.name);

if (orderError) console.log('Order error:', orderError.publicMessage);
else console.log('Order:', order?.id);
server.routes.ts
import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

export type User = {id: string; name: string};
export type Order = {id: string; userId: string; totalUSD: number};

const routes = {
  users: {
    getById: mion.route(
      (
        ctx,
        id: string
      ): User | RpcError<'user-not-found', {requestedId: string}> => {
        if (id !== 'USER-123') {
          return new RpcError({
            publicMessage: 'User not found',
            type: 'user-not-found',
            errorData: {requestedId: id},
          });
        }
        return {id, name: 'John'};
      }
    ),
  },
  orders: {
    getById: mion.route(
      (
        ctx,
        id: string
      ): Order | RpcError<'order-not-found', {requestedId: string}> => {
        if (id === 'ORDER-404') {
          return new RpcError({
            publicMessage: 'Order not found',
            type: 'order-not-found',
            errorData: {requestedId: id},
          });
        }
        return {id, userId: 'USER-123', totalUSD: 120};
      }
    ),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
Aspectroute.call()batch([routes...]).call()
HTTP RequestsOne per routeOne for all routes
Result[result, error, response]One [result, error, response] per route, in order
ResponseOne per callOne per batch, shared by every entry
When to use a batch:
  • Fetching data for a page that needs multiple API calls
  • Independent routes, or routes chained with inputFrom
  • Reducing network latency in high-latency environments

Middleware in a Batch

A middleware's onRequest hook also sends its data with a batch.

client.ts
import {HeadersSubset, isRpcError} from '@mionjs/core';
import {initClient, batch} from '@mionjs/client';
import type {MyApi} from './hello-sum-auth.routes.ts';

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

// runs once per batch, so the whole batch shares one trace id
middlewares.trace.onRequest((trace) =>
  trace(new HeadersSubset({'X-Trace-Id': crypto.randomUUID()}))
);

const [[sum, sumError, response], [greeting, greetingError]] = await batch([
  routes.utils.sum(5, 2),
  routes.sayHello('John'),
]).call();

// one response for the whole batch
if (isRpcError(response.auth))
  console.log('Auth failed:', response.auth.publicMessage);
if (response.trace) console.log('Trace:', response.trace);
if (response['@thrownErrors'])
  console.log('Request failed:', response['@thrownErrors'][0].publicMessage);

if (!sumError) console.log('Sum:', sum);
if (!greetingError) console.log(greeting);
server.routes.ts
import {HeadersSubset, FatalError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import {getSession} from './session.ts';

const mion = createMionRouter();

const routes = {
  // the client sends a trace id with every request, the server echoes it back
  trace: mion.headersMiddleware(
    (ctx, {headers}: HeadersSubset<'X-Trace-Id'>): string =>
      headers['X-Trace-Id']
  ),
  // runs before every route; a returned FatalError ends the request and stays typed
  auth: mion.middleware((ctx): void | FatalError<'not-authorized'> => {
    if (!getSession(ctx.request.headers.get('cookie')))
      return new FatalError({
        publicMessage: 'Not Authorized',
        type: 'not-authorized',
      });
  }),
  sayHello: mion.route((ctx, name: string): string => `Hello ${name}`),
  utils: {
    sum: mion.route((ctx, a: number, b: number): number => a + b),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
Middleware runs once for the whole batch, not once per route.

Error Handling in a Batch

Each entry follows the call() rules: a strongly typed result or error from its own route, and every other error in the shared response.

client.ts
import {initClient, batch} from '@mionjs/client';
import type {MyApi} from './batch-orders.routes.ts';

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

const [[user, , response], [, orderError]] = await batch([
  routes.users.getById('USER-123'),
  routes.orders.getById('ORDER-404'), // returns order-not-found
]).call();

if (user) console.log('User:', user.name); // John
if (orderError?.type === 'order-not-found')
  console.log('No order:', orderError.errorData?.requestedId);

// a timeout or a thrown error leaves every result and error undefined
for (const thrown of response['@thrownErrors'] ?? [])
  console.log('Batch failed:', thrown.publicMessage);
server.routes.ts
import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

export type User = {id: string; name: string};
export type Order = {id: string; userId: string; totalUSD: number};

const routes = {
  users: {
    getById: mion.route(
      (
        ctx,
        id: string
      ): User | RpcError<'user-not-found', {requestedId: string}> => {
        if (id !== 'USER-123') {
          return new RpcError({
            publicMessage: 'User not found',
            type: 'user-not-found',
            errorData: {requestedId: id},
          });
        }
        return {id, name: 'John'};
      }
    ),
  },
  orders: {
    getById: mion.route(
      (
        ctx,
        id: string
      ): Order | RpcError<'order-not-found', {requestedId: string}> => {
        if (id === 'ORDER-404') {
          return new RpcError({
            publicMessage: 'Order not found',
            type: 'order-not-found',
            errorData: {requestedId: id},
          });
        }
        return {id, userId: 'USER-123', totalUSD: 120};
      }
    ),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;
A batch with wrong params in any route is not sent. That route's error slot holds the validation error, and every other entry stays empty.
A route that fails on the server does NOT stop the other routes.

Errors in Mapped Routes

A route that reads a mapped input depends on its source route:

What happenedWhat you get
The source route returned a declared errorThe source keeps its own error. The target does not run and its result and error stay undefined. @thrownErrors holds batch-mapping-source-failed, naming both routes.
The mapper function threwThe whole batch stops with one error in @thrownErrors, batch-mapper-failed, naming the two routes.
The source route threw an undeclared errorThe batch stops with one error in @thrownErrors, as with any thrown error.

JSON Round Trip in a Batch

A batch converts Date, Map and Set like a single route call does:

const testDate = new Date('2024-06-15T12:30:00.000Z');
const testMap = new Map([['a', 1], ['b', 2]]);

const [[date, dateError], [map, mapError]] = await batch([
    routes.getSameDate(testDate),
    routes.getSameMap(testMap),
]).call();

// date is a Date instance
// map is a Map instance

Constraints

  • Same client instance: All routes in a batch must use the same client
  • At least one route: A batch requires at least one route
// ❌ This will throw an error
const {routes: routes1} = initClient<MyApi>({baseURL: 'http://server1.com'});
const {routes: routes2} = initClient<MyApi>({baseURL: 'http://server2.com'});

await batch([routes1.users.getById('USER-123'), routes2.orders.getById('ORDER-1')]).call();

// ✅ Use routes from the same client
const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
await batch([routes.users.getById('USER-123'), routes.orders.getById('ORDER-1')]).call();

Build Configuration

Nothing to configure. The build writes the batch table and every inline inputFrom mapper into one folder under its gen dir, and imports the table from the file that calls createMionRouter:

<genDir>/rpc/batches.generated.js
<genDir>/rpc/pf/…                   one module per inline mapper
RuleWhat it means for you
Relative imports onlyThe table imports each mapper as ./pf/…, so the folder is self-contained and portable. No path leaks into a generated file.
Whole table replaced on loadEvery load registers exactly the batches in the file and nothing from before.
No build orderEach batch's id and mapper hash come from the same source, so client and server bundles agree whichever is built first.
Follows client editsIn dev a batch edit regenerates the table and reloads the API.

See Vite for the plugin reference, and CLI for the same output from mion compile.

An alias, a namespace import, a local barrel or your own wrapper around createMionRouter all work. A wrapper shipped by another package (behind a declaration file) does not: a production build fails with rpc-batch-router-init-hidden, and the dev server only reports it. Add the import yourself in the module that creates the router:
import './.mion/rpc/batches.generated.js';
Copyright © 2026