Routes
A route is a function your client calls remotely. Its first parameter is always the Call Context. The rest come from the client, validated before the route runs.
Your API is a plain object of routes and middleware, with no URLs to write. Each route gets a URL underneath, which the client handles for you.
Creating the Router
Create the router once per app with createMionRouter and pass it every router option (base path, parser, context data factory and the rest). Every helper it returns (route, query, mutation, middleware, headersMiddleware, rawMiddleware) gets those options by type.
const mion = createMionRouter({
contextDataFactory: getSharedData,
basePath: 'api/v1',
});
initRoutes in your routes file, then import that file from the server entry. Destructuring the helpers is fine: const {route, middleware} = createMionRouter({...}).createMionRouter and initRoutes once each, with all your routes in one initRoutes call. A second call to either throws.Defining a Route
Declare a route with mion.route: the handler is the first argument and the RouteOptions the second.
import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import type {SomeData as Data} from './full-example.app.ts';
import {myDbService as db} from './full-example.app.ts';
const mion = createMionRouter();
export const routes = {
sayHello: mion.route((ctx, name1: string, name2: string): string => {
return `Hello ${name1} and ${name2}.`;
}),
getSomeData: mion.route(
async (ctx, id: string): Promise<Data | RpcError<'data-not-found'>> => {
const data = await db.getData(id);
return (
data ||
new RpcError({publicMessage: 'Data not found', type: 'data-not-found'})
);
}
),
} satisfies Routes;
satisfies operator instead, so the router stays fully typed.Query and Mutation Handlers
The query and mutation helpers declare what a route does, so the client can choose how to send it. A query sends small payloads in the URL as a GET, a mutation always in the body. A mion.route() declares no intent, sends its data in the body and has no cache semantics.
import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
const mion = createMionRouter();
type User = {name: string; surname: string};
const routes = {
// mion.query() for read-only operations
// client automatically uses GET with ?data=base64url for small payloads
getUser: mion.query((ctx, id: number): User | RpcError<'user-not-found'> => {
return {name: 'John', surname: 'Doe'};
}),
// mion.mutation() for operations that modify data
// client always uses POST with body
createUser: mion.mutation((ctx, user: User): User => {
return user;
}),
// mion.route() declares no intent (client uses POST)
sayHello: mion.route((ctx, name: string): string => {
return `Hello ${name}`;
}),
} satisfies Routes;
mion.query() and mion.mutation(): your code reads better, and it leaves room for future optimizations like caching layers.Undeclared Properties
By default, a param property your types do not declare is dropped while the request is decoded, not at validation. The clone and compact strategies rebuild the params from your declared type.
To reject the request instead, set parser: {params: 'mutateStrict'}. See Validation.
import {createMionRouter, Routes} from '@mionjs/router';
interface User {
name: string;
email: string;
age: number;
}
// every route on this router rejects params carrying a property the type does not declare
const mion = createMionRouter({parser: {params: 'mutateStrict'}} as const);
const routes = {
// inherits mutateStrict from the router
createUser: mion.route((ctx, user: User): User => user),
// this route drops the extra property instead of refusing the request
updateUser: mion.route((ctx, user: Partial<User>): Partial<User> => user, {
parser: {params: 'clone'},
}),
} satisfies Routes;
mion.initRoutes(routes);
Registering Routes
Pass the routes object to mion.initRoutes, once per app. It initializes the router with the options given to createMionRouter, registers the routes and returns the API type the client uses.
import {HeadersSubset} from '@mionjs/core';
import {createMionRouter, PublicApi, Routes} from '@mionjs/router';
const mion = createMionRouter();
const authRoutes = {
logIn: mion.route(
(ctx, email: string, password: string): string => 'loggedIn'
),
logOut: mion.route((): string => 'loggedOut'),
} satisfies Routes;
const routes = {
trace: mion.headersMiddleware(
(ctx, headers: HeadersSubset<'X-Trace-Id'>): void => undefined
),
sayHello: mion.route((ctx, name: string): string => 'hello ' + name),
} satisfies Routes;
// routes split across files are spread into the one initRoutes call
export const myApi = mion.initRoutes({...routes, ...authRoutes});
export type MyApi = typeof myApi;
// a sub-api can be typed on its own from its routes object
export type AuthApi = PublicApi<typeof authRoutes>;
Naming Routes
Name routes with valid JS variable names, not quoted keys.
import {createMionRouter, Routes} from '@mionjs/router';
const mion = createMionRouter();
const sayHello = mion.route((ctx, name: string): string => {
return `Hello ${name}.`;
});
const routes = {
'say-Hello': sayHello, // path = /say-Hello !! NOT Recommended
'say Hello': sayHello, // path = /say%20Hello !! ROUTE WONT BE FOUND
} satisfies Routes;
initRoutes refuses the names __proto__, prototype and constructor.Sanitizing Params
A route can apply the transforms its param types declare (trim, lowercase, strip separators) after decoding and before validation, on the server and in the client. Turn it on with sanitizeParams, globally in createMionRouter or per route. See Validation.
import {createMionRouter, Routes} from '@mionjs/router';
import type * as TF from '@mionjs/run-types/formats';
// The type says what a clean value looks like: trimmed and lowercased.
type Email = TF.Transform<TF.Email, {trim: true; lowercase: true}>;
// Turn sanitizing on for every route, or per route below. It is off by default.
const mion = createMionRouter({sanitizeParams: true});
const routes = {
// ' [email protected] ' arrives, the handler gets '[email protected]'
login: mion.route(
(ctx, email: Email, password: string): boolean =>
email.length > 0 && password.length > 0
),
// this route opts out: the handler gets the value exactly as sent
echo: mion.route((ctx, email: Email): string => email, {
sanitizeParams: false,
}),
} satisfies Routes;
mion.initRoutes(routes);
Request Size
mion works out the largest valid request for each route from the parameter types of the route and its middleware. A bigger request gets a 413 error, as early as the platform allows. You write no size limits by hand.
import {createMionRouter, Routes} from '@mionjs/router';
import * as TF from '@mionjs/run-types/formats';
interface Item {
id: TF.String<{maxLength: 36}>;
qty: number;
}
// the one router-wide knob: the slack applied to a limit derived from the types
const mion = createMionRouter({maxBodySizeFactor: 2});
const routes = {
// every param has a maximum, so the limit is derived from the types
addItems: mion.route(
(
ctx,
orderId: TF.String<{maxLength: 36}>,
items: TF.FormattedArray<Item[], {maxItems: 50}>
): number => items.length
),
// a plain string has no maximum, so this route takes the adapter's number
search: mion.route((ctx, text: string): number => text.length),
// the route option wins over both
upload: mion.route((ctx, payload: string): number => payload.length, {
maxBodySize: 512_000,
}),
} satisfies Routes;
mion.initRoutes(routes);
The first limit that applies wins:
| Limit | Used when |
|---|---|
maxBodySize on the route | You set it on the route options. |
| From the types | Every parameter has a maximum, like a string with maxLength or an array, Set or Map with maxItems. The limit is the largest valid JSON body, times maxBodySizeFactor (a router option, 2 by default). |
maxBodySize on the platform adapter | A parameter has no maximum, like a plain string, string[], Map, record or any. 128 KB by default. |
derivedPayloadLimits: false in the runTypes block of the Vite plugin or withMion.Platform Ceilings
Some platforms refuse a request past their own ceiling before mion runs, and no route limit goes above it. Each platform page lists its ceiling, and the adapter's maxBodySizeCap option sets it.
import {createCloudflareHandler} from '@mionjs/platform-cloudflare';
// a bigger plan allows more than the default ceiling
createCloudflareHandler({maxBodySizeCap: 200_000_000});
Keeping the Raw Body
Once the body is parsed mion drops the raw text, so ctx.request.rawBody reads empty in your handlers. Set releaseRawBody: false in createMionRouter to keep it, for an access log that records exactly what arrived.
Awaiting Every Step
mion awaits every middleware and route, even synchronous ones, so other requests can run between steps. Set alwaysAwait: false to skip the await on synchronous handlers: faster, but the slowest requests can get slower under load. A route whose middlewares and handler are all synchronous then answers with no promise (on Node.js and uWebSockets.js always, on Bun when the request has no body).
const mion = createMionRouter({alwaysAwait: false});