npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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

The 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(). RPCHandler loads 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 generate

You 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 inferred

File 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 only default from a lazy module; a file without one fails the request with an unhandled TypeError — 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 (and then.ts additionally makes a server-side client thenable, so await client unexpectedly calls the procedure); ~orpc is oRPC's internal brand key; __proto__ in an object literal silently sets the prototype instead of creating a key.
  • names collide: planet.ts next to a planet/ directory, or planet/index.ts next to planet/find.ts — a leaf procedure cannot also hold nested keys.
  • index.ts sits at the root of the routes directory: a root-level Lazy is supported by neither oRPC's matcher nor unlazyRouter.

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 changes

Running 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 watching

Without 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 createRouterClient re-unwraps lazy branches on every call (Builder.lazy rebuilds procedure objects each time). On a hot path use await unlazyRouter(router). RPCHandler memoizes on its own and is not affected.
  • Files at the root of routes/ have no directory prefix, so OpenAPIHandler loads them on the first request. There are usually only a handful.
  • OpenAPIGenerator needs the full tree: await unlazyRouter(router).
  • Extensions.ts only. .tsx and .js are 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 plugin

The 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