Batch Requests
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().
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);
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;
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.
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.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}`);
}
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:
- 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. - Each inline
inputFrommapper is copied into the server build. - The server build compiles a table of batch ids, each with its routes and mappers.
- At runtime the client sends only the id,
POST /mion-batch?id=<batchId>, with the route inputs in the body. - 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();
}
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)
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:
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);
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;
| Aspect | route.call() | batch([routes...]).call() |
|---|---|---|
| HTTP Requests | One per route | One for all routes |
| Result | [result, error, response] | One [result, error, response] per route, in order |
| Response | One per call | One per batch, shared by every entry |
- 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.
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);
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;
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.
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);
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;
Errors in Mapped Routes
A route that reads a mapped input depends on its source route:
| What happened | What you get |
|---|---|
| The source route returned a declared error | The 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 threw | The whole batch stops with one error in @thrownErrors, batch-mapper-failed, naming the two routes. |
| The source route threw an undeclared error | The 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
| Rule | What it means for you |
|---|---|
| Relative imports only | The 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 load | Every load registers exactly the batches in the file and nothing from before. |
| No build order | Each batch's id and mapper hash come from the same source, so client and server bundles agree whichever is built first. |
| Follows client edits | In 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.
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';