Client

Routes Flow

Client-orchestrated queries that batch and compose API calls in a single request — a type-safe, deterministic alternative to GraphQL.

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 mapFrom — resolving relationships server-side without extra round trips.

GraphQL alternative:
Instead of defining a schema and resolvers, just use your existing routes and compose them from the client using routesFlow and mapFrom.
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 '@mionkit/client';
import type {MyApi} from './server.routes.ts';

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

async function basicRoutesFlow() {
    // 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')]);

    // 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 mapFrom

Use mapFrom() 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, mapFrom} from '@mionkit/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');
// mapFrom maps order.userId → getById input, (runs server-side)
const mapping = mapFrom(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.type());

const [[orderData, userData]] = await routesFlow([orderReq, userReq]);
if (orderData && userData) {
    console.log(`Order ${orderData.id} placed by ${userData.name} ${userData.surname}`);
}

How it works

  1. mapFrom(sourceRoute, mapperFn) creates a reference to the source route's output
  2. .fake() provides a type-safe placeholder so TypeScript can infer the result types
  3. At build time, the mion vite plugin extracts the mapper function from your client source and bundles it into the server
  4. 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 mapFrom 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
mapFrom(order, (o) => o!.userId)

// ✅ Pure function — inline computation is fine
mapFrom(order, (o) => o!.totalUSD * 100)

// ❌ NOT pure — references external variable
const multiplier = 2;
mapFrom(order, (o) => o!.totalUSD * multiplier)

Vite plugin configuration

To enable mapFrom, 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 '@mionkit/devtools/vite-plugin';
import {resolve} from 'path';

export default defineConfig({
    plugins: [
        mionPlugin({
            serverPureFunctions: {
                clientSrcPath: resolve(__dirname, '../client/src'),
            },
        }),
    ],
});
OptionDescription
clientSrcPathPath to the client source directory containing mapFrom() calls
includeGlob patterns for files to scan (defaults to ['**/*.ts', '**/*.tsx'])
excludeGlob patterns for files to exclude from scanning

RoutesFlow vs Single Route Call

The key difference between call() and routesFlow() is in the response structure:

Aspectroute.call()routesFlow([routes...])
HTTP RequestsOne per routeOne for all routes
ResultSingle valueArray of values
ErrorSingle errorArray of errors
PositionDirect accessIndex-based access
import {initClient, routesFlow} from '@mionkit/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
async function singleRouteCall() {
    // 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
async function routesFlowCall() {
    // Returns: [[results...], [errors...], middleFnResults, middleFnErrors]
    const [[user, order], [userError, orderError]] = await routesFlow([
        routes.users.getById('USER-123'),
        routes.orders.getById('ORDER-1'),
    ]);

    // `user` 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:', user?.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]);
When to use routesFlow:
  • 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 '@mionkit/client';
import {HeadersSubset} from '@mionkit/core';
import type {MyApi} from './server.routes.ts';

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

async function routesFlowWithMiddleFns() {
    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')],
        {auth: middleFns.auth(authHeaders)}
    );

    // Check middleFn errors
    if (middleFnErrors?.auth) {
        console.log('Auth failed:', middleFnErrors.auth.publicMessage);
        return;
    }

    // Handle route results
    if (!sumError) console.log('Sum:', sum);
    if (!userError) console.log('User:', user);
}
MiddleFns are executed once for the entire routesFlow, not once per route. This is more efficient than making separate calls.

Alternative Syntax: callWithWorkflow

If you prefer starting from a route, use callWithWorkflow() to add more routes:

import {initClient} from '@mionkit/client';
import {HeadersSubset} from '@mionkit/core';
import type {MyApi} from './server.routes.ts';

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

async function callWithWorkflowExample() {
    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)
        .callWithWorkflow([routes.users.getById('USER-123'), routes.orders.getById('ORDER-1')], {
            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 callWithWorkflow() 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
]);

// Results:
// user = {id: 'USER-123', ...}  - success
// userError = undefined

// order = undefined              - failed
// orderError = RpcError<'order-not-found'>

// sum = 7                        - success
// sumError = undefined
A failing route does NOT stop other routes from executing. Each route in the routesFlow is processed independently.

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),
]);

// 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')]); // Error!
    
    // ✅ Use routes from the same client
    const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
    await routesFlow([routes.getUser('1'), routes.getOrder('1')]); // Works!