npm package: @literallyjoel/router
Description: Bun router/controllers with Standard Schema validation
Author: literallyJoel
Latest version: 1.0.10

# @literallyjoel/router

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

## Installation

```bash
bun add @literallyjoel/router
# or
npm install @literallyjoel/router
```

Install any Standard Schema-compatible validator you prefer (Zod, Valibot, ArkType, Yup, Joi, etc.).

## Quick Start (Bun’s routes)

```ts
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
  sessionGetter: async (headers) => {
    const token = headers.get("authorization");
    return token ? { user: { id: "123" } } : null;
  },
  logger: {
    error: (message, meta) => console.error(message, meta),
  },
});

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}`);
```

## File structure

`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 `routePrefix` to mount under a base path (e.g., `/api`).
- Bracketed segments like `[id]` are recognized and, when enabled, params are extracted at runtime and attached to `req.params`.

## Controllers

Create controllers with `createController(handler, config, additionalValidator?)`.

- `validationSchema`: any Standard Schema V1-compliant schema (Zod, Valibot, ArkType, Yup, Joi, …)
- `querySchema`: optional Standard Schema V1 schema for query params
- `requiresAuthentication`: boolean
- `validateUUIDs`: string[] of param keys to validate as UUID

Example (Zod):

```ts
// 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,
  },
  () => [],
);
```

Query params:

```ts
import { createController } from "@literallyjoel/router";
import { z } from "zod";

const Query = z.object({
  page: z.coerce.number().int().min(1).default(1),
  tag: z.array(z.string()).optional(),
});

export default createController(
  async (ctrl) => {
    // Typed from querySchema:
    const page = ctrl.query.page;

    // Raw URL API is also available:
    const rawSearch = ctrl.searchParams.get("search");

    return Response.json({ page, rawSearch });
  },
  {
    requiresAuthentication: false,
    querySchema: Query,
  },
);
```

Valibot:

```ts
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:

```ts
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 },
  () => [],
);
```

## Params and UUIDs

- When `params` extraction is enabled (default), bracketed paths like `/users/[id]` will populate `req.params = { id: "..." }`.
- Controllers can ask to validate specific params as UUIDs:

```ts
export default createController(
  async (ctrl) => Response.json({ id: ctrl.params.userId }),
  {
    requiresAuthentication: false,
    validateUUIDs: ["userId"],
  },
  () => [],
);
```

Notes:

- Bun’s native `routes` map doesn’t populate `req.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.

## Authentication

Provide a `sessionGetter` in `getRoutes()` to enable sessions for all routes:

```ts
sessionGetter: 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.

## Validation (Standard Schema)

This library accepts any validator that implements Standard Schema V1:

- The schema must expose `~standard.validate(value)` and `version: 1`.
- `validate` returns `{ value }` on success or `{ issues }` on failure (sync or async).
- Validation failures are returned as `ValidationError` (400) with a standardized `fields` array.

Example error response:

```json
{
  "message": "Bad Request",
  "fields": [{ "field": "email", "message": "Invalid email" }]
}
```

## Built-in errors

- ValidationError (400)
- UnauthorizedError (401)
- ForbiddenError (403)
- NotFoundError (404)
- ConflictError (409)
- InternalServerError (500)

Usage:

```ts
throw new NotFoundError({ message: "User not found" });
```

## API

- `getRoutes(options)`
  - `routesDirectory`: string
  - `routePrefix?`: string (prefix all discovered routes, e.g., `/api`)
  - `sessionGetter?`: `(headers: Headers) => Promise<any | null> | any | null`
  - `logger?`: { `error(message, meta?)` }

- `createController(handler, config, additionalValidator?)`
  - `validationSchema?`: StandardSchemaV1<any, TData>
  - `querySchema?`: StandardSchemaV1<any, TQuery>
  - `validateUUIDs?`: string[]
  - `requiresAuthentication`: boolean

- `BaseController`
  - `request`: BunRequest
  - `ctx`: HandlerContext
  - `json`: TData (validated)
  - `query`: raw query object (`Record<string, string | string[] | undefined>`) or validated `TQuery` when `querySchema` is configured
  - `searchParams`: URLSearchParams
  - `params`: Record<string, string> (validated UUIDs if configured)
  - `session`, `user`

## Build & Publish

```bash
bun build ./src --outdir ./dist
npm publish --access public
```

package.json should include:

```json
{
  "type": "module",
  "main": "./dist/index.js",
  "exports": { ".": "./dist/index.js" }
}
```

## Notes

- Validation uses Standard Schema V1 (sync or async). Issues are mapped to field errors.
- Relies on Bun’s `routes` map for dispatch. This package only discovers controllers and wires handlers.
- Param extraction is runtime-only for bracketed paths; static routes have zero overhead.