Routes Flow
A routesFlow is a client-orchestrated query that executes multiple routes in a single HTTP request. You can batch independent calls together and map results between them using serverMapFrom — resolving relationships server-side without extra round trips.
Instead of defining a schema and resolvers, just use your existing routes and compose them from the client using
routesFlow and serverMapFrom.
You get the same data-fetching efficiency — fetching related data in a single request — with full type safety and zero boilerplate.
Basic Usage
Use the routesFlow() function to execute multiple routes together:
import {initClient, routesFlow} from '@mionjs/client';
import type {MyApi} from './server.routes.ts';
const {routes, middleFns} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
// Execute multiple routes in a single HTTP request
const [[sum, user], [sumError, userError]] = await routesFlow([routes.utils.sum(5, 2), routes.users.getById('USER-123')]).call();
// Results are returned as arrays in the same order as the routes
if (sumError) console.log('Sum error:', sumError.publicMessage);
else console.log('Sum:', sum); // 7
if (userError) console.log('User error:', userError.publicMessage);
else console.log('User:', user); // {id: 'USER-123', name: 'John', surname: 'Smith'}
Result Mapping with serverMapFrom
Use serverMapFrom() to map the output of one route to the input of another — all resolved server-side in a single HTTP request. This enables GraphQL-like data composition with the simplicity and type safety of RPC.
import {initClient, routesFlow, serverMapFrom} from '@mionjs/client';
import type {MyApi} from './server.routes.ts';
const {routes, middleFns} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
// Fetch an order
const orderReq = routes.orders.getById('ORDER-123');
// serverMapFrom maps order.userId → getById input, (runs server-side)
const mapping = serverMapFrom(orderReq, (order) => order!.userId);
// fake is just used for faking the expected type of users.getById in the client
const userReq = routes.users.getById(mapping.asArg());
const [[orderData, userData]] = await routesFlow([orderReq, userReq]).call();
if (orderData && userData) {
console.log(`Order ${orderData.id} placed by ${userData.name} ${userData.surname}`);
}
How it works
serverMapFrom(sourceRoute, mapperFn)creates a reference to the source route's output.fake()provides a type-safe placeholder so TypeScript can infer the result types- At build time, the mion vite plugin extracts the mapper function from your client source and bundles it into the server
- At runtime, the server executes the first route, runs the mapper function, and passes the mapped value as input to the second route
Pure functions requirement
The mapper function passed to serverMapFrom must be a pure function — no closures, no external variables, no side effects. This is because the function body is extracted at compile time by the mion vite plugin and bundled into the server. The server executes the function in its own context, so it has no access to your client-side scope.
// ✅ Pure function — only uses the input parameter
serverMapFrom(order, (o) => o!.userId)
// ✅ Pure function — inline computation is fine
serverMapFrom(order, (o) => o!.totalUSD * 100)
// ❌ NOT pure — references external variable
const multiplier = 2;
serverMapFrom(order, (o) => o!.totalUSD * multiplier)
Vite plugin configuration
To enable serverMapFrom, the server vite config must include the serverPureFunctions option pointing to your client source directory. This tells the plugin where to scan for mapper functions to bundle into the server:
// server vite.config.ts
import {mionPlugin} from '@mionjs/devtools/vite-plugin';
import {resolve} from 'path';
export default defineConfig({
plugins: [
mionPlugin({
serverPureFunctions: {
clientSrcPath: resolve(__dirname, '../client/src'),
},
}),
],
});
| Option | Description |
|---|---|
clientSrcPath | Path to the client source directory containing serverMapFrom() calls |
include | Glob patterns for files to scan (defaults to ['**/*.ts', '**/*.tsx']) |
exclude | Glob patterns for files to exclude from scanning |
RoutesFlow vs Single Route Call
The key difference between call() and routesFlow() is in the response structure:
| Aspect | route.call() | routesFlow([routes...]).call() |
|---|---|---|
| HTTP Requests | One per route | One for all routes |
| Result | Single value | Array of values |
| Error | Single error | Array of errors |
| Position | Direct access | Index-based access |
import {initClient, routesFlow} from '@mionjs/client';
import type {MyApi} from './server.routes.ts';
const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
// ============================================
// SINGLE ROUTE CALL - call()
// ============================================
// Result and error are the direct types from the route
// Returns: [result, error, middleFnResults, middleFnErrors]
const [user, error] = await routes.users.getById('USER-123').call();
// `user` is User | undefined
// `error` is RpcError<'user-not-found', UserNotFoundData> | ValidationError | undefined
if (error) {
if (error.type === 'user-not-found') {
console.log('User not found:', error.errorData?.requestedId);
}
} else {
console.log('User:', user?.name);
}
// ============================================
// ROUTES_FLOW - Multiple routes in one request
// ============================================
// Results and errors are ARRAYS in the same order as the routes
// Returns: [[results...], [errors...], middleFnResults, middleFnErrors]
const [[user2, order], [userError, orderError]] = await routesFlow([
routes.users.getById('USER-123'),
routes.orders.getById('ORDER-1'),
]).call();
// `user2` is User | undefined (first route result)
// `order` is Order | undefined (second route result)
// `userError` is RpcError<'user-not-found'> | ValidationError | undefined
// `orderError` is RpcError<'order-not-found'> | ValidationError | undefined
// Each result/error corresponds to its route by position
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);
Response Pattern Comparison
// Single route call - direct result/error
const [result, error, middleFnResults, middleFnErrors] = await route.call();
// RoutesFlow - arrays of results/errors
const [[result1, result2], [error1, error2], middleFnResults, middleFnErrors] = await routesFlow([route1, route2]).call();
- Fetching data for a page that needs multiple API calls
- Batch operations that don't depend on each other
- Reducing network latency in high-latency environments
Using MiddleFns with RoutesFlow
You can include middleFns (like authentication) in your routesFlow:
import {initClient, routesFlow} from '@mionjs/client';
import {HeadersSubset} from '@mionjs/core';
import type {MyApi} from './server.routes.ts';
const {routes, middleFns} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
const authHeaders = new HeadersSubset({Authorization: 'my-token'});
// Execute routesFlow with explicit middleFns
const [[sum, user], [sumError, userError], middleFnResults, middleFnErrors] = await routesFlow([
routes.utils.sum(5, 2),
routes.users.getById('USER-123'),
]).call({middleFns: {auth: middleFns.auth(authHeaders)}});
// Check middleFn errors
if (middleFnErrors?.auth) {
console.log('Auth failed:', middleFnErrors.auth.publicMessage);
}
// Handle route results
if (!sumError) console.log('Sum:', sum);
if (!userError) console.log('User:', user);
Alternative Syntax: call with otherRoutes
If you prefer starting from a route, use call() with the otherRoutes option to add more routes:
import {initClient} from '@mionjs/client';
import {HeadersSubset} from '@mionjs/core';
import type {MyApi} from './server.routes.ts';
const {routes, middleFns} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
const authHeaders = new HeadersSubset({Authorization: 'my-token'});
// Alternative syntax: start from a route and add more routes to the routesFlow
const [[sum, user, order], [sumError, userError, orderError]] = await routes.utils.sum(5, 2).call({
otherRoutes: [routes.users.getById('USER-123'), routes.orders.getById('ORDER-1')],
middleFns: {auth: middleFns.auth(authHeaders)},
});
// Handle results - same array pattern as routesFlow()
if (!sumError) console.log('Sum:', sum);
if (!userError) console.log('User:', user);
if (!orderError) console.log('Order:', order);
Both routesFlow() and call({otherRoutes: [...]}) produce the same result - choose the syntax that fits your code style.
Error Handling in RoutesFlows
In a routesFlow, each route can succeed or fail independently. The error array contains errors at the same index as their corresponding routes:
const [[user, order, sum], [userError, orderError, sumError]] = await routesFlow([
routes.users.getById('USER-123'), // index 0
routes.orders.getById('ORDER-404'), // index 1 - this will fail
routes.utils.sum(5, 2), // index 2
]).call();
// Results:
// user = {id: 'USER-123', ...} - success
// userError = undefined
// order = undefined - failed
// orderError = RpcError<'order-not-found'>
// sum = 7 - success
// sumError = undefined
Serialization in RoutesFlows
Complex types like Date, Map, and Set are automatically serialized and deserialized in routesFlows, just like single route calls:
const testDate = new Date('2024-06-15T12:30:00.000Z');
const testMap = new Map([['a', 1], ['b', 2]]);
const [[date, map], [dateError, mapError]] = await routesFlow([
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 routesFlow must use the same client
- At least one route: RoutesFlow requires at least one route subrequest
// ❌ This will throw an error
const {routes: routes1} = initClient<MyApi>({baseURL: 'http://server1.com'});
const {routes: routes2} = initClient<MyApi>({baseURL: 'http://server2.com'});
await routesFlow([routes1.getUser('1'), routes2.getOrder('1')]).call(); // Error!
// ✅ Use routes from the same client
const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
await routesFlow([routes.getUser('1'), routes.getOrder('1')]).call(); // Works!