orpc-file-router
v0.1.0
Published
File-based routing for oRPC v2: generates a fully typed, lazily loaded router from a directory of files
Maintainers
Readme
orpc-file-router
File-based routing for oRPC v2. A directory of files is the router: a file becomes a procedure, a directory becomes a namespace — the Nitro/Next.js convention, but with full type inference and lazy module loading.
routes/ router RPC path
ping.ts → router.ping POST /ping
planet/
find.ts → router.planet.find POST /planet/find
admin/
remove.ts → router.planet.admin.remove POST /planet/admin/removeThe generator writes router.gen.ts with static import() paths, so
RouterClient<typeof router> resolves all the way down to individual
procedures, while modules are only loaded on first use.
Why
An oRPC router is a plain object, and in a project with a hundred procedures it becomes a file with a hundred imports that has to be edited on every rename. Here that file is generated, and the directory structure is the source of truth.
Neither of the two properties you picked oRPC for is lost:
- Types. Import paths in the generated file are static, so inference works end to end — from the namespace key to the procedure's return type.
- Laziness. Every leaf is wrapped in
os.lazy().RPCHandlerloads exactly one module per request; sibling procedures are never read from disk.
Installation
bun add orpc-file-router
npm install orpc-file-router@orpc/server is a required peer. @orpc/openapi and vite are optional
peers: the first enables OpenAPI metadata, the second is only needed for the
plugin.
The package ships compiled ESM (ES2022) with .d.ts declarations, so it works
in any bundler setup and on Node ≥ 20 — the floor comes from node:util's
parseArgs, used by the CLI — without special TypeScript settings on your side.
Sources are included too, and source maps point at them.
Quick start
Every route file default-exports a procedure:
// routes/planet/find.ts
import { os } from "@orpc/server";
import { z } from "zod";
export default os
.input(z.object({ id: z.string() }))
.handler(({ input }) => ({ id: input.id, name: "Mars" }));Generate the router:
bunx orpc-file-router generateYou get router.gen.ts — commit it to the repository:
// Generated by orpc-file-router. Do not edit.
import { os } from "@orpc/server";
export const router = {
ping: os.lazy(() => import("./routes/ping.ts")),
planet: {
find: os.lazy(() => import("./routes/planet/find.ts")),
},
};From there it is plain oRPC:
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "./router.gen.ts";
const handler = new RPCHandler(router);
Bun.serve({
port: 3000,
fetch: async (request) => {
const { matched, response } = await handler.handle(request, { prefix: "/rpc" });
return matched ? response : new Response("Not Found", { status: 404 });
},
});And a fully typed client:
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import type { router } from "./router.gen.ts";
const client: RouterClient<typeof router> = createORPCClient(
new RPCLink({ origin: "http://localhost:3000", url: "/rpc" }),
);
const planet = await client.planet.find({ id: "4" }); // return type is inferredFile conventions
| Path | Router key | Notes |
| ------------------------ | -------------------- | ---------------------------------- |
| routes/ping.ts | router.ping | a file is a procedure |
| routes/planet/find.ts | router.planet.find | a directory is a namespace |
| routes/planet/index.ts | router.planet | index.ts collapses into its dir |
| routes/my-route.ts | router["my-route"] | non-identifiers are quoted |
Ignored: any path segment starting with _ (_utils.ts,
_shared/helpers.ts), plus *.test.ts, *.spec.ts, *.d.ts and dotfiles.
Everything else ending in .ts becomes part of the router.
Generation errors
Generation fails with an explicit message — and leaves the existing
router.gen.ts untouched — when:
- a route file has no
export default. oRPC reads onlydefaultfrom a lazy module; a file without one fails the request with an unhandledTypeError— not a 404, not a 500 — and does so on every request. That is why it is checked at generation time. - a name is reserved:
then,bind,valueOf,toString,toJSON,~orpc,__proto__. The first five are unreachable through oRPC's client proxy (andthen.tsadditionally makes a server-side client thenable, soawait clientunexpectedly calls the procedure);~orpcis oRPC's internal brand key;__proto__in an object literal silently sets the prototype instead of creating a key. - names collide:
planet.tsnext to aplanet/directory, orplanet/index.tsnext toplanet/find.ts— a leaf procedure cannot also hold nested keys. index.tssits at the root of the routes directory: a root-levelLazyis supported by neither oRPC's matcher norunlazyRouter.
Ways to generate
CLI
orpc-file-router generate # routes/ → router.gen.ts
orpc-file-router generate --dir api --output src/router.gen.ts
orpc-file-router generate --no-openapi # without OpenAPI metadata
orpc-file-router watch # regenerate on structure changesRunning generate again with an unchanged structure rewrites nothing and
reports that the file is up to date. Errors go to stderr with exit code 1.
Vite plugin
// vite.config.ts
import { defineConfig } from "vite";
import { orpcFileRouter } from "orpc-file-router/vite";
export default defineConfig({
plugins: [orpcFileRouter({ dir: "routes", output: "router.gen.ts" })],
});The router is generated when the dev server starts and on every build. In dev
the plugin listens to Vite's watcher: adding, removing or renaming a route file
regenerates the router; a structural error goes to the HMR overlay and does not
clobber the working file. During build the same error fails the build.
Works with Vite 7 and 8.
Programmatic API
import { generateRouter, watchRoutes } from "orpc-file-router";
const { output, written } = await generateRouter({ dir: "routes" });
console.log(written ? `regenerated ${output}` : "up to date");
const handle = watchRoutes({
dir: "routes",
onGenerate: ({ output }) => console.log(`regenerated ${output}`),
onError: (error) => console.error(error),
});
// handle.close() stops watchingWithout code generation
buildRouter assembles the router in memory — handy for scripts and tests — but
procedure types are not inferred, because import paths are dynamic:
import { buildRouter, scanRoutes } from "orpc-file-router";
const root = new URL("./routes", import.meta.url).pathname;
const router = buildRouter(root, await scanRoutes(root));Options
| Option | Type | Default | Description |
| --------- | --------- | ----------------- | ---------------------------------------------- |
| dir | string | "routes" | routes directory, resolved from cwd |
| output | string | "router.gen.ts" | path of the generated file |
| openapi | boolean | auto | true when @orpc/openapi resolves in the project |
CLI flags mirror the options: --dir, --output, --openapi, --no-openapi.
Writes are atomic (via a temporary file) and idempotent: identical content is never rewritten, so tsc and HMR are not woken up for nothing.
OpenAPI
When @orpc/openapi is installed, every leaf gets metadata:
planet: {
find: os
.meta(openapi.prefix("/planet"), openapi.path("/find"))
.lazy(() => import("./routes/planet/find.ts")),
}This is not cosmetic. On any incoming path OpenAPIMatcher unwraps every lazy
branch that has no prefix metadata — so without it the first request would pull
in the entire router. With it, laziness survives at top-level-directory
granularity: a request for /planet/find only loads the planet branch, while
star/ stays untouched.
Paths stay clean (/planet/find, no duplicated segments), and in RPCHandler
laziness remains per-file — it builds paths from keys and never reads metadata.
A custom path is declared in the file itself and is mounted under the directory prefix:
// routes/planet/list.ts → GET /planet/planets
import { openapi } from "@orpc/openapi";
export default os
.meta(openapi({ method: "GET", path: "/planets" }))
.handler(() => []);Turn it off with --no-openapi or openapi: false.
Shared context
os.lazy is typed as AnyRouter, so the compiler will not catch a context
mismatch between files. Use a shared base builder:
// routes/_base.ts — the _ prefix keeps this file out of the router
import { os } from "@orpc/server";
export const base = os.$context<{ db: Database; user?: User }>();// routes/planet/find.ts
import { base } from "../_base.ts";
export default base.handler(({ context }) => context.db.planets.find());Limitations
- Server-side
createRouterClientre-unwraps lazy branches on every call (Builder.lazyrebuilds procedure objects each time). On a hot path useawait unlazyRouter(router).RPCHandlermemoizes on its own and is not affected. - Files at the root of
routes/have no directory prefix, soOpenAPIHandlerloads them on the first request. There are usually only a handful. OpenAPIGeneratorneeds the full tree:await unlazyRouter(router).- Extensions —
.tsonly..tsxand.jsare not supported.
API
| Export | Description |
| --------------------------------- | -------------------------------------------------- |
| generateRouter(options?, cwd?) | generate the file; returns { output, code, written } |
| renderRouter(tree, opts) | tree → source string, without touching disk |
| watchRoutes(options?, cwd?) | watch for changes; returns { close } |
| buildRouter(root, files) | in-memory router, no code generation |
| scanRoutes(root) | sorted list of route file paths |
| buildTree(files) | paths → tree, validating names and collisions |
| hasDefaultExport(source) | whether a module has export default (async) |
| assertRouteModules(root, files) | validate all files at once, throws RouteError |
| resolveOptions(options?, cwd?) | resolve options and auto-detect @orpc/openapi |
| RouteError | error describing a broken route structure |
| orpcFileRouter(options?) | Vite plugin (orpc-file-router/vite) |
Development
These scripts live in the repository, not in the published tarball — clone it first:
git clone https://github.com/Prains/orpc-file-router.git
cd orpc-file-router
bun install # runs `prepare`, which builds dist/ (needed by the bin)
bun test # unit + integration against oRPC handlers
bun run test:e2e # live Vite, core under Node, HTTP server, package install
bun run test:all
bun run typecheck
bun run build # src/*.ts → dist/*.js + .d.ts (what npm publishes)
bun run mutation # mutation testing (Stryker)
bun run example:server # demo: Bun.serve on a generated router
bun run example:vite # demo: Vite project with the pluginThe e2e suite covers what mocks cannot: a real Vite dev server with its watcher
and HMR overlay, a real production build, the core running under Node, network
calls from a typed client, and installing the package with its full exports
and bin. The examples/vite-app demo doubles as the e2e fixture, so it cannot
drift from the code.
Test quality is measured with mutation testing rather than line coverage: the
suite kills ~98% of mutants (bun run mutation). The handful that survive are
equivalent mutants — null vs undefined in a value that is only compared, and
a debounce clearTimeout whose extra run is idempotent because generation is.
One caveat: mutating fs.watch's recursive flag survives on macOS, where
FSEvents reports nested changes regardless, and the boolean variant flips
between runs depending on event timing. On Linux (inotify) those mutants should
die, so the score there will differ slightly.
License
MIT
