Vite
mionVitePlugin from @mionjs/devtools/vite is the one Vite plugin for mion's build-time features: compiling validation and the JSON round trip, the batch transport, and, for fullstack apps, running your API inside the Vite dev server.
The options are listed in All Plugin Options.
Configuration Examples
Server-Only Builds
Use a plain library build for an API with no client in the same project.
import {defineConfig} from 'vite';
import {resolve} from 'path';
import {mionVitePlugin} from '@mionjs/devtools/vite';
export default defineConfig({
plugins: [mionVitePlugin()],
build: {
lib: {
entry: resolve(__dirname, 'src/server.ts'),
formats: ['es'],
},
rollupOptions: {
external: [/^[^./]/],
},
},
});
One Tsconfig for Client and Server
mion reads your client and server code as one program. tsConfig names the tsconfig that includes both, and defaults to tsconfig.json in the Vite root.
references and no files (the shape Vite starters use) fails the build. Point tsConfig at the tsconfig that lists your source files.Fullstack Apps
For a fullstack app (Nuxt, SSR, a frontend's own backend), set server.entry to your server entry file. vite dev then mounts your API in this Vite process as dev-server middleware: one process, one port, one module graph. The frontend calls /api/... on the origin it is served from.
Your server entry needs no changes: mion.initRoutes(routes); startNodeServer(); registers its routes without opening a port. Batches need nothing either: client and API are one program, and a batch edit reloads the API like any other change.
import {defineConfig} from 'vite';
import {mionVitePlugin} from '@mionjs/devtools/vite';
export default defineConfig({
plugins: [
mionVitePlugin({
server: {
// loaded through Vite's SSR pipeline (`ssrLoadModule`), so the same plugin transforms it
entry: 'src/server.ts',
// optional, reloads the API when its sources change (default true)
hotReload: true,
},
}),
],
});
The router's basePath decides what reaches mion, matched on a path boundary, so /api never catches /apidocs. With no basePath, mion serves the root and exclude leaves Vite's own URLs (/@vite/..., assets, HMR pings) to Vite; extend DEFAULT_MIDDLEWARE_EXCLUDE rather than replace it. Editing a route file reloads the API on the next request, and a failed load answers 503 with the real cause instead of a blank page.
A fetch-based adapter (@mionjs/platform-bun) works the same way, in every runtime. For an adapter that starts no server (an edge handler), export its handler from the entry as requestHandler.
One Config, Two Bundles
Set server.build and one vite build emits the static client files in build.outDir (default dist) and your API in server.build.outDir (default dist-server): static files on a CDN plus an API server, from the same config the dev server uses.
import {defineConfig} from 'vite';
import {mionVitePlugin} from '@mionjs/devtools/vite';
export default defineConfig({
plugins: [
mionVitePlugin({
// one tsconfig for client and server code (the default)
tsConfig: 'tsconfig.json',
server: {
entry: 'src/server.ts',
// opt in to the API bundle; leave it out and `vite build` emits the client half only
build: {outDir: 'dist-server'},
},
}),
],
});
Both bundles come from one program, so a type shared by client and API is compiled once and cannot drift. Each bundle holds only the compiled types of the files it imports: the client gets the routes it calls, never the rest of your API. Keep client and server code in separate files, since a file both sides import puts its types in both bundles.
server block and use a plain library build, as in Server-Only Builds.Client Tests
Tests that need a real socket start the API in the same process, from a Vitest globalSetup. Client and API stay one program, so the build compiles the batches your tests write into the API they run against.
import {startNodeServer} from '@mionjs/platform-node';
import type {Server} from 'node:http';
// the routes module: importing it registers the router
import '../http/node-routes.ts';
let server: Server | undefined;
export async function setup() {
// resolves once the socket is listening, so nothing is spawned and no port is polled
server = (await startNodeServer({port: 8076})) as Server;
}
export async function teardown() {
if (server) await new Promise<void>((done) => server?.close(() => done()));
}
Vue Single-File Components
Typed mion code in a .vue <script> (createValidate*, batch, inputFrom, any call the plugin compiles) is compiled like code in a .ts file. Keep @vitejs/plugin-vue in the same config; <script setup> and classic <script> both work, even with a type declared in one and used in the other.
The types a marker reads must live in a .ts or .d.ts file or a package. A type imported from another .vue file cannot be resolved, so the build stops with marker-any-from-unresolved-import naming that import.
runTypes: {sfc: false} turns this off. The plugin still warns when a .vue file ships a marker with no compiled functions, since that fails at runtime.Bundled Client
By default the build bundles every route the client calls, under .mion/api/, so a call needs no request and no code built at runtime. Set client: {routes: 'fetch'} to fetch them from the server instead.
Generic Call Helpers
The build bundles a call only when it can see which route the call names. A helper typed with a plain RouteSubRequest<any> hides the route, so keep the helper generic.
import {initClient, type RouteSubRequest} from '@mionjs/client';
import type {MyApi} from './init.routes.ts';
const {routes} = initClient<MyApi>({baseURL: 'http://localhost:3000'});
// This route is bundled: the call names it, so its functions ship with the app.
const [user, error] = await routes.users.getById('u1').call();
// A helper keeps the route's name when it stays generic, so the build still sees which route it calls.
async function callWithRetry<S extends RouteSubRequest<any>>(
sub: S,
tries = 2
) {
let last: Awaited<ReturnType<S['call']>> | undefined;
for (let i = 0; i < tries; i++) {
last = (await sub.call()) as Awaited<ReturnType<S['call']>>;
if (!last[2]['@thrownErrors']) break; // no untyped error: done
}
return last!;
}
const [again] = await callWithRetry(routes.users.getById('u1'));
console.log(user, error, again);
Client Without the Plugin
Without the plugin, a client fetches validation and JSON round trip metadata from the server at runtime, so single route calls need no build step once you set up useFetchMetadata. It cannot send a batch: batch ids come from the build, so batch() throws batch-missing-id. A client that uses batches must be built in the same program as its API.
Batch Transport
A batch is written in client code, but the server runs only the batch ids it knows. The build gives each batch([...]) call an id from its routes and mappings, and collects them for the server itself, with no option:
- It reads every
batch([...])call and inlineinputFrommapper in the program. It writes the batch table (batches.generated.js) and one module per inline mapper to.mion/rpc/in its gen dir. - It adds the table's import to the module that calls
createMionRouter, so you write nothing.vite buildinlines the table and bundles each mapper, so the output has nonode:fsand stays edge and lambda safe. - The client calls get each batch's id and each mapper's hash from the same source, so the two sides agree in any build order.
mion compile in the CLI generates the same folder.
Lint Rules
The lint plugin ships in the same package, on the @mionjs/devtools/eslint entry. See Linter for its rules and config.
All Plugin Options
import {mionVitePlugin} from '@mionjs/devtools/vite';
mionVitePlugin({
// The one tsconfig that includes your client and server code (default: tsconfig.json in the Vite root)
tsConfig?: string;
client?: {
// 'bundle' (default): ship each called route's metadata and compiled functions, no request needed.
// 'fetch': get them from the server on first use (needs useFetchMetadata and mionFetchMetadata).
routes?: 'bundle' | 'fetch';
},
// Fullstack app only: the mion API this Vite run hosts, mounted INSIDE `vite dev`, and a second
// `vite build` bundle with `build` set. Leave it out for an API started on its own.
server?: {
entry: string; // The server entry file, relative to the Vite root (required)
// build: opt in to the second bundle
build?: {
outDir?: string; // Where the API bundle lands (default: 'dist-server')
},
// dev server
exclude?: RegExp[]; // With no router basePath: paths left to Vite (assets, /@vite/…)
hotReload?: boolean; // Re-load the API on source changes (default: true)
},
// Build-time compilation of validation and the JSON round trip
runTypes?: {
genDir?: string; // Generated-output root (default: <srcDir>/.mion)
emitMode?: 'code' | 'both' | 'ts'; // What generated functions ship (default: 'code'). MUST be 'both' on edge
// runtimes (Cloudflare Workers, Vercel Edge), which forbid the
// `new Function` that 'code' runs on first use. 'ts' writes typed .ts modules.
downgradeErrors?: string[] | '*'; // Report these RuntimeErrors as warnings instead of stopping (default: none)
levels?: 'all'; // Also print Info messages, hidden by default
logStyle?: 'grouped' | 'lines'; // Each message once with its places (default), or one line each
patternSampleCount?: number; // mockSamples generated per pattern that declares none
patternSampleRetries?: number; // Generation retries before failing with format-sample-generation-failed
derivedPayloadLimits?: boolean; // Derive per-route size limits from the types (default: true)
jsRuntime?: string; // Runtime for the pattern-check sidecar (node/bun auto-detected)
sfc?: boolean; // Transform typed code in Vue SFC <script> blocks (default: true)
},
});