Devtools

Vite

Configure the mion Vite plugin for server and client packages.

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.

Server
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.

A tsconfig with only 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.

Client + in-process API
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.

vite.config.ts
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.

For a standalone API with no client, leave out the 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.

globalSetup.ts
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.

client.ts
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 inline inputFrom mapper 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 build inlines the table and bundles each mapper, so the output has no node:fs and 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.

A batch the build cannot read (a spread, a route from a function parameter, a mapper it cannot trace) fails the build, because a batch without an id can never be sent.

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)
    },
});
Build-time compilation is always on, the batch transport is automatic, and scan scope comes from your tsconfig, so none of the three has an option here.
Copyright © 2026