@fresho/router 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,12 +39,13 @@ export default { fetch: api.handler() };
39
39
 
40
40
  ## Features
41
41
 
42
- - **Type-safe path params** — `$id` creates dynamic segments, typed via `route.ctx<>()`
42
+ - **Type-safe path params** — `$id` creates dynamic segments whose names are inferred by handlers and clients
43
43
  - **Schema validation** — query and body validated at runtime, typed at compile time
44
44
  - **Property-based routing** — property names become URL segments
45
45
  - **Typed HTTP client** — call your API with full type safety
46
46
  - **Typed local client** — test handlers directly without HTTP
47
47
  - **OpenAPI generation** — auto-generate docs from your routes
48
+ - **MCP server** — expose routes as tools for AI agents
48
49
  - **Middleware** — cors, auth, rate limiting, and more
49
50
  - **Streaming** — SSE and JSON lines helpers
50
51
  - **HTTP-compliant** — automatic HEAD support for GET routes
@@ -56,6 +57,16 @@ export default { fetch: api.handler() };
56
57
  npm install @fresho/router
57
58
  ```
58
59
 
60
+ ```bash
61
+ bun add @fresho/router
62
+ ```
63
+
64
+ ## Runtime Support
65
+
66
+ The full test suite runs in Node.js 22/24 and Bun 1.3.14. CI also deploys the built
67
+ package to a local Cloudflare `workerd` runtime and verifies routing, validation,
68
+ middleware, and streaming behavior.
69
+
59
70
  ## Path Convention
60
71
 
61
72
  Property names become URL path segments:
@@ -219,7 +230,7 @@ See **[Middleware Documentation](docs/middleware.md)** for detailed usage.
219
230
  For standalone auth utilities (JWT signing/verification, Basic auth, OAuth), import from `@fresho/router/auth`:
220
231
 
221
232
  ```typescript
222
- import { jwtSign, jwtVerify, parseBasicAuth } from "@fresho/router/auth";
233
+ import { createJwks, jwtSign, jwtVerify, parseBasicAuth } from "@fresho/router/auth";
223
234
 
224
235
  // Sign a JWT
225
236
  const token = await jwtSign({ uid: "user-123" }, secret, { expiresIn: "1h" });
@@ -227,12 +238,20 @@ const token = await jwtSign({ uid: "user-123" }, secret, { expiresIn: "1h" });
227
238
  // Verify a JWT
228
239
  const payload = await jwtVerify(token, secret);
229
240
 
241
+ // Verify a third-party OAuth/OIDC token against the provider's JWKS
242
+ const jwks = createJwks("https://example.auth0.com/.well-known/jwks.json");
243
+ const claims = await jwtVerify(accessToken, jwks, {
244
+ algorithms: ["RS256"],
245
+ issuer: "https://example.auth0.com/",
246
+ audience: "https://api.example.com",
247
+ });
248
+
230
249
  // Parse Basic auth header
231
250
  const creds = parseBasicAuth(request.headers.get("Authorization"));
232
251
  ```
233
252
 
234
253
  Available utilities:
235
- - **JWT**: `jwtSign`, `jwtVerify`
254
+ - **JWT**: `jwtSign`, `jwtVerify`, `createJwks` (HS*, RS*, PS*, ES256/384)
236
255
  - **Basic Auth**: `parseBasicAuth`, `encodeBasicAuth`, `extractBasicAuthToken`
237
256
  - **OAuth**: `encodeOAuthState`, `decodeOAuthState`, `exchangeCode`, `refreshAccessToken`, `revokeToken`, `buildAuthorizationUrl`, `OAUTH_PROVIDERS`
238
257
 
@@ -278,6 +297,26 @@ await client.users.$get(); // GET /users
278
297
  await client.users.$post({ body: { name: "Bob" } }); // POST /users
279
298
  ```
280
299
 
300
+ The `$id` segment determines the `id` key in the client's `path` option, so missing or misspelled
301
+ parameter names are caught at compile time.
302
+
303
+ ### Routers Built by a Factory
304
+
305
+ If your router is created inside a factory so its handlers can close over injected dependencies,
306
+ derive the API type from the factory's return type. The type-only import keeps server code out of the
307
+ client bundle:
308
+
309
+ ```typescript
310
+ import { createHttpClient } from "@fresho/router";
311
+ import type { createServer } from "./server";
312
+
313
+ type Api = ReturnType<typeof createServer>["api"];
314
+
315
+ const client = createHttpClient<Api>({
316
+ baseUrl: "https://api.example.com",
317
+ });
318
+ ```
319
+
281
320
  ### HTTP Method Syntax
282
321
 
283
322
  HTTP methods are prefixed with `$` to distinguish them from path navigation:
@@ -297,7 +336,9 @@ This allows routes with path segments named after HTTP methods (e.g., `/api/get`
297
336
 
298
337
  ### Building URLs Without Fetching
299
338
 
300
- 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, so path params stay type-checked:
339
+ Use `$url` to derive a URL string from the typed routes without firing a request. It accepts the
340
+ same `path`/`query` options as a real call, including the parameter names inferred from `$id`
341
+ segments:
301
342
 
302
343
  ```typescript
303
344
  // Absolute URL when baseUrl is configured:
@@ -348,6 +389,52 @@ const docs = router({
348
389
  });
349
390
  ```
350
391
 
392
+ ## MCP Server
393
+
394
+ Expose routes as [Model Context Protocol](https://modelcontextprotocol.io) tools, so agents can use the same API as your other clients. Requires the optional peer dependency `@modelcontextprotocol/sdk`:
395
+
396
+ ```bash
397
+ npm install @modelcontextprotocol/sdk
398
+ ```
399
+
400
+ ```typescript
401
+ import { router } from "@fresho/router";
402
+ import { mcp } from "@fresho/router/mcp";
403
+
404
+ const api = router({
405
+ users,
406
+ billing,
407
+ mcp: mcp({ users }, { name: "my-api", version: "1.0.0" }, bearerAuth({ verify })),
408
+ });
409
+ ```
410
+
411
+ `mcp()` requires endpoint middleware (usually auth). Pass `public: true` instead when the endpoint is open, or already guarded by middleware on its parent routers.
412
+
413
+ - **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`.
414
+ - **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. `descriptions: { field: '...', 'address.city': '...' }` on a route documents 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.
415
+ - **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: { ... } }`.
416
+ - **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.
417
+ - **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`.
418
+ - **Transport** — stateless Streamable HTTP with JSON responses; `GET` and `DELETE` return 405.
419
+ - Pass a router to expose all of its routes, or a record of routers to mount them under their keys.
420
+
421
+ ### OAuth
422
+
423
+ Put auth middleware on the endpoint so unauthenticated clients get an HTTP 401, and set `oauth` to advertise your authorization server (RFC 9728):
424
+
425
+ ```typescript
426
+ const oauth = { authorizationServers: ["https://auth.example.com"], scopes: ["users:read"] };
427
+
428
+ const app = router({
429
+ users,
430
+ mcp: mcp({ users }, { name: "my-api", version: "1.0.0", oauth }, jwtAuth({ ... })),
431
+ // Optional: metadata at the origin root, for clients that probe it before calling the endpoint.
432
+ ".well-known": oauthProtectedResource(oauth),
433
+ });
434
+ ```
435
+
436
+ 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.
437
+
351
438
  ## Streaming
352
439
 
353
440
  ### Server-Sent Events
@@ -355,16 +442,36 @@ const docs = router({
355
442
  ```typescript
356
443
  import { sseResponse } from "@fresho/router";
357
444
 
445
+ export interface ItemEvents {
446
+ message: { connected: boolean };
447
+ update: { count: number };
448
+ removed: { id: string };
449
+ }
450
+
358
451
  events: router({
359
452
  get: async () =>
360
- sseResponse(async (send, close) => {
361
- send({ data: "connected" });
453
+ sseResponse<ItemEvents>(async (send, close) => {
454
+ send({ data: { connected: true } });
362
455
  send({ event: "update", data: { count: 1 } });
456
+ send({ event: "removed", data: { id: "item-1" } });
363
457
  close();
364
458
  }),
365
459
  });
366
460
  ```
367
461
 
462
+ The event map checks event names and their corresponding payloads. The reserved `message` key types
463
+ events that omit the `event` field. Export the map for consumers, or extract it from a typed client
464
+ method:
465
+
466
+ ```typescript
467
+ import type { SSEEventsOf } from "@fresho/router";
468
+
469
+ type ClientEvents = SSEEventsOf<ReturnType<typeof client.events>>;
470
+ ```
471
+
472
+ HTTP and local clients return streaming routes as raw `Response` objects instead of parsing their
473
+ bodies as JSON, so callers can consume `response.body` as data arrives.
474
+
368
475
  ### JSON Lines
369
476
 
370
477
  ```typescript
@@ -1,4 +1,4 @@
1
- import { a as VerifyJwtOptions, i as SignJwtOptions, n as JwtPayload, o as jwtSign, r as JwtSecret, s as jwtVerify, t as JwtAlgorithm } from "../jwt-DHM_nSwL.mjs";
1
+ import { a as SignJwtOptions, c as jwtSign, i as JwtSecret, l as jwtVerify, n as JwtAlgorithm, o as VerifyJwtOptions, r as JwtPayload, s as createJwks, t as Jwks } from "../jwt-B-Fqwu4E.mjs";
2
2
 
3
3
  //#region src/auth/basic.d.ts
4
4
 
@@ -323,5 +323,5 @@ interface AuthorizationUrlOptions {
323
323
  */
324
324
  declare function buildAuthorizationUrl(options: AuthorizationUrlOptions): string;
325
325
  //#endregion
326
- export { type AuthorizationUrlOptions, type BasicCredentials, type ExchangeCodeOptions, type JwtAlgorithm, type JwtPayload, type JwtSecret, OAUTH_PROVIDERS, type OAuthProvider, type OAuthTokenResponse, type RevokeTokenOptions, type SignJwtOptions, type TokenAuthMethod, type VerifyJwtOptions, buildAuthorizationUrl, decodeOAuthState, encodeBasicAuth, encodeOAuthState, exchangeCode, extractBasicAuthToken, jwtSign, jwtVerify, parseBasicAuth, refreshAccessToken, revokeToken };
326
+ export { type AuthorizationUrlOptions, type BasicCredentials, type ExchangeCodeOptions, type Jwks, type JwtAlgorithm, type JwtPayload, type JwtSecret, OAUTH_PROVIDERS, type OAuthProvider, type OAuthTokenResponse, type RevokeTokenOptions, type SignJwtOptions, type TokenAuthMethod, type VerifyJwtOptions, buildAuthorizationUrl, createJwks, decodeOAuthState, encodeBasicAuth, encodeOAuthState, exchangeCode, extractBasicAuthToken, jwtSign, jwtVerify, parseBasicAuth, refreshAccessToken, revokeToken };
327
327
  //# sourceMappingURL=index.d.mts.map
@@ -1,4 +1,4 @@
1
- import { a as parseBasicAuth, i as extractBasicAuthToken, n as jwtVerify, r as encodeBasicAuth, t as jwtSign } from "../jwt-Dab9kOC2.mjs";
1
+ import { a as extractBasicAuthToken, i as encodeBasicAuth, n as jwtSign, o as parseBasicAuth, r as jwtVerify, t as createJwks } from "../jwt-ikNuUBcp.mjs";
2
2
 
3
3
  //#region src/auth/oauth.ts
4
4
  /** Common OAuth 2.0 providers with their endpoints. */
@@ -275,5 +275,5 @@ function buildAuthorizationUrl(options) {
275
275
  }
276
276
 
277
277
  //#endregion
278
- export { OAUTH_PROVIDERS, buildAuthorizationUrl, decodeOAuthState, encodeBasicAuth, encodeOAuthState, exchangeCode, extractBasicAuthToken, jwtSign, jwtVerify, parseBasicAuth, refreshAccessToken, revokeToken };
278
+ export { OAUTH_PROVIDERS, buildAuthorizationUrl, createJwks, decodeOAuthState, encodeBasicAuth, encodeOAuthState, exchangeCode, extractBasicAuthToken, jwtSign, jwtVerify, parseBasicAuth, refreshAccessToken, revokeToken };
279
279
  //# sourceMappingURL=index.mjs.map