A minimal filesystem router + controller framework for Bun that:
- Uses Bun’s native route matching (
serve({ routes })) — no custom matcher on the hot path - Autoloads controllers from files like
get.ts,post.ts, etc. - Validates request JSON via Standard Schema V1 (Zod, Valibot, ArkType, Yup, Joi, Effect Schema, …)
- Optionally extracts route params from bracketed paths (e.g.,
/users/[id]) without replacing Bun’s router - Supports UUID path param validation and optional authentication
- Returns consistent JSON errors
Standard Schema: https://standardschema.dev
bun add @literallyjoel/router
# or
npm install @literallyjoel/routerInstall any Standard Schema-compatible validator you prefer (Zod, Valibot, ArkType, Yup, Joi, etc.).
import { serve } from "bun";
import index from "@app/index.html";
import { getRoutes } from "@literallyjoel/router";
const routes = await getRoutes({
routesDirectory: "./src/routes",
routePrefix: "/api", // optional; you can also include 'api' in your directory structure
authProvider: {
getSession: async (headers) => {
const token = headers.get("authorization");
return token ? { user: { id: "123" } } : null;
},
},
logger: {
error: (message, meta) => console.error(message, meta),
},
[id]`
});
const server = serve({
routes: {
// Serve index.html for all unmatched routes.
"/*": index,
// Default 404 for unmatched API paths
"/api/*": new Response("Not Found", { status: 404 }),
// Autogenerated API endpoints from the filesystem:
...routes,
},
development: process.env.NODE_ENV !== "production" && {
hmr: true,
console: true,
},
port: 8181,
});
console.log(`🚀 Server running at ${server.url}`);getRoutes() discovers controllers from filenames:
src/
routes/
users/
get.ts → GET /api/users
post.ts → POST /api/users
users/[id]/
get.ts → GET /api/users/[id]
- Use
routePrefixto mount under a base path (e.g.,/api). - Bracketed segments like
[id]are recognized and, when enabled, params are extracted at runtime and attached toreq.params.
Create controllers with createController(handler, config, additionalValidator?).
validationSchema: any Standard Schema V1-compliant schema (Zod, Valibot, ArkType, Yup, Joi, …)requiresAuthentication: booleanvalidateUUIDs: string[] of param keys to validate as UUIDauthProvider: override per-controller if desired (otherwise uses the one fromgetRoutes)
Example (Zod):
// src/routes/users/post.ts
import { createController } from "@literallyjoel/router";
import { z } from "zod";
const Schema = z.object({
username: z.string().min(3),
email: z.string().email(),
});
export default createController(
async (ctrl) => {
return Response.json({ user: ctrl.json });
},
{
validationSchema: Schema, // Standard Schema via Zod
requiresAuthentication: false,
},
() => []
);Valibot:
import { createController } from "@literallyjoel/router";
import * as v from "valibot";
const Schema = v.object({
username: v.string([v.minLength(3)]),
email: v.string([v.email()]),
});
export default createController(
async (ctrl) => Response.json({ user: ctrl.json }),
{ validationSchema: Schema, requiresAuthentication: false },
() => []
);ArkType:
import { createController } from "@literallyjoel/router";
import { type } from "arktype";
const Schema = type({ username: "string.min(3)", email: "string.email" });
export default createController(
async (ctrl) => Response.json({ user: ctrl.json }),
{ validationSchema: Schema, requiresAuthentication: false },
() => []
);- When
paramsextraction is enabled (default), bracketed paths like/users/[id]will populatereq.params = { id: "..." }. - Controllers can ask to validate specific params as UUIDs:
export default createController(
async (ctrl) => Response.json({ id: ctrl.params.userId }),
{
requiresAuthentication: false,
validateUUIDs: ["userId"],
},
() => []
);Notes:
- Bun’s native
routesmap doesn’t populatereq.params. This package adds params only for paths discovered with bracket segments, keeping the overhead minimal (compiled once at boot; O(segments) match per request for those routes only). - Static routes incur zero param-extraction overhead.
Provide an authProvider in getRoutes() to enable sessions for all routes:
authProvider: {
getSession: async (headers) => {
const token = headers.get("authorization");
return token ? { user: { id: "123" } } : null;
},
}If a controller sets requiresAuthentication: true and session is missing, the controller responds with 401 automatically.
This library accepts any validator that implements Standard Schema V1:
- The schema must expose
~standard.validate(value)andversion: 1. validatereturns{ value }on success or{ issues }on failure (sync or async).- Validation failures are returned as
ValidationError(400) with a standardizedfieldsarray.
Example error response:
{
"message": "Bad Request",
"fields": [
{ "field": "email", "message": "Invalid email" }
]
}- ValidationError (400)
- UnauthorizedError (401)
- ForbiddenError (403)
- NotFoundError (404)
- ConflictError (409)
- InternalServerError (500)
Usage:
throw new NotFoundError({ message: "User not found" });-
getRoutes(options)routesDirectory: stringroutePrefix?: string (prefix all discovered routes, e.g.,/api)authProvider?: {getSession(headers): Promise<any | null>}logger?: {error(message, meta?)}
-
createController(handler, config, additionalValidator?)validationSchema?: StandardSchemaV1<any, TData>validateUUIDs?: string[]requiresAuthentication: booleanauthProvider?: AuthProvider
-
BaseControllerrequest: BunRequestctx: HandlerContextjson: TData (validated)params: Record<string, string> (validated UUIDs if configured)session,user
bun build ./src --outdir ./dist
npm publish --access publicpackage.json should include:
{
"type": "module",
"main": "./dist/index.js",
"exports": { ".": "./dist/index.js" }
}- Validation uses Standard Schema V1 (sync or async). Issues are mapped to field errors.
- Relies on Bun’s
routesmap for dispatch. This package only discovers controllers and wires handlers. - Param extraction is runtime-only for bracketed paths; static routes have zero overhead.