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

@fresho/router

v0.6.2

Published

Type-safe route definitions with auto-generated clients and OpenAPI docs

Readme

@fresho/router

Type-safe routing for Cloudflare Workers, Deno, Bun, and Node.js. Define routes once, get validated handlers, typed clients, and OpenAPI docs.

~2KB gzipped. Zero dependencies.

import { route, router } from "@fresho/router";

const api = router({
	health: router({
		get: async () => ({ status: "ok" }),
	}),

	users: router({
		// GET /users - list with optional limit
		get: route({
			query: { limit: "number?" },
			handler: async (c) => db.users.list(c.query.limit),
		}),

		// POST /users - create user
		post: route({
			body: { name: "string", email: "string" },
			handler: async (c) => db.users.create(c.body),
		}),

		// GET /users/:id - get by id
		$id: router({
			// Bare function shorthand instead of `route` type
			get: async (c) => db.users.get(c.path.id),
		}),
	}),
});

// Cloudflare Worker / Deno / Bun
export default { fetch: api.handler() };

Features

  • Type-safe path params — $id creates dynamic segments whose names are inferred by handlers and clients
  • Schema validation — query and body validated at runtime, typed at compile time
  • Property-based routing — property names become URL segments
  • Typed HTTP client — call your API with full type safety
  • Typed local client — test handlers directly without HTTP
  • OpenAPI generation — auto-generate docs from your routes
  • MCP server — expose routes as tools for AI agents
  • Middleware — cors, auth, rate limiting, and more
  • Streaming — SSE and JSON lines helpers
  • HTTP-compliant — automatic HEAD support for GET routes
  • Zero dependencies — just Web APIs

Installation

npm install @fresho/router
bun add @fresho/router

Runtime Support

The full test suite runs in Node.js 22/24 and Bun 1.3.14. CI also deploys the built package to a local Cloudflare workerd runtime and verifies routing, validation, middleware, and streaming behavior.

Path Convention

Property names become URL path segments:

router({
  api: router({           // /api
    users: router({       // /api/users
      get: async () => ...,
      $id: router({       // /api/users/:id
        get: async (c) => c.path.id,
      }),
    }),
  }),
});
  • Regular properties → static segments (users → /users)
  • $param properties → dynamic segments ($id → /:id)
  • HTTP methods (get, post, etc.) → handlers at that path

Schemas

Define query and body schemas using shorthand syntax:

// Primitives
{ name: 'string' }      // required string
{ age: 'number' }       // required number (coerced from string in query)
{ active: 'boolean' }   // required boolean (accepts "true"/"false"/"1"/"0")

// Optional (append ?)
{ name: 'string?' }     // optional string

// Arrays
{ tags: 'string[]' }    // string array
{ scores: 'number[]' }  // number array

// Field descriptions (for OpenAPI and MCP tools)
{ folder: ['string?', 'Mailbox folder, INBOX by default'] }

// Nested objects
{
  address: {
    street: 'string',
    city: 'string',
    zip: 'number?'
  }
}

Types are automatically inferred:

post: route({
	body: {
		title: "string",
		tags: "string[]",
		metadata: { priority: "number", draft: "boolean?" },
	},
	handler: async (c) => {
		c.body.title; // string
		c.body.tags; // string[]
		c.body.metadata; // { priority: number, draft: boolean | undefined }
	},
});

Typing Best Practices

Schemas (query/body) provide runtime validation AND type inference:

get: route({
	query: { limit: "number?" },
	handler: async (c) => c.query.limit, // number | undefined
});

Context (route.ctx<T>()) provides types only for things schemas don't cover:

interface MyContext {
	path: { id: string }; // from $id segment
	env: { DB: Database }; // runtime environment
	user: { name: string }; // from auth middleware
}

get: route.ctx<MyContext>()({
	query: { include: "string?" }, // validated
	handler: async (c) => ({
		id: c.path.id, // from context
		user: c.user.name, // from context
		include: c.query.include, // from schema
	}),
});

Don't add explicit type annotations to handlers — let types flow from schemas and context:

// GOOD: types inferred
handler: async (c) => c.query.limit;

// BAD: redundant annotation
handler: async (c: { query: { limit?: number } }) => c.query.limit;

Nested Routers

Compose routers:

const users = router({
  get: async () => db.users.list(),
  post: route({
    body: { name: 'string' },
    handler: async (c) => db.users.create(c.body),
  }),
  $id: router({
    get: async (c) => db.users.get(c.path.id),
    delete: async (c) => db.users.delete(c.path.id),
  }),
});

const api = router({
  users,
  posts: router({ ... }),
});

// Routes:
// GET    /users
// POST   /users
// GET    /users/:id
// DELETE /users/:id

Middleware

Add middleware to routers:

import { router, route } from "@fresho/router";
import { cors, errorHandler, jwtAuth } from "@fresho/router/middleware";

const api = router(
	{
		hello: router({
			get: async () => ({ message: "world" }),
		}),
	},
	cors(),
	errorHandler(),
	jwtAuth({ secret: process.env.JWT_SECRET, claims: (p) => ({ user: p.sub }) })
);

Built-in middleware: cors, errorHandler, logger, rateLimit, requestId, timeout, basicAuth, bearerAuth, jwtAuth, contentType, cookies.

See Middleware Documentation for detailed usage.

Authentication Utilities

For standalone auth utilities (JWT signing/verification, Basic auth, OAuth), import from @fresho/router/auth:

import { createJwks, jwtSign, jwtVerify, parseBasicAuth } from "@fresho/router/auth";

// Sign a JWT
const token = await jwtSign({ uid: "user-123" }, secret, { expiresIn: "1h" });

// Verify a JWT
const payload = await jwtVerify(token, secret);

// Verify a third-party OAuth/OIDC token against the provider's JWKS
const jwks = createJwks("https://example.auth0.com/.well-known/jwks.json");
const claims = await jwtVerify(accessToken, jwks, {
	algorithms: ["RS256"],
	issuer: "https://example.auth0.com/",
	audience: "https://api.example.com",
});

// Parse Basic auth header
const creds = parseBasicAuth(request.headers.get("Authorization"));

Available utilities:

  • JWT: jwtSign, jwtVerify, createJwks (HS*, RS*, PS*, ES256/384)
  • Basic Auth: parseBasicAuth, encodeBasicAuth, extractBasicAuthToken
  • OAuth: encodeOAuthState, decodeOAuthState, exchangeCode, refreshAccessToken, revokeToken, buildAuthorizationUrl, OAUTH_PROVIDERS

See Authentication Documentation for detailed usage.

HTTP Client

Generate a typed client for your API:

// === Server (api.ts) ===
import { route, router } from "@fresho/router";

export const api = router({
	users: router({
		get: route({
			query: { limit: "number?" },
			handler: async (c) => ({ users: [], limit: c.query.limit }),
		}),
		$id: router({
			get: async (c) => ({ id: c.path.id, name: "Alice" }),
		}),
	}),
});

// === Client ===
import { createHttpClient } from "@fresho/router";
import type { api } from "./api"; // Type-only import!

const client = createHttpClient<typeof api>({
	baseUrl: "https://api.example.com",
});

// Direct call for implicit GET
const users = await client.users({ query: { limit: 10 } });

// Path params
const user = await client.users.$id({ path: { id: "123" } });
// user is typed as { id: string, name: string }

// Explicit HTTP methods use $-prefix
await client.users.$get();                    // GET /users
await client.users.$post({ body: { name: "Bob" } }); // POST /users

The $id segment determines the id key in the client's path option, so missing or misspelled parameter names are caught at compile time.

Routers Built by a Factory

If your router is created inside a factory so its handlers can close over injected dependencies, derive the API type from the factory's return type. The type-only import keeps server code out of the client bundle:

import { createHttpClient } from "@fresho/router";
import type { createServer } from "./server";

type Api = ReturnType<typeof createServer>["api"];

const client = createHttpClient<Api>({
	baseUrl: "https://api.example.com",
});

HTTP Method Syntax

HTTP methods are prefixed with $ to distinguish them from path navigation:

// $-prefixed = execute HTTP method
client.users.$get();     // GET /users
client.users.$post({});  // POST /users
client.users.$put({});   // PUT /users
client.users.$delete();  // DELETE /users

// No prefix = navigate to path segment
client.api.get.$get();   // GET /api/get (navigate to "get", then execute GET)

This allows routes with path segments named after HTTP methods (e.g., /api/get, /resources/delete).

Building URLs Without Fetching

Use $url to derive a URL string from the typed routes without firing a request. It accepts the same path/query options as a real call, including the parameter names inferred from $id segments:

// Absolute URL when baseUrl is configured:
client.media.playlist.$url({ query: { channel: "classics", chunks: 1 } });
// → "https://api.example.com/media/playlist?channel=classics&chunks=1"

client.users.$id.$url({ path: { id: "123" } });
// → "https://api.example.com/users/123"

// Relative URL (pathname + search) when no baseUrl is set:
createHttpClient<typeof api>({}).users.$id.$url({ path: { id: "123" } });
// → "/users/123"

This keeps route paths as the single source of truth for URLs embedded in response bodies, deep links, or server-side URL generation — cases where raw: true can't help, since Response.url only exists after a request runs.

Local Client

Test handlers directly without HTTP overhead:

import { createLocalClient } from "@fresho/router";

const client = createLocalClient(api);
client.configure({ env: { DB: mockDb } });

const user = await client.users.$id({ path: { id: "123" } });
assert.equal(user.name, "Alice");

OpenAPI Generation

Generate OpenAPI 3.0 documentation:

import { generateDocs } from "@fresho/router";

const spec = generateDocs(api, {
	title: "My API",
	version: "1.0.0",
});

// Serve at /openapi.json
const docs = router({
	openapi: router({
		get: async () => spec,
	}),
});

MCP Server

Expose routes as Model Context Protocol tools, so agents can use the same API as your other clients. Requires the optional peer dependency @modelcontextprotocol/sdk:

npm install @modelcontextprotocol/sdk
import { router } from "@fresho/router";
import { mcp } from "@fresho/router/mcp";

const api = router({
	users,
	billing,
	mcp: mcp({ users }, { name: "my-api", version: "1.0.0" }, bearerAuth({ verify })),
});

mcp() requires endpoint middleware (usually auth). Pass public: true instead when the endpoint is open, or already guarded by middleware on its parent routers.

Besides name and version, the options take instructions for agents and title, icons ({ src, mimeType?, sizes? }) and websiteUrl, which clients may show in their UI.

  • Tools — one per route, named from the method and path (get_users, post_users, get_users_by_id, ...) unless the route sets operationId (also used in OpenAPI). Descriptions come from route.description.
  • Arguments — flat: path params, query fields and body fields side by side ({ id, text }), as JSON Schema derived from the route's schemas. Names must be unique across the three; a clash throws when mcp() is called. Use [type, description] tuples (e.g. { folder: ['string?', 'Mailbox folder'] }) in query and body schemas to document fields for agents and OpenAPI. Array query params are left out, since the router can't parse them from query strings. Without a body schema, post/put/patch tools send any other arguments as the body.
  • Annotations — get tools are marked readOnlyHint, delete tools destructiveHint and idempotentHint, and put tools idempotentHint, so clients can decide when to ask for confirmation. Override per route with mcp: { annotations: { ... } }.
  • Calls — each tool call is dispatched as an HTTP request through the given routers with the caller's headers forwarded. Middleware on the MCP endpoint (passed to mcp() or on its parent routers) runs once per MCP request, and what it adds to the context (e.g. c.user) reaches handlers; middleware on the given routers and below runs on every call. Middleware on the given routers' own parents elsewhere in your app does not run, so guard the endpoint or the routers themselves. Responses become text content, plus structuredContent when the body is a JSON object; a non-2xx status sets isError, and streaming (SSE, NDJSON) responses are rejected.
  • Safety — requests with an Origin other than the endpoint's get 403 (as the MCP spec requires against DNS rebinding), so browser-hosted clients on other origins can't connect. Path parameters can't be . or .., and handler exceptions become a generic Internal error.
  • Transport — stateless Streamable HTTP with JSON responses; GET and DELETE return 405.
  • Pass a router to expose all of its routes, or a record of routers to mount them under their keys.

OAuth

Put auth middleware on the endpoint so unauthenticated clients get an HTTP 401, and set oauth to advertise your authorization server (RFC 9728):

const oauth = { authorizationServers: ["https://auth.example.com"], scopes: ["users:read"] };

const app = router({
	users,
	mcp: mcp({ users }, { name: "my-api", version: "1.0.0", oauth }, jwtAuth({ ... })),
	// Optional: metadata at the origin root, for clients that probe it before calling the endpoint.
	".well-known": oauthProtectedResource(oauth),
});

401s from the endpoint gain WWW-Authenticate: Bearer resource_metadata="https://host/mcp/.well-known/oauth-protected-resource", and that URL serves the metadata without the endpoint's middleware (keep auth off the endpoint's parent routers, or the metadata is guarded too). oauthProtectedResource only handles single-segment endpoint paths such as /mcp. Validate that tokens were issued for this server (the aud claim), not just by your authorization server.

Streaming

Server-Sent Events

import { sseResponse } from "@fresho/router";

export interface ItemEvents {
	message: { connected: boolean };
	update: { count: number };
	removed: { id: string };
}

events: router({
	get: async () =>
		sseResponse<ItemEvents>(async (send, close) => {
			send({ data: { connected: true } });
			send({ event: "update", data: { count: 1 } });
			send({ event: "removed", data: { id: "item-1" } });
			close();
		}),
});

The event map checks event names and their corresponding payloads. The reserved message key types events that omit the event field. Export the map for consumers, or extract it from a typed client method:

import type { SSEEventsOf } from "@fresho/router";

type ClientEvents = SSEEventsOf<ReturnType<typeof client.events>>;

HTTP and local clients return streaming routes as raw Response objects instead of parsing their bodies as JSON, so callers can consume response.body as data arrives.

JSON Lines

import { streamJsonLines } from "@fresho/router";

logs: router({
	get: async () =>
		streamJsonLines(async (send, close) => {
			send({ level: "info", message: "Starting..." });
			send({ level: "info", message: "Done" });
			close();
		}),
});

Cloudflare Workers

import { route, router } from '@fresho/router';

const api = router({ ... });

export default {
  fetch: api.handler(),
};

Typed Context

Use route.ctx<T>() for environment bindings and middleware-added properties:

interface AppContext {
	env: { DB: D1Database };
	user: { id: string }; // from auth middleware
}

const api = router(
	{
		profile: router({
			get: route.ctx<AppContext>()({
				handler: async (c) => {
					const data = await c.env.DB.prepare("...").all();
					return { userId: c.user.id, data };
				},
			}),
		}),
	},
	jwtAuth({
		secret: (c) => c.env.JWT_SECRET,
		claims: (p) => ({ user: { id: p.sub } }),
	})
);

export default { fetch: api.handler() };

HEAD Requests

Per RFC 9110, HEAD requests are automatically handled for any GET route:

users: router({
	get: async () => ({ users: await db.getUsers() }),
});

// GET /users  → 200 with body
// HEAD /users → 200 with no body (same headers)

Define an explicit head handler if you need different behavior:

users: router({
	head: async () => new Response(null, { headers: { "X-Count": "100" } }),
	get: async () => ({ users: await db.getUsers() }),
});

Size

| Usage | Minified | Gzipped | | --------------------------- | -------- | ------- | | Core (routing + validation) | 4.2 KB | 1.9 KB | | + HTTP client | 5.8 KB | 2.4 KB | | + OpenAPI docs | 5.3 KB | 2.3 KB | | + cors, errorHandler | 6.5 KB | 2.7 KB | | + all middleware | 11.6 KB | 4.4 KB |

Tree-shakeable: only pay for what you import.

License

MIT