Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ and Zod validation between a Hattip-compatible server and a typed fetch client.
## What it does

A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and
optional JSON or newline-delimited JSON response types once, then reuses that
contract to:
optional JSON, error, or newline-delimited JSON response types once, then reuses
that contract to:

- validate client arguments before `fetch`
- match and validate server requests before handlers run
- type handler context from path, query/body, headers, and middleware
- attach typed client action functions such as `client.profiles.get(...)`
- parse typed JSON responses and typed NDJSON response streams
- parse typed JSON responses, declared error responses, and NDJSON streams

Rouzer optimizes for shared TypeScript route modules over language-agnostic API
schemas or generated SDKs.
Expand All @@ -24,15 +24,17 @@ Use Rouzer if:

- your server and client can import the same TypeScript route tree
- you want Zod request validation on both sides of an HTTP boundary
- response data is validated at data/client boundaries, not by re-checking every
handler return
- a Hattip-compatible handler fits your server runtime
- you prefer named resource/action functions over a generated client class

Consider something else if:

- you need OpenAPI-first workflows, schema files, or generated clients for other
languages
- you need runtime response-body validation; `response: $type<T>()` and
`response: ndjson.$type<T>()` are compile-time only
- you want the router to validate every response body at the server boundary;
`$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are type contracts
- you want a framework that owns controllers, data loading, rendering, and
deployment adapters
- you cannot use ESM or Zod v4+
Expand All @@ -55,7 +57,7 @@ Import the primary API from the root package and declare routes through the HTTP
subpath:

```ts
import { $type, chain, createClient, createRouter } from 'rouzer'
import { $error, $type, chain, createClient, createRouter } from 'rouzer'
import * as http from 'rouzer/http'
```

Expand Down Expand Up @@ -103,6 +105,49 @@ const { message } = await client.hello({
route arguments before `fetch`; server handlers validate matched path, query,
headers, and JSON bodies before your handler runs.

### Typed status responses

Use a response map when client code needs declared error statuses as data instead
of exceptions.

```ts
import { $error, $type, createClient, createRouter } from 'rouzer'
import * as http from 'rouzer/http'

type User = { id: string; name: string }
type NotFound = { code: 'NOT_FOUND'; message: string }

export const getUser = http.get('users/:id', {
response: {
200: $type<User>(),
404: $error<NotFound>(),
},
})
export const routes = { getUser }

createRouter().use(routes, {
getUser(ctx) {
if (ctx.path.id === 'missing') {
return ctx.error(404, {
code: 'NOT_FOUND',
message: 'User not found',
})
}
return { id: ctx.path.id, name: 'Ada' }
},
})

const client = createClient({
baseURL: 'https://example.com/api/',
routes,
})

const [error, user, status] = await client.getUser({ path: { id: '42' } })
```

Success entries resolve as `[null, value, status]`; declared error entries
resolve as `[error, null, status]`.

### NDJSON response streams

Use `response: ndjson.$type<T>()` for endpoints that stream
Expand Down Expand Up @@ -141,6 +186,7 @@ for await (const event of await client.events()) {

- [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
- [Runnable shared-route example](examples/basic-usage.ts)
- [Runnable typed error response example](examples/error-responses.ts)
- [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
- Generated declarations in the published package provide the exact signatures
for every public export, including the `rouzer/http` and `rouzer/ndjson`
Expand Down
152 changes: 128 additions & 24 deletions docs/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Rouzer is for applications that want one TypeScript HTTP route tree to drive
both the server and the client that calls it. A route tree combines URL
patterns, named actions, HTTP method schemas, and optional compile-time JSON or
NDJSON response types.
patterns, named actions, HTTP method schemas, and optional compile-time success,
error, or plugin response types.

## When to use Rouzer

Expand All @@ -17,9 +17,12 @@ Use Rouzer when:
- generated clients should stay close to route definitions instead of being
produced by a separate OpenAPI build step

Rouzer is not a response validation library, an OpenAPI generator, or a complete
Rouzer is not a server response validator, an OpenAPI generator, or a complete
server framework. It focuses on typed route contracts, request validation,
routing, and a small client wrapper.
routing, and a small client wrapper. Response markers are type contracts; if
response data comes from an untrusted source, validate it where it enters your
server or client code instead of relying on the router to re-check handler
returns.

## Core abstractions

Expand Down Expand Up @@ -92,11 +95,47 @@ The HTTP action API models explicit operations. It does not expose the old
method-map `ALL` fallback route shape; declare the concrete methods your client
and server support.

### `$type<T>()` and `ndjson.$type<T>()`
### Response markers and maps

`response: $type<T>()` is a TypeScript-only marker for JSON response payloads.
It tells handlers and client action functions what response payload type to
expect, but Rouzer does not validate response bodies at runtime.
`response: $type<T>()` is a TypeScript-only marker for JSON success payloads. It
tells handlers and client action functions what payload type to expect, but
Rouzer does not validate handler return values at the server boundary. Validate
response data where it enters your system, such as an external API client,
database decoder, or UI/client boundary, when runtime integrity is required.

Use a status-keyed response map when callers need to branch on declared statuses:

```ts
import { $error, $type } from 'rouzer'
import * as http from 'rouzer/http'

type User = { id: string; name: string }
type NotFound = { code: 'NOT_FOUND'; message: string }

export const getUser = http.get('users/:id', {
response: {
200: $type<User>(),
201: $type<User>(),
404: $error<NotFound>(),
},
})
```

Success entries use `$type<T>()` or a response plugin marker. Error entries use
`$error<T>()` and are encoded as JSON. Generated client action functions resolve
declared statuses as tuples:

- success: `[null, value, status]`
- error: `[error, null, status]`

Declared error statuses do not reject the client promise. Undeclared statuses
still go through `onJsonError` or throw the default error.

Handlers for response-map actions may return the default success value directly,
use `ctx.success(status, body)` to choose a declared success status, or use
`ctx.error(status, body)` to return a declared error status. The `ctx.error` and
`ctx.success` helpers only accept statuses and bodies declared in the response
map.

`response: ndjson.$type<T>()` is a TypeScript-only marker for newline-delimited
JSON response streams from the `rouzer/ndjson` subpath. Register
Expand All @@ -109,8 +148,8 @@ response body. Streamed items are parsed as JSON but are not validated against a
Zod schema.

Actions without a `response` marker return a raw `Response` from client action
functions. Actions with `response: $type<T>()` use `client.json(...)` under the
hood and return parsed JSON typed as `T`.
functions. Actions with `response: $type<T>()` return parsed JSON typed as `T`.
Actions with a response map return the tuple union described by that map.

### Response plugins

Expand All @@ -121,10 +160,11 @@ matching runtime plugins. For NDJSON, those are `ndjson.$type<T>()`,

The router plugin encodes non-`Response` handler results into an HTTP `Response`.
The client plugin decodes successful HTTP responses for generated client action
functions. Rouzer validates plugin registration when routes are attached to a
router or client, so routes that use an unregistered response marker fail fast
instead of falling back to JSON. Response plugins do not automatically validate
response payloads unless the plugin itself implements validation.
functions. Plugin markers can also be success entries in a status-keyed response
map. Rouzer validates plugin registration when routes are attached to a router or
client, so routes that use an unregistered response marker fail fast instead of
falling back to JSON. Response plugins do not automatically validate response
payloads unless the plugin itself implements validation.

### Router

Expand Down Expand Up @@ -157,6 +197,8 @@ Handlers receive a context typed from middleware plus the action schema:
- `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
- mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
- handlers may return a plain JSON-serializable value or a `Response`
- response-map handlers can return a default success value directly or use
`ctx.success(status, body)` and `ctx.error(status, body)`
- `ndjson.$type<T>()` handlers return an `Iterable<T>` or `AsyncIterable<T>`
unless they return a custom `Response`
- plain values are returned with `Response.json(value)`
Expand All @@ -175,6 +217,8 @@ requests with an `Origin` header.
request factory contains the full path you want to call
- `client.json(action.request(args))` for parsed JSON and default non-2xx
throwing
- response-map support for generated client action functions, returning
`[error, value, status]` tuples for declared statuses
- response plugin support for generated client action functions, such as
`ndjson.clientPlugin` for NDJSON response streams
- a client tree that mirrors `routes`, with action functions such as
Expand Down Expand Up @@ -205,9 +249,9 @@ runtimes.
`fetch`.
5. The router matches the request, validates the matched inputs, and calls the
handler.
6. Plain handler results become JSON responses, plugin handler results become
plugin-encoded responses, and explicit `Response` objects pass through
unchanged.
6. Plain handler results become JSON responses, response-map helpers choose
declared statuses, plugin handler results become plugin-encoded responses, and
explicit `Response` objects pass through unchanged.

On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
Expand Down Expand Up @@ -247,9 +291,64 @@ const json = await client.json(
)
```

Response plugins are applied by generated client action functions. For longhand
calls to plugin-backed routes, use `client.request(...)` for the raw `Response`
and call the plugin subpath's decoder yourself.
Response maps and response plugins are applied by generated client action
functions. For longhand calls to mapped or plugin-backed routes, use
`client.request(...)` for the raw `Response` and decode the response yourself.

### Handle declared error responses

Use `$error<T>()` inside a response map when an error status is part of the route
contract:

```ts
import { $error, $type, createClient, createRouter } from 'rouzer'
import * as http from 'rouzer/http'

type User = { id: string; name: string }
type NotFound = { code: 'NOT_FOUND'; message: string }

export const getUser = http.get('users/:id', {
response: {
200: $type<User>(),
404: $error<NotFound>(),
},
})
export const routes = { getUser }

createRouter().use(routes, {
getUser(ctx) {
if (ctx.path.id === 'missing') {
return ctx.error(404, {
code: 'NOT_FOUND',
message: 'User not found',
})
}
return { id: ctx.path.id, name: 'Ada' }
},
})

const client = createClient({
baseURL: 'https://example.com/api/',
routes,
})

const [error, user, status] = await client.getUser({
path: { id: 'missing' },
})

if (status === 404) {
console.log(error.message)
} else {
console.log(user.name)
}
```

A complete runnable version lives in
[`examples/error-responses.ts`](../examples/error-responses.ts).

When a response map declares multiple success statuses, return a plain value for
the default success status or use `ctx.success(status, body)` to choose a
specific declared success status.

### Stream newline-delimited JSON

Expand Down Expand Up @@ -322,8 +421,8 @@ custom headers. Return a plain value for the default `Response.json(value)` path
### Customize JSON errors

By default, `client.json(...)` and generated client action functions throw for
non-2xx responses. If the response body is JSON, its properties are copied onto
the thrown `Error`.
non-2xx responses that are not declared in a response map. If the response body
is JSON, its properties are copied onto the thrown `Error`.

`onJsonError` can override that behavior. Its return value is returned from the
response helper as-is; Rouzer does not automatically parse a returned `Response`
Expand Down Expand Up @@ -390,6 +489,8 @@ await client.profiles.update({
only when string params are sufficient.
- Use `response: $type<T>()` for JSON endpoints that should have typed client
action functions.
- Use response maps with `$error<T>()` when callers should handle declared error
statuses as typed data instead of exceptions.
- Use `response: ndjson.$type<T>()` plus `ndjson.routerPlugin` and
`ndjson.clientPlugin` for response streams where each line is a JSON value and
the client should consume an `AsyncIterable<T>`.
Expand All @@ -400,10 +501,13 @@ await client.profiles.update({

## Constraints and gotchas

- `$type<T>()` and `ndjson.$type<T>()` are compile-time only and do not validate
response payloads or streamed items.
- `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time-only type
contracts. Rouzer does not re-validate handler return values at the server
boundary.
- NDJSON support is for response streams; request bodies still use the existing
JSON body schema path.
- Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
for non-JSON error payloads.
- Routes that use a response plugin fail fast if the matching client or router
plugin is not registered.
- Pathname route patterns expect an absolute client `baseURL`.
Expand Down
Loading