From fd3c9bd201e8820ca90d6b111acb7edec5b7c078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 05:14:18 +0000 Subject: [PATCH 01/10] initial plan for typed error responses Agent-Logs-Url: https://github.com/alloc/rouzer/sessions/ad2874c8-97ca-4ce2-8013-e7bf328459c6 Co-authored-by: aleclarson <1925840+aleclarson@users.noreply.github.com> --- pnpm-workspace.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16bd489..baf7cc8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,5 @@ minimumReleaseAge: 10080 minimumReleaseAgeExclude: - '@remix-run/route-pattern' - alien-middleware +allowBuilds: + esbuild: true From 194bc36f5d1b629a639a16dc2b36a7f450d1f42f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 23 May 2026 05:19:28 +0000 Subject: [PATCH 02/10] feat: add $error marker and typed error responses - Add $error() marker function for declaring error response types - Support status-keyed response maps in route schemas - Add ctx.error(status, body) to server handler context - Client resolves declared errors as [error, null, status] tuples - Undeclared statuses still reject the promise - Add runtime and type-level tests Agent-Logs-Url: https://github.com/alloc/rouzer/sessions/ad2874c8-97ca-4ce2-8013-e7bf328459c6 Co-authored-by: aleclarson <1925840+aleclarson@users.noreply.github.com> --- src/client/index.ts | 41 ++++++- src/common.ts | 9 ++ src/server/router.ts | 51 ++++++++- src/type.ts | 29 ++++- src/types/handler.ts | 130 +++++++++++++++++------ src/types/response.ts | 72 +++++++++++-- src/types/schema.ts | 20 +++- test/error-responses.test-d.ts | 71 +++++++++++++ test/fixtures/error-responses/handler.ts | 27 +++++ test/fixtures/error-responses/routes.ts | 25 +++++ test/fixtures/error-responses/test.ts | 40 +++++++ 11 files changed, 464 insertions(+), 51 deletions(-) create mode 100644 test/error-responses.test-d.ts create mode 100644 test/fixtures/error-responses/handler.ts create mode 100644 test/fixtures/error-responses/routes.ts create mode 100644 test/fixtures/error-responses/test.ts diff --git a/src/client/index.ts b/src/client/index.ts index 5d0a8bc..193eb4a 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,13 +5,15 @@ import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js' import { createResponsePluginMap, getResponsePluginMarkerId, + responsePluginMarkerSymbol, type ClientResponsePlugin, type ResponsePluginMarker, } from '../response.js' +import { $error } from '../type.js' import type { RouteArgs } from '../types/args.js' import type { RouteRequest } from '../types/request.js' import type { InferRouteResponse } from '../types/response.js' -import type { RouteSchema } from '../types/schema.js' +import type { RouteResponseMap, RouteSchema } from '../types/schema.js' /** Client type inferred from an HTTP route tree passed to `createClient`. */ export type RouzerClient< @@ -140,18 +142,36 @@ export function createClient< props: T ): Promise { const httpResponse = await request(props) + const responseSchema = props.schema.response + + // Handle status-keyed response maps + if (isResponseMap(responseSchema)) { + const status = httpResponse.status + const statusKey = status as keyof typeof responseSchema + if (statusKey in responseSchema) { + const marker = responseSchema[statusKey] + const body = await httpResponse.json() + if (isErrorMarker(marker)) { + return [body, null, status] as T['$result'] + } + return [null, body, status] as T['$result'] + } + // Undeclared status — reject + return handleResponseError(httpResponse, props) + } + if (!httpResponse.ok) { return handleResponseError(httpResponse, props) } - const pluginId = getResponsePluginMarkerId(props.schema.response) + const pluginId = getResponsePluginMarkerId(responseSchema) if (pluginId) { const plugin = responsePlugins.get(pluginId) if (!plugin) { throw missingClientResponsePlugin(pluginId) } return plugin.decode(httpResponse, { - marker: props.schema.response as ResponsePluginMarker, + marker: responseSchema as unknown as ResponsePluginMarker, request: props, }) as T['$result'] } @@ -278,3 +298,18 @@ function missingClientResponsePlugin(pluginId: string) { function joinPaths(left: string, right: string) { return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/') } + +/** Return true when the response schema is a status-keyed response map. */ +function isResponseMap( + response: RouteSchema['response'] +): response is RouteResponseMap { + return ( + typeof response === 'object' && + response !== null && + !(responsePluginMarkerSymbol in response) + ) +} + +function isErrorMarker(marker: unknown): boolean { + return marker === $error.symbol +} diff --git a/src/common.ts b/src/common.ts index 5450f2a..03819d8 100644 --- a/src/common.ts +++ b/src/common.ts @@ -9,6 +9,15 @@ export type Promisable = T | Promise */ export type Unchecked = { __unchecked__: T } +/** + * Compile-time-only marker used by `$error()` to carry a declared error + * response type through route declarations. + * + * @remarks Consumers usually use `$error()` instead of constructing this + * type directly. + */ +export type UncheckedError = { __uncheckedError__: T } + /** * Map over all the keys to create a new object. * diff --git a/src/server/router.ts b/src/server/router.ts index 2be54d9..57e8eb2 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -18,7 +18,8 @@ import { type ResponsePluginMarker, type RouterResponsePlugin, } from '../response.js' -import type { RouteSchema } from '../types/schema.js' +import { $error } from '../type.js' +import type { RouteResponseMap, RouteSchema } from '../types/schema.js' import type { RouteRequestHandlerMap } from '../types/server.js' export { chain } @@ -215,6 +216,10 @@ class RouterObject extends MiddlewareChain { } } + if (isResponseMap(schema.response)) { + ;(context as any).error = createErrorHelper() + } + const result = await handler(context) addDebugHeaders?.(context, route) if (result instanceof Response) { @@ -231,6 +236,11 @@ class RouterObject extends MiddlewareChain { request, }) } + if (isResponseMap(schema.response)) { + // Success response from a response map — find the success status + const status = getSuccessStatus(schema.response) + return Response.json(result, { status }) + } return Response.json(result) } } as any) @@ -481,3 +491,42 @@ function createOriginPattern(origin: string) { } return new ExactPattern(origin) } + +/** Return true when the response schema is a status-keyed response map. */ +function isResponseMap( + response: RouteSchema['response'] +): response is RouteResponseMap { + return ( + typeof response === 'object' && + response !== null && + !(responsePluginMarkerSymbol in response) + ) +} + +import { responsePluginMarkerSymbol } from '../response.js' + +/** Create the `ctx.error(status, body)` helper for route handlers. */ +function createErrorHelper() { + return (status: number, body: unknown): Response => { + return Response.json(body, { status }) + } +} + +/** + * Find the first success status in a response map (a status whose marker is + * `$type()` or a plugin marker, not `$error()`). + */ +function getSuccessStatus(responseMap: RouteResponseMap): number { + for (const key of Object.keys(responseMap)) { + const status = Number(key) + const marker = (responseMap as any)[status] + if (!isErrorMarker(marker)) { + return status + } + } + return 200 +} + +function isErrorMarker(marker: unknown): boolean { + return marker === $error.symbol +} diff --git a/src/type.ts b/src/type.ts index 3970550..13ee158 100644 --- a/src/type.ts +++ b/src/type.ts @@ -1,4 +1,4 @@ -import type { Unchecked } from './common.js' +import type { Unchecked, UncheckedError } from './common.js' /** * Create a compile-time-only marker for an action's JSON response payload type. @@ -22,3 +22,30 @@ export function $type() { } $type.symbol = Symbol() + +/** + * Create a compile-time-only marker for a declared error response type. + * + * @remarks `$error()` marks a non-success response branch in a status-keyed + * response map. On the server, handlers use `ctx.error(status, body)` to return + * declared errors. On the client, declared error responses resolve as part of a + * discriminated tuple instead of rejecting the promise. + * + * @example + * ```ts + * import { $type, $error } from 'rouzer' + * import * as http from 'rouzer/http' + * + * const getUser = http.get('users/:id', { + * response: { + * 200: $type(), + * 404: $error<{ code: string; message: string }>(), + * }, + * }) + * ``` + */ +export function $error() { + return $error.symbol as unknown as UncheckedError +} + +$error.symbol = Symbol() diff --git a/src/types/handler.ts b/src/types/handler.ts index 7b54b26..59dbcf8 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -3,52 +3,114 @@ import type { AnyMiddlewareChain, MiddlewareContext } from 'alien-middleware' import type * as z from 'zod' import { Promisable } from '../common.js' import type { HttpAction } from '../http.js' -import type { InferRouteHandlerResult } from './response.js' -import type { RouteSchema } from './schema.js' +import type { + InferRouteHandlerResult, + InferResponseMapErrors, +} from './response.js' +import type { RouteResponseMap, RouteSchema } from './schema.js' type RequestContext = MiddlewareContext +/** + * Error response returned by `ctx.error(status, body)` in route handlers. + * + * @remarks This is an opaque branded type returned by the error helper. Route + * handlers may return it to signal a declared error response. + */ +export type RouteErrorResponse = Response & { __routeError__: true } + export type RouteRequestHandler< TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult, + TErrors = never, > = ( - context: RequestContext & TArgs + context: RequestContext & + TArgs & + ([TErrors] extends [never] + ? {} + : { + /** + * Return a declared error response. + * + * @remarks Only statuses declared with `$error()` in the response + * map are accepted. + */ + error: ( + ...args: TEntry extends [infer S extends number, infer B] + ? [status: S, body: B] + : never + ) => RouteErrorResponse + }) ) => Promisable export type InferActionHandler< TMiddleware extends AnyMiddlewareChain, TAction extends HttpAction, TPath extends string, -> = TAction['method'] extends 'GET' - ? RouteRequestHandler< - TMiddleware, - { - path: TAction['schema'] extends { path: any } - ? z.infer - : MatchParams - query: TAction['schema'] extends { query: any } - ? z.infer - : undefined - headers: TAction['schema'] extends { headers: any } - ? z.infer - : undefined - }, - InferRouteHandlerResult> - > - : RouteRequestHandler< - TMiddleware, - { - path: TAction['schema'] extends { path: any } - ? z.infer - : MatchParams - body: TAction['schema'] extends { body: any } - ? z.infer - : undefined - headers: TAction['schema'] extends { headers: any } - ? z.infer - : undefined - }, - InferRouteHandlerResult> - > +> = TAction['schema'] extends { response: infer R extends RouteResponseMap } + ? TAction['method'] extends 'GET' + ? RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + query: TAction['schema'] extends { query: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult>, + InferResponseMapErrors + > + : RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + body: TAction['schema'] extends { body: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult>, + InferResponseMapErrors + > + : TAction['method'] extends 'GET' + ? RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + query: TAction['schema'] extends { query: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult> + > + : RouteRequestHandler< + TMiddleware, + { + path: TAction['schema'] extends { path: any } + ? z.infer + : MatchParams + body: TAction['schema'] extends { body: any } + ? z.infer + : undefined + headers: TAction['schema'] extends { headers: any } + ? z.infer + : undefined + }, + InferRouteHandlerResult> + > diff --git a/src/types/response.ts b/src/types/response.ts index 2ecdec9..4393223 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -1,24 +1,74 @@ -import type { ResponsePluginMarker, RouteSchema, Unchecked } from './schema.js' +import type { Unchecked, UncheckedError } from '../common.js' +import type { ResponsePluginMarker } from '../response.js' +import type { RouteResponseMap, RouteSchema } from './schema.js' /** `Response` whose `.json()` method resolves to a known payload type. */ export type RouteResponse = Response & { json(): Promise } +/** + * Helper: given a status-keyed response map, produce the discriminated tuple + * union for the client. + * + * Each entry becomes: + * - `$type()` → `[null, T, Status]` + * - `$error()` → `[T, null, Status]` + */ +type InferResponseMapClient = { + [K in keyof T & number]: T[K] extends UncheckedError + ? [TError, null, K] + : T[K] extends Unchecked + ? [null, TSuccess, K] + : T[K] extends ResponsePluginMarker + ? [null, TClient, K] + : never +}[keyof T & number] + /** Infer the client response type from an action schema. */ export type InferRouteResponse = T extends { - response: ResponsePluginMarker + response: infer R } - ? TClient - : T extends { response: Unchecked } - ? TResponse - : void + ? R extends ResponsePluginMarker + ? TClient + : R extends Unchecked + ? TResponse + : R extends RouteResponseMap + ? InferResponseMapClient + : void + : void + +/** + * Helper: given a status-keyed response map, produce the union of handler + * result types (success values the handler can return directly). + */ +type InferResponseMapHandlerResult = { + [K in keyof T & number]: T[K] extends Unchecked + ? TSuccess + : T[K] extends ResponsePluginMarker + ? TRouter + : never +}[keyof T & number] /** Infer the non-`Response` handler result type from an action schema. */ export type InferRouteHandlerResult = T extends { - response: ResponsePluginMarker + response: infer R } - ? TRouter - : T extends { response: Unchecked } - ? TResponse - : void + ? R extends ResponsePluginMarker + ? TRouter + : R extends Unchecked + ? TResponse + : R extends RouteResponseMap + ? InferResponseMapHandlerResult + : void + : void + +/** + * Helper: given a status-keyed response map, extract error entries as a union + * of `[status, body]` pairs for typing `ctx.error(status, body)`. + */ +export type InferResponseMapErrors = { + [K in keyof T & number]: T[K] extends UncheckedError + ? [K, TError] + : never +}[keyof T & number] diff --git a/src/types/schema.ts b/src/types/schema.ts index 75aee3d..e00b13e 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -1,5 +1,5 @@ import * as z from 'zod' -import type { Unchecked } from '../common.js' +import type { Unchecked, UncheckedError } from '../common.js' import type { ResponsePluginMarker } from '../response.js' /** @@ -10,12 +10,30 @@ import type { ResponsePluginMarker } from '../response.js' * this marker directly. */ export type { Unchecked } +export type { UncheckedError } export type { ResponsePluginMarker } +/** Single response marker accepted by HTTP action schemas. */ +export type RouteResponseMarker = + | Unchecked + | UncheckedError + | ResponsePluginMarker + +/** + * Status-keyed response map for declaring multiple response types. + * + * @remarks Numeric keys are HTTP status codes. Use `$type()` for success + * responses and `$error()` for declared error responses. + */ +export type RouteResponseMap = { + [status: number]: RouteResponseMarker +} + /** Response marker accepted by HTTP action schemas. */ export type RouteResponseSchema = | Unchecked | ResponsePluginMarker + | RouteResponseMap /** Schema shape for `GET` route methods. */ export type QueryRouteSchema = { diff --git a/test/error-responses.test-d.ts b/test/error-responses.test-d.ts new file mode 100644 index 0000000..cb22f99 --- /dev/null +++ b/test/error-responses.test-d.ts @@ -0,0 +1,71 @@ +import { $type, $error, createClient, createRouter } from 'rouzer' +import * as http from 'rouzer/http' + +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false + +type Assert = T + +// --- Route with response map --- + +type User = { id: string; name: string } +type NotFoundError = { code: 'NOT_FOUND'; message: string } +type AuthError = { code: 'UNAUTHORIZED'; message: string } + +const getUser = http.get('users/:id', { + response: { + 200: $type(), + 401: $error(), + 404: $error(), + }, +}) + +const routes = { getUser } + +const client = createClient({ + baseURL: 'https://example.com/api/', + routes, +}) + +// Client action returns a discriminated tuple +type GetUserResult = Awaited> + +type _ClientReturnsDiscriminatedTuple = Assert< + Equal< + GetUserResult, + [null, User, 200] | [AuthError, null, 401] | [NotFoundError, null, 404] + > +> + +// Handler can return success value or ctx.error(...) +createRouter().use(routes, { + getUser(ctx) { + if (ctx.path.id === 'missing') { + return ctx.error(404, { code: 'NOT_FOUND', message: 'not found' }) + } + if (ctx.path.id === 'unauthorized') { + return ctx.error(401, { code: 'UNAUTHORIZED', message: 'no auth' }) + } + return { id: ctx.path.id, name: 'Ada' } + }, +}) + +// --- Verify existing $type() still works --- + +const simpleRoute = http.get('simple', { + response: $type<{ message: string }>(), +}) + +const simpleRoutes = { simpleRoute } +const simpleClient = createClient({ + baseURL: 'https://example.com/api/', + routes: simpleRoutes, +}) + +type SimpleResult = Awaited> + +type _SimpleRouteStillReturnsPlainType = Assert< + Equal +> diff --git a/test/fixtures/error-responses/handler.ts b/test/fixtures/error-responses/handler.ts new file mode 100644 index 0000000..73a4fc7 --- /dev/null +++ b/test/fixtures/error-responses/handler.ts @@ -0,0 +1,27 @@ +import { createRouter } from 'rouzer' +import * as routes from './routes.js' + +export default createRouter().use(routes, { + getUser(ctx) { + const id = ctx.path.id + + if (id === 'unauthorized') { + return ctx.error(401, { + code: 'UNAUTHORIZED', + message: 'Login required', + }) + } + + if (id === 'missing') { + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'User not found', + }) + } + + return { + id, + name: 'Ada', + } + }, +}) diff --git a/test/fixtures/error-responses/routes.ts b/test/fixtures/error-responses/routes.ts new file mode 100644 index 0000000..c804ad6 --- /dev/null +++ b/test/fixtures/error-responses/routes.ts @@ -0,0 +1,25 @@ +import { $type, $error } from 'rouzer' +import * as http from 'rouzer/http' + +export type User = { + id: string + name: string +} + +export type NotFoundError = { + code: 'NOT_FOUND' + message: string +} + +export type AuthError = { + code: 'UNAUTHORIZED' + message: string +} + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 401: $error(), + 404: $error(), + }, +}) diff --git a/test/fixtures/error-responses/test.ts b/test/fixtures/error-responses/test.ts new file mode 100644 index 0000000..65f313e --- /dev/null +++ b/test/fixtures/error-responses/test.ts @@ -0,0 +1,40 @@ +import { createTest } from '../shared.js' +import handler from './handler.js' +import * as routes from './routes.js' + +export default createTest({ + name: 'typed error responses ($error with status-keyed response map)', + routes, + handler, + test: async client => { + // Success case: returns tuple [null, User, 200] + const [error1, result1, status1] = await client.getUser({ + path: { id: '42' }, + }) + expect(error1).toBeNull() + expect(result1).toEqual({ id: '42', name: 'Ada' }) + expect(status1).toBe(200) + + // 401 error case: returns tuple [AuthError, null, 401] + const [error2, result2, status2] = await client.getUser({ + path: { id: 'unauthorized' }, + }) + expect(error2).toEqual({ + code: 'UNAUTHORIZED', + message: 'Login required', + }) + expect(result2).toBeNull() + expect(status2).toBe(401) + + // 404 error case: returns tuple [NotFoundError, null, 404] + const [error3, result3, status3] = await client.getUser({ + path: { id: 'missing' }, + }) + expect(error3).toEqual({ + code: 'NOT_FOUND', + message: 'User not found', + }) + expect(result3).toBeNull() + expect(status3).toBe(404) + }, +}) From 6f3ce63977bc38407e6ca6ddf33f478eab164007 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 11:08:58 -0400 Subject: [PATCH 03/10] refactor: change internal symbol identifier --- src/client/index.ts | 4 ++-- src/response.ts | 14 +++++--------- src/server/router.ts | 4 ++-- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 193eb4a..5ec36bd 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,7 +5,7 @@ import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js' import { createResponsePluginMap, getResponsePluginMarkerId, - responsePluginMarkerSymbol, + responsePluginMarker, type ClientResponsePlugin, type ResponsePluginMarker, } from '../response.js' @@ -306,7 +306,7 @@ function isResponseMap( return ( typeof response === 'object' && response !== null && - !(responsePluginMarkerSymbol in response) + !(responsePluginMarker in response) ) } diff --git a/src/response.ts b/src/response.ts index 3616d15..c73b396 100644 --- a/src/response.ts +++ b/src/response.ts @@ -2,9 +2,7 @@ import type { Promisable } from './common.js' import type { RouteRequest } from './types/request.js' /** Runtime key carried by response plugin markers. */ -export const responsePluginMarkerSymbol: unique symbol = Symbol.for( - 'rouzer.response-plugin' -) as any +export const responsePluginMarker = Symbol.for('rouzer.response-plugin') /** * Compile-time response marker handled by a client/router response plugin pair. @@ -17,7 +15,7 @@ export type ResponsePluginMarker< TRouter = TClient, TId extends string = string, > = { - readonly [responsePluginMarkerSymbol]: { + readonly [responsePluginMarker]: { readonly id: TId readonly client: TClient readonly router: TRouter @@ -59,7 +57,7 @@ export function createResponsePluginMarker< const TId extends string = string, >(id: TId): ResponsePluginMarker { return { - [responsePluginMarkerSymbol]: { + [responsePluginMarker]: { id, client: undefined!, router: undefined!, @@ -70,7 +68,7 @@ export function createResponsePluginMarker< /** Get the response plugin id from a plugin marker, if present. */ export function getResponsePluginMarkerId(value: unknown): string | undefined { return isResponsePluginMarker(value) - ? value[responsePluginMarkerSymbol].id + ? value[responsePluginMarker].id : undefined } @@ -79,9 +77,7 @@ export function isResponsePluginMarker( value: unknown ): value is ResponsePluginMarker { return ( - typeof value === 'object' && - value !== null && - responsePluginMarkerSymbol in value + typeof value === 'object' && value !== null && responsePluginMarker in value ) } diff --git a/src/server/router.ts b/src/server/router.ts index 57e8eb2..f2ef0fd 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -499,11 +499,11 @@ function isResponseMap( return ( typeof response === 'object' && response !== null && - !(responsePluginMarkerSymbol in response) + !(responsePluginMarker in response) ) } -import { responsePluginMarkerSymbol } from '../response.js' +import { responsePluginMarker } from '../response.js' /** Create the `ctx.error(status, body)` helper for route handlers. */ function createErrorHelper() { From 612b368c45f751a5578de86f5b43f91f5017c078 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 11:09:34 -0400 Subject: [PATCH 04/10] chore: add prettier format script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6dd7830..4ddcf6f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "scripts": { "build": "rm -rf dist && tsgo -b tsconfig.json", + "format": "prettier --write src test", "test": "vitest run", "prepublishOnly": "pnpm build" }, From f4a5ed73a4a98c6eea729ef7a255ca4e82274b8e Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 11:09:58 -0400 Subject: [PATCH 05/10] chore: format --- src/http.ts | 9 ++++----- src/types/args.ts | 6 +++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/http.ts b/src/http.ts index 8fcf3e6..dbb5065 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,9 +1,6 @@ import { RoutePattern } from '@remix-run/route-pattern' import type { RouteArgs } from './types/args.js' -import type { - RouteRequest, - RouteRequestFactory, -} from './types/request.js' +import type { RouteRequest, RouteRequestFactory } from './types/request.js' import type { RouteSchema } from './types/schema.js' /** HTTP methods supported by Rouzer action declarations. */ @@ -157,7 +154,9 @@ function action( schema?: RouteSchema ) { const path = - typeof pathOrSchema === 'string' ? RoutePattern.parse(pathOrSchema) : undefined + typeof pathOrSchema === 'string' + ? RoutePattern.parse(pathOrSchema) + : undefined schema ??= typeof pathOrSchema === 'string' ? {} : pathOrSchema const request = ((args: RouteArgs = {}): RouteRequest => ({ schema, diff --git a/src/types/args.ts b/src/types/args.ts index fcf8ec6..b5d3033 100644 --- a/src/types/args.ts +++ b/src/types/args.ts @@ -1,6 +1,10 @@ import type { MatchParams } from '@remix-run/route-pattern/match' import type * as z from 'zod' -import type { MutationRouteSchema, QueryRouteSchema, RouteSchema } from './schema.js' +import type { + MutationRouteSchema, + QueryRouteSchema, + RouteSchema, +} from './schema.js' declare class Any { private isAny: true From c0fe45c1292b120bb21ff0c11e5dc34937386c0a Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 13:08:19 -0400 Subject: [PATCH 06/10] fix: support plugins in response maps Honor per-status response plugin markers when decoding client tuples and encoding router responses. Validate plugin requirements nested inside status-keyed response maps and cover the behavior with an NDJSON response-map fixture. --- src/client/index.ts | 35 +++++++++++++++--- src/common.ts | 2 +- src/response-map.ts | 45 ++++++++++++++++++++++++ src/response.ts | 2 +- src/server/router.ts | 40 +++++++++++++++++++-- src/types/schema.ts | 7 ++-- test/fixtures/error-responses/handler.ts | 15 +++++++- test/fixtures/error-responses/routes.ts | 8 +++++ test/fixtures/error-responses/test.ts | 22 ++++++++++++ 9 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 src/response-map.ts diff --git a/src/client/index.ts b/src/client/index.ts index 5ec36bd..1ae0fdf 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -9,7 +9,11 @@ import { type ClientResponsePlugin, type ResponsePluginMarker, } from '../response.js' -import { $error } from '../type.js' +import { + getResponseMapPluginIds, + isErrorMarker, + isResponseMap, +} from '../response-map.js' import type { RouteArgs } from '../types/args.js' import type { RouteRequest } from '../types/request.js' import type { InferRouteResponse } from '../types/response.js' @@ -152,7 +156,22 @@ export function createClient< const marker = responseSchema[statusKey] const body = await httpResponse.json() if (isErrorMarker(marker)) { - return [body, null, status] as T['$result'] + return [await httpResponse.json(), null, status] as T['$result'] + } + const pluginId = getResponsePluginMarkerId(marker) + if (pluginId) { + const plugin = responsePlugins.get(pluginId) + if (!plugin) { + throw missingClientResponsePlugin(pluginId) + } + return [ + null, + await plugin.decode(httpResponse, { + marker: marker as ResponsePluginMarker, + request: props, + }), + status, + ] as T['$result'] } return [null, body, status] as T['$result'] } @@ -283,9 +302,15 @@ function validateClientResponsePlugins( if (node.kind === 'resource') { validateClientResponsePlugins(node.children, plugins) } else { - const pluginId = getResponsePluginMarkerId(node.schema.response) - if (pluginId && !plugins.has(pluginId)) { - throw missingClientResponsePlugin(pluginId) + const pluginIds = isResponseMap(node.schema.response) + ? getResponseMapPluginIds(node.schema.response) + : [getResponsePluginMarkerId(node.schema.response)].filter( + pluginId => pluginId !== undefined + ) + for (const pluginId of pluginIds) { + if (!plugins.has(pluginId)) { + throw missingClientResponsePlugin(pluginId) + } } } } diff --git a/src/common.ts b/src/common.ts index 03819d8..9e0ffbf 100644 --- a/src/common.ts +++ b/src/common.ts @@ -7,7 +7,7 @@ export type Promisable = T | Promise * @remarks Consumers usually use `$type()` instead of constructing this type * directly. */ -export type Unchecked = { __unchecked__: T } +export type Unchecked = Record & { __unchecked__: T } /** * Compile-time-only marker used by `$error()` to carry a declared error diff --git a/src/response-map.ts b/src/response-map.ts new file mode 100644 index 0000000..b715f06 --- /dev/null +++ b/src/response-map.ts @@ -0,0 +1,45 @@ +import { + getResponsePluginMarkerId, + responsePluginMarker, +} from './response.js' +import { $error } from './type.js' +import type { RouteResponseMap, RouteSchema } from './types/schema.js' + +/** Return true when the response schema is a status-keyed response map. */ +export function isResponseMap( + response: RouteSchema['response'] +): response is RouteResponseMap { + return ( + typeof response === 'object' && + response !== null && + !(responsePluginMarker in response) + ) +} + +/** Return true when the marker represents a declared error response. */ +export function isErrorMarker(marker: unknown): boolean { + return marker === $error.symbol +} + +/** Return true when the marker represents a success response. */ +export function isSuccessMarker(marker: unknown): boolean { + return marker !== undefined && !isErrorMarker(marker) +} + +/** Find the default success status for a direct handler result. */ +export function getResponseMapPluginIds(responseMap: RouteResponseMap): string[] { + return Object.values(responseMap).flatMap(marker => { + const pluginId = getResponsePluginMarkerId(marker) + return pluginId ? [pluginId] : [] + }) +} + +export function getDefaultSuccessStatus(responseMap: RouteResponseMap): number { + for (const key of Object.keys(responseMap)) { + const marker = responseMap[Number(key)] + if (isSuccessMarker(marker)) { + return Number(key) + } + } + return 200 +} diff --git a/src/response.ts b/src/response.ts index c73b396..0a47e8d 100644 --- a/src/response.ts +++ b/src/response.ts @@ -14,7 +14,7 @@ export type ResponsePluginMarker< TClient, TRouter = TClient, TId extends string = string, -> = { +> = Record & { readonly [responsePluginMarker]: { readonly id: TId readonly client: TClient diff --git a/src/server/router.ts b/src/server/router.ts index f2ef0fd..be6a0f5 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -18,7 +18,12 @@ import { type ResponsePluginMarker, type RouterResponsePlugin, } from '../response.js' -import { $error } from '../type.js' +import { + getDefaultSuccessStatus, + getResponseMapPluginIds, + isErrorMarker, + isResponseMap, +} from '../response-map.js' import type { RouteResponseMap, RouteSchema } from '../types/schema.js' import type { RouteRequestHandlerMap } from '../types/server.js' @@ -527,6 +532,35 @@ function getSuccessStatus(responseMap: RouteResponseMap): number { return 200 } -function isErrorMarker(marker: unknown): boolean { - return marker === $error.symbol +async function encodeResponseMapResult( + responseMap: RouteResponseMap, + status: number, + value: unknown, + request: Request, + responsePlugins: Map +): Promise { + const marker = responseMap[status] + if (!marker) { + throw new Error(`Undeclared response status: ${status}`) + } + if (isErrorMarker(marker)) { + return Response.json(value, { status }) + } + const pluginId = getResponsePluginMarkerId(marker) + if (!pluginId) { + return Response.json(value, { status }) + } + const plugin = responsePlugins.get(pluginId) + if (!plugin) { + throw missingRouterResponsePlugin(pluginId) + } + const response = await plugin.encode(value, { + marker: marker as ResponsePluginMarker, + request, + }) + return new Response(response.body, { + status, + statusText: response.statusText, + headers: response.headers, + }) } diff --git a/src/types/schema.ts b/src/types/schema.ts index e00b13e..adbda09 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -13,7 +13,7 @@ export type { Unchecked } export type { UncheckedError } export type { ResponsePluginMarker } -/** Single response marker accepted by HTTP action schemas. */ +/** Single response marker accepted by status-keyed response maps. */ export type RouteResponseMarker = | Unchecked | UncheckedError @@ -22,8 +22,9 @@ export type RouteResponseMarker = /** * Status-keyed response map for declaring multiple response types. * - * @remarks Numeric keys are HTTP status codes. Use `$type()` for success - * responses and `$error()` for declared error responses. + * @remarks Numeric keys are HTTP status codes. Use `$type()` or a response + * plugin marker for success responses and `$error()` for declared error + * JSON responses. */ export type RouteResponseMap = { [status: number]: RouteResponseMarker diff --git a/test/fixtures/error-responses/handler.ts b/test/fixtures/error-responses/handler.ts index 73a4fc7..4da7bac 100644 --- a/test/fixtures/error-responses/handler.ts +++ b/test/fixtures/error-responses/handler.ts @@ -1,7 +1,8 @@ import { createRouter } from 'rouzer' +import * as ndjson from 'rouzer/ndjson' import * as routes from './routes.js' -export default createRouter().use(routes, { +export default createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, { getUser(ctx) { const id = ctx.path.id @@ -24,4 +25,16 @@ export default createRouter().use(routes, { name: 'Ada', } }, + streamUsers(ctx) { + if (ctx.query === undefined) { + return [ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ] + } + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'No stream', + }) + }, }) diff --git a/test/fixtures/error-responses/routes.ts b/test/fixtures/error-responses/routes.ts index c804ad6..a20bccb 100644 --- a/test/fixtures/error-responses/routes.ts +++ b/test/fixtures/error-responses/routes.ts @@ -1,5 +1,6 @@ import { $type, $error } from 'rouzer' import * as http from 'rouzer/http' +import * as ndjson from 'rouzer/ndjson' export type User = { id: string @@ -23,3 +24,10 @@ export const getUser = http.get('users/:id', { 404: $error(), }, }) + +export const streamUsers = http.get('users.ndjson', { + response: { + 200: ndjson.$type(), + 404: $error(), + }, +}) diff --git a/test/fixtures/error-responses/test.ts b/test/fixtures/error-responses/test.ts index 65f313e..7b8a700 100644 --- a/test/fixtures/error-responses/test.ts +++ b/test/fixtures/error-responses/test.ts @@ -1,3 +1,4 @@ +import * as ndjson from 'rouzer/ndjson' import { createTest } from '../shared.js' import handler from './handler.js' import * as routes from './routes.js' @@ -15,6 +16,27 @@ export default createTest({ expect(result1).toEqual({ id: '42', name: 'Ada' }) expect(status1).toBe(200) + // Explicit success case: returns tuple [null, User, 201] + const [error4, result4, status4] = await client.getUser({ + path: { id: 'created' }, + }) + expect(error4).toBeNull() + expect(result4).toEqual({ id: 'created', name: 'Grace' }) + expect(status4).toBe(201) + + // Plugin success case inside a response map. + const [streamError, stream, streamStatus] = await client.streamUsers() + expect(streamError).toBeNull() + expect(streamStatus).toBe(200) + const users = [] + for await (const user of stream!) { + users.push(user) + } + expect(users).toEqual([ + { id: '1', name: 'Ada' }, + { id: '2', name: 'Grace' }, + ]) + // 401 error case: returns tuple [AuthError, null, 401] const [error2, result2, status2] = await client.getUser({ path: { id: 'unauthorized' }, From 688bb65c2a13ba77240674e019c96be99ad9ccbe Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 13:09:01 -0400 Subject: [PATCH 07/10] feat: add explicit response map success helper Add ctx.success(status, body) for handlers that need to select among multiple declared success statuses. Route both ctx.success and ctx.error through the shared response-map encoder so undeclared statuses fail at runtime and plugin-backed success responses keep their per-status encoding. --- src/client/index.ts | 25 ++----- src/server/router.ts | 87 +++++++++++++----------- src/types/handler.ts | 25 ++++++- src/types/response.ts | 9 +++ src/types/schema.ts | 4 +- test/error-responses.test-d.ts | 23 ++++++- test/fixtures/error-responses/handler.ts | 7 ++ test/fixtures/error-responses/routes.ts | 1 + test/fixtures/error-responses/test.ts | 1 + 9 files changed, 117 insertions(+), 65 deletions(-) diff --git a/src/client/index.ts b/src/client/index.ts index 1ae0fdf..f217800 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -5,7 +5,6 @@ import type { HttpAction, HttpResource, HttpRouteTree } from '../http.js' import { createResponsePluginMap, getResponsePluginMarkerId, - responsePluginMarker, type ClientResponsePlugin, type ResponsePluginMarker, } from '../response.js' @@ -17,7 +16,7 @@ import { import type { RouteArgs } from '../types/args.js' import type { RouteRequest } from '../types/request.js' import type { InferRouteResponse } from '../types/response.js' -import type { RouteResponseMap, RouteSchema } from '../types/schema.js' +import type { RouteSchema } from '../types/schema.js' /** Client type inferred from an HTTP route tree passed to `createClient`. */ export type RouzerClient< @@ -151,10 +150,8 @@ export function createClient< // Handle status-keyed response maps if (isResponseMap(responseSchema)) { const status = httpResponse.status - const statusKey = status as keyof typeof responseSchema - if (statusKey in responseSchema) { - const marker = responseSchema[statusKey] - const body = await httpResponse.json() + if (status in responseSchema) { + const marker = responseSchema[status] if (isErrorMarker(marker)) { return [await httpResponse.json(), null, status] as T['$result'] } @@ -173,7 +170,7 @@ export function createClient< status, ] as T['$result'] } - return [null, body, status] as T['$result'] + return [null, await httpResponse.json(), status] as T['$result'] } // Undeclared status — reject return handleResponseError(httpResponse, props) @@ -324,17 +321,3 @@ function joinPaths(left: string, right: string) { return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/') } -/** Return true when the response schema is a status-keyed response map. */ -function isResponseMap( - response: RouteSchema['response'] -): response is RouteResponseMap { - return ( - typeof response === 'object' && - response !== null && - !(responsePluginMarker in response) - ) -} - -function isErrorMarker(marker: unknown): boolean { - return marker === $error.symbol -} diff --git a/src/server/router.ts b/src/server/router.ts index be6a0f5..e2bd856 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -222,7 +222,18 @@ class RouterObject extends MiddlewareChain { } if (isResponseMap(schema.response)) { - ;(context as any).error = createErrorHelper() + ;(context as any).error = createResponseHelper( + schema.response, + request, + responsePlugins, + true + ) + ;(context as any).success = createResponseHelper( + schema.response, + request, + responsePlugins, + false + ) } const result = await handler(context) @@ -242,9 +253,14 @@ class RouterObject extends MiddlewareChain { }) } if (isResponseMap(schema.response)) { - // Success response from a response map — find the success status - const status = getSuccessStatus(schema.response) - return Response.json(result, { status }) + const status = getDefaultSuccessStatus(schema.response) + return encodeResponseMapResult( + schema.response, + status, + result, + request, + responsePlugins + ) } return Response.json(result) } @@ -350,9 +366,15 @@ function validateRouterResponsePlugins( plugins: Map ) { for (const route of routes) { - const pluginId = getResponsePluginMarkerId(route.schema.response) - if (pluginId && !plugins.has(pluginId)) { - throw missingRouterResponsePlugin(pluginId) + const pluginIds = isResponseMap(route.schema.response) + ? getResponseMapPluginIds(route.schema.response) + : [getResponsePluginMarkerId(route.schema.response)].filter( + pluginId => pluginId !== undefined + ) + for (const pluginId of pluginIds) { + if (!plugins.has(pluginId)) { + throw missingRouterResponsePlugin(pluginId) + } } } } @@ -497,39 +519,28 @@ function createOriginPattern(origin: string) { return new ExactPattern(origin) } -/** Return true when the response schema is a status-keyed response map. */ -function isResponseMap( - response: RouteSchema['response'] -): response is RouteResponseMap { - return ( - typeof response === 'object' && - response !== null && - !(responsePluginMarker in response) - ) -} - -import { responsePluginMarker } from '../response.js' - -/** Create the `ctx.error(status, body)` helper for route handlers. */ -function createErrorHelper() { - return (status: number, body: unknown): Response => { - return Response.json(body, { status }) - } -} - -/** - * Find the first success status in a response map (a status whose marker is - * `$type()` or a plugin marker, not `$error()`). - */ -function getSuccessStatus(responseMap: RouteResponseMap): number { - for (const key of Object.keys(responseMap)) { - const status = Number(key) - const marker = (responseMap as any)[status] - if (!isErrorMarker(marker)) { - return status +/** Create `ctx.error(status, body)` or `ctx.success(status, body)`. */ +function createResponseHelper( + responseMap: RouteResponseMap, + request: Request, + responsePlugins: Map, + error: boolean +) { + return (status: number, body: unknown): Promise | Response => { + const marker = responseMap[status] + if (!marker || isErrorMarker(marker) !== error) { + throw new Error( + `Undeclared ${error ? 'error' : 'success'} response status: ${status}` + ) } + return encodeResponseMapResult( + responseMap, + status, + body, + request, + responsePlugins + ) } - return 200 } async function encodeResponseMapResult( diff --git a/src/types/handler.ts b/src/types/handler.ts index 59dbcf8..37fb6f1 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -6,6 +6,7 @@ import type { HttpAction } from '../http.js' import type { InferRouteHandlerResult, InferResponseMapErrors, + InferResponseMapSuccesses, } from './response.js' import type { RouteResponseMap, RouteSchema } from './schema.js' @@ -20,11 +21,15 @@ type RequestContext = */ export type RouteErrorResponse = Response & { __routeError__: true } +/** Response returned by `ctx.success(status, body)` in route handlers. */ +export type RouteSuccessResponse = Response & { __routeSuccess__: true } + export type RouteRequestHandler< TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult, TErrors = never, + TSuccesses = never, > = ( context: RequestContext & TArgs & @@ -42,6 +47,20 @@ export type RouteRequestHandler< ? [status: S, body: B] : never ) => RouteErrorResponse + }) & + ([TSuccesses] extends [never] + ? {} + : { + /** + * Return a declared success response with an explicit status. + * + * @remarks Useful when a response map declares multiple 2xx statuses. + */ + success: ( + ...args: TEntry extends [infer S extends number, infer B] + ? [status: S, body: B] + : never + ) => RouteSuccessResponse }) ) => Promisable @@ -65,7 +84,8 @@ export type InferActionHandler< : undefined }, InferRouteHandlerResult>, - InferResponseMapErrors + InferResponseMapErrors, + InferResponseMapSuccesses > : RouteRequestHandler< TMiddleware, @@ -81,7 +101,8 @@ export type InferActionHandler< : undefined }, InferRouteHandlerResult>, - InferResponseMapErrors + InferResponseMapErrors, + InferResponseMapSuccesses > : TAction['method'] extends 'GET' ? RouteRequestHandler< diff --git a/src/types/response.ts b/src/types/response.ts index 4393223..cb9e63e 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -72,3 +72,12 @@ export type InferResponseMapErrors = { ? [K, TError] : never }[keyof T & number] + +/** Extract success entries as a union of `[status, body]` pairs. */ +export type InferResponseMapSuccesses = { + [K in keyof T & number]: T[K] extends Unchecked + ? [K, TSuccess] + : T[K] extends ResponsePluginMarker + ? [K, TRouter] + : never +}[keyof T & number] diff --git a/src/types/schema.ts b/src/types/schema.ts index adbda09..1b9c4a3 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -9,9 +9,7 @@ import type { ResponsePluginMarker } from '../response.js' * @remarks Application code should usually call `$type()` instead of naming * this marker directly. */ -export type { Unchecked } -export type { UncheckedError } -export type { ResponsePluginMarker } +export type { ResponsePluginMarker, Unchecked, UncheckedError } /** Single response marker accepted by status-keyed response maps. */ export type RouteResponseMarker = diff --git a/test/error-responses.test-d.ts b/test/error-responses.test-d.ts index cb22f99..c861fe0 100644 --- a/test/error-responses.test-d.ts +++ b/test/error-responses.test-d.ts @@ -17,6 +17,7 @@ type AuthError = { code: 'UNAUTHORIZED'; message: string } const getUser = http.get('users/:id', { response: { 200: $type(), + 201: $type(), 401: $error(), 404: $error(), }, @@ -35,7 +36,10 @@ type GetUserResult = Awaited> type _ClientReturnsDiscriminatedTuple = Assert< Equal< GetUserResult, - [null, User, 200] | [AuthError, null, 401] | [NotFoundError, null, 404] + | [null, User, 200] + | [null, User, 201] + | [AuthError, null, 401] + | [NotFoundError, null, 404] > > @@ -48,6 +52,23 @@ createRouter().use(routes, { if (ctx.path.id === 'unauthorized') { return ctx.error(401, { code: 'UNAUTHORIZED', message: 'no auth' }) } + if (ctx.path.id === 'created') { + return ctx.success(201, { id: ctx.path.id, name: 'Ada' }) + } + return { id: ctx.path.id, name: 'Ada' } + }, +}) + +createRouter().use(routes, { + getUser(ctx) { + // @ts-expect-error 500 is not a declared error status. + ctx.error(500, { code: 'NOT_FOUND', message: 'nope' }) + // @ts-expect-error Error body must match the selected status. + ctx.error(404, { code: 'UNAUTHORIZED', message: 'nope' }) + // @ts-expect-error 404 is not a declared success status. + ctx.success(404, { code: 'NOT_FOUND', message: 'nope' }) + // @ts-expect-error Success body must match the selected status. + ctx.success(201, { id: 123, name: 'Ada' }) return { id: ctx.path.id, name: 'Ada' } }, }) diff --git a/test/fixtures/error-responses/handler.ts b/test/fixtures/error-responses/handler.ts index 4da7bac..841c8e2 100644 --- a/test/fixtures/error-responses/handler.ts +++ b/test/fixtures/error-responses/handler.ts @@ -13,6 +13,13 @@ export default createRouter({ plugins: [ndjson.routerPlugin] }).use(routes, { }) } + if (id === 'created') { + return ctx.success(201, { + id, + name: 'Grace', + }) + } + if (id === 'missing') { return ctx.error(404, { code: 'NOT_FOUND', diff --git a/test/fixtures/error-responses/routes.ts b/test/fixtures/error-responses/routes.ts index a20bccb..a390057 100644 --- a/test/fixtures/error-responses/routes.ts +++ b/test/fixtures/error-responses/routes.ts @@ -20,6 +20,7 @@ export type AuthError = { export const getUser = http.get('users/:id', { response: { 200: $type(), + 201: $type(), 401: $error(), 404: $error(), }, diff --git a/test/fixtures/error-responses/test.ts b/test/fixtures/error-responses/test.ts index 7b8a700..ce8b696 100644 --- a/test/fixtures/error-responses/test.ts +++ b/test/fixtures/error-responses/test.ts @@ -7,6 +7,7 @@ export default createTest({ name: 'typed error responses ($error with status-keyed response map)', routes, handler, + clientPlugins: [ndjson.clientPlugin], test: async client => { // Success case: returns tuple [null, User, 200] const [error1, result1, status1] = await client.getUser({ From 2c5236d517d131d3fe898e7ef70348d45be72b3e Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 15:00:42 -0400 Subject: [PATCH 08/10] docs: cover typed response maps Document the user-visible response map API added on this branch: (), generated client tuple results, ctx.error, ctx.success, and response-plugin success entries inside maps. Add a runnable typed error response example and keep public TSDoc aligned so declaration docs describe response maps alongside direct JSON and plugin responses. --- README.md | 56 ++++++++++++-- docs/context.md | 142 ++++++++++++++++++++++++++++++------ examples/error-responses.ts | 98 +++++++++++++++++++++++++ src/client/index.ts | 7 +- src/response.ts | 2 + src/type.ts | 7 +- src/types/response.ts | 16 +++- test/examples.test.ts | 14 ++++ 8 files changed, 306 insertions(+), 36 deletions(-) create mode 100644 examples/error-responses.ts diff --git a/README.md b/README.md index c43aaed..acf1f63 100644 --- a/README.md +++ b/README.md @@ -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. @@ -31,8 +31,8 @@ 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()` and - `response: ndjson.$type()` are compile-time only +- you need runtime response-body validation; `$type()`, `$error()`, and + `ndjson.$type()` are compile-time only - you want a framework that owns controllers, data loading, rendering, and deployment adapters - you cannot use ESM or Zod v4+ @@ -55,7 +55,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' ``` @@ -103,6 +103,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(), + 404: $error(), + }, +}) +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()` for endpoints that stream @@ -141,6 +184,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` diff --git a/docs/context.md b/docs/context.md index 6d1f00f..031a761 100644 --- a/docs/context.md +++ b/docs/context.md @@ -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 @@ -92,11 +92,45 @@ 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()` and `ndjson.$type()` +### Response markers and maps -`response: $type()` 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()` 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 response bodies at runtime. + +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(), + 201: $type(), + 404: $error(), + }, +}) +``` + +Success entries use `$type()` or a response plugin marker. Error entries use +`$error()` 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()` is a TypeScript-only marker for newline-delimited JSON response streams from the `rouzer/ndjson` subpath. Register @@ -109,8 +143,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()` use `client.json(...)` under the -hood and return parsed JSON typed as `T`. +functions. Actions with `response: $type()` return parsed JSON typed as `T`. +Actions with a response map return the tuple union described by that map. ### Response plugins @@ -121,10 +155,11 @@ matching runtime plugins. For NDJSON, those are `ndjson.$type()`, 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 @@ -157,6 +192,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()` handlers return an `Iterable` or `AsyncIterable` unless they return a custom `Response` - plain values are returned with `Response.json(value)` @@ -175,6 +212,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 @@ -205,9 +244,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 @@ -247,9 +286,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()` 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(), + 404: $error(), + }, +}) +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 @@ -322,8 +416,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` @@ -390,6 +484,8 @@ await client.profiles.update({ only when string params are sufficient. - Use `response: $type()` for JSON endpoints that should have typed client action functions. +- Use response maps with `$error()` when callers should handle declared error + statuses as typed data instead of exceptions. - Use `response: ndjson.$type()` plus `ndjson.routerPlugin` and `ndjson.clientPlugin` for response streams where each line is a JSON value and the client should consume an `AsyncIterable`. @@ -400,10 +496,12 @@ await client.profiles.update({ ## Constraints and gotchas -- `$type()` and `ndjson.$type()` are compile-time only and do not validate - response payloads or streamed items. +- `$type()`, `$error()`, and `ndjson.$type()` are compile-time only and + do not validate response payloads or streamed items. - NDJSON support is for response streams; request bodies still use the existing JSON body schema path. +- Declared `$error()` 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`. diff --git a/examples/error-responses.ts b/examples/error-responses.ts new file mode 100644 index 0000000..e8df688 --- /dev/null +++ b/examples/error-responses.ts @@ -0,0 +1,98 @@ +import type { HattipHandler } from '@hattip/core' +import { $error, $type, createClient, createRouter } from 'rouzer' +import * as http from 'rouzer/http' + +type User = { + id: string + name: string +} + +type AuthError = { + code: 'UNAUTHORIZED' + message: string +} + +type NotFoundError = { + code: 'NOT_FOUND' + message: string +} + +export const getUser = http.get('users/:id', { + response: { + 200: $type(), + 201: $type(), + 401: $error(), + 404: $error(), + }, +}) + +export const routes = { getUser } + +/** + * Tiny Hattip adapter used only to keep this example self-contained. Real apps + * mount the handler with a Hattip adapter for their runtime. + */ +function createLocalFetch(handler: HattipHandler): typeof fetch { + return async (input, init) => { + const request = new Request(input, init) + const response = await handler({ + request, + ip: '127.0.0.1', + platform: undefined, + env() { + return undefined + }, + passThrough() {}, + waitUntil(promise) { + void promise + }, + }) + + return response ?? new Response(null, { status: 404 }) + } +} + +export async function runErrorResponsesExample() { + const users = new Map([['42', { id: '42', name: 'Ada' }]]) + + const handler = createRouter({ basePath: 'api/' }).use(routes, { + getUser(ctx) { + if (ctx.path.id === 'unauthorized') { + return ctx.error(401, { + code: 'UNAUTHORIZED', + message: 'Login required', + }) + } + + if (ctx.path.id === 'created') { + return ctx.success(201, { + id: 'created', + name: 'Grace', + }) + } + + const user = users.get(ctx.path.id) + if (!user) { + return ctx.error(404, { + code: 'NOT_FOUND', + message: 'User not found', + }) + } + + return user + }, + }) + + const client = createClient({ + baseURL: 'https://example.test/api/', + routes, + fetch: createLocalFetch(handler), + }) + + const found = await client.getUser({ path: { id: '42' } }) + const created = await client.getUser({ path: { id: 'created' } }) + const missing = await client.getUser({ path: { id: 'missing' } }) + const unauthorized = await client.getUser({ path: { id: 'unauthorized' } }) + + return { found, created, missing, unauthorized } +} diff --git a/src/client/index.ts b/src/client/index.ts index f217800..155b7fe 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -241,8 +241,10 @@ export type ClientTree = { * Client action function attached for each HTTP action leaf. * * @remarks Actions whose schema has `response: $type()` return parsed JSON - * as `T`. Actions whose schema has a plugin response marker return the plugin's - * client result type. Actions without a response marker return the raw + * as `T`. Actions whose schema has a status-keyed response map return a tuple + * union of `[null, value, status]` success entries and `[error, null, status]` + * error entries. Actions whose schema has a plugin response marker return the + * plugin's client result type. Actions without a response marker return the raw * `Response`. */ export type RouteFunction = ( @@ -320,4 +322,3 @@ function missingClientResponsePlugin(pluginId: string) { function joinPaths(left: string, right: string) { return [left, right].filter(Boolean).join('/').replace(/\/+/g, '/') } - diff --git a/src/response.ts b/src/response.ts index 0a47e8d..0ceb528 100644 --- a/src/response.ts +++ b/src/response.ts @@ -9,6 +9,8 @@ export const responsePluginMarker = Symbol.for('rouzer.response-plugin') * * @remarks `TClient` is the value returned by generated client action * functions. `TRouter` is the non-`Response` value accepted from route handlers. + * Plugin markers may be used directly as an action response or as success + * entries in a status-keyed response map. */ export type ResponsePluginMarker< TClient, diff --git a/src/type.ts b/src/type.ts index 13ee158..07e22f2 100644 --- a/src/type.ts +++ b/src/type.ts @@ -5,7 +5,8 @@ import type { Unchecked, UncheckedError } from './common.js' * * @remarks `$type()` does not perform runtime validation. It lets Rouzer type * server handler return values and client action functions for HTTP actions - * whose responses are expected to be JSON. + * whose responses are expected to be JSON. Use it directly as `response` for one + * JSON success shape, or as a success entry in a status-keyed response map. * * @example * ```ts @@ -28,8 +29,8 @@ $type.symbol = Symbol() * * @remarks `$error()` marks a non-success response branch in a status-keyed * response map. On the server, handlers use `ctx.error(status, body)` to return - * declared errors. On the client, declared error responses resolve as part of a - * discriminated tuple instead of rejecting the promise. + * declared errors. On the client, declared error responses resolve as + * `[error, null, status]` tuple entries instead of rejecting the promise. * * @example * ```ts diff --git a/src/types/response.ts b/src/types/response.ts index cb9e63e..bfdbb3a 100644 --- a/src/types/response.ts +++ b/src/types/response.ts @@ -25,7 +25,14 @@ type InferResponseMapClient = { : never }[keyof T & number] -/** Infer the client response type from an action schema. */ +/** + * Infer the generated client action result type from an action schema. + * + * @remarks Direct JSON markers infer their payload type, plugin markers infer + * their client result type, and status-keyed response maps infer a tuple union + * of `[null, value, status]` success entries and `[error, null, status]` error + * entries. + */ export type InferRouteResponse = T extends { response: infer R } @@ -50,7 +57,12 @@ type InferResponseMapHandlerResult = { : never }[keyof T & number] -/** Infer the non-`Response` handler result type from an action schema. */ +/** + * Infer the non-`Response` handler result type from an action schema. + * + * @remarks For status-keyed response maps, this includes only success result + * values. Declared error responses are returned with `ctx.error(status, body)`. + */ export type InferRouteHandlerResult = T extends { response: infer R } diff --git a/test/examples.test.ts b/test/examples.test.ts index 79fb61d..b62c41b 100644 --- a/test/examples.test.ts +++ b/test/examples.test.ts @@ -1,4 +1,5 @@ import { runBasicUsageExample } from '../examples/basic-usage.js' +import { runErrorResponsesExample } from '../examples/error-responses.js' import { runNdjsonStreamExample } from '../examples/ndjson-stream.js' test('basic usage example stays runnable', async () => { @@ -18,6 +19,19 @@ test('basic usage example stays runnable', async () => { }) }) +test('typed error response example stays runnable', async () => { + await expect(runErrorResponsesExample()).resolves.toEqual({ + found: [null, { id: '42', name: 'Ada' }, 200], + created: [null, { id: 'created', name: 'Grace' }, 201], + missing: [{ code: 'NOT_FOUND', message: 'User not found' }, null, 404], + unauthorized: [ + { code: 'UNAUTHORIZED', message: 'Login required' }, + null, + 401, + ], + }) +}) + test('NDJSON stream example stays runnable', async () => { await expect(runNdjsonStreamExample()).resolves.toEqual([ { id: 1, message: 'ready' }, From 9891bf24e8f966500ead9bb826efce95a7d58e26 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 23 May 2026 15:08:09 -0400 Subject: [PATCH 09/10] docs: clarify response validation boundaries Avoid implying Rouzer is a poor fit for any runtime response validation. The docs now distinguish server-boundary response validation from the intended model: response markers are type contracts, with runtime integrity checks placed where data enters server or client code. --- README.md | 6 ++++-- docs/context.md | 16 +++++++++++----- src/type.ts | 17 ++++++++++------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index acf1f63..2a997d4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ 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 @@ -31,8 +33,8 @@ Consider something else if: - you need OpenAPI-first workflows, schema files, or generated clients for other languages -- you need runtime response-body validation; `$type()`, `$error()`, and - `ndjson.$type()` are compile-time only +- you want the router to validate every response body at the server boundary; + `$type()`, `$error()`, and `ndjson.$type()` are type contracts - you want a framework that owns controllers, data loading, rendering, and deployment adapters - you cannot use ESM or Zod v4+ diff --git a/docs/context.md b/docs/context.md index 031a761..ac10842 100644 --- a/docs/context.md +++ b/docs/context.md @@ -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 @@ -96,7 +99,9 @@ and server support. `response: $type()` 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 response bodies at runtime. +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: @@ -496,8 +501,9 @@ await client.profiles.update({ ## Constraints and gotchas -- `$type()`, `$error()`, and `ndjson.$type()` are compile-time only and - do not validate response payloads or streamed items. +- `$type()`, `$error()`, and `ndjson.$type()` 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()` responses are JSON responses. Use a custom `Response` diff --git a/src/type.ts b/src/type.ts index 07e22f2..fb367c9 100644 --- a/src/type.ts +++ b/src/type.ts @@ -3,10 +3,12 @@ import type { Unchecked, UncheckedError } from './common.js' /** * Create a compile-time-only marker for an action's JSON response payload type. * - * @remarks `$type()` does not perform runtime validation. It lets Rouzer type - * server handler return values and client action functions for HTTP actions - * whose responses are expected to be JSON. Use it directly as `response` for one - * JSON success shape, or as a success entry in a status-keyed response map. + * @remarks `$type()` does not validate handler return values at the server + * boundary. It lets Rouzer type server handler return values and client action + * functions for HTTP actions whose responses are expected to be JSON. Use it + * directly as `response` for one JSON success shape, or as a success entry in a + * status-keyed response map. Validate response data where it enters your server + * or client code when runtime integrity is required. * * @example * ```ts @@ -28,9 +30,10 @@ $type.symbol = Symbol() * Create a compile-time-only marker for a declared error response type. * * @remarks `$error()` marks a non-success response branch in a status-keyed - * response map. On the server, handlers use `ctx.error(status, body)` to return - * declared errors. On the client, declared error responses resolve as - * `[error, null, status]` tuple entries instead of rejecting the promise. + * response map. It is a type contract, not a runtime validator. On the server, + * handlers use `ctx.error(status, body)` to return declared errors. On the + * client, declared error responses resolve as `[error, null, status]` tuple + * entries instead of rejecting the promise. * * @example * ```ts From fef48b2f928d2ada23ade338a324f2da7ebde8f7 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sun, 24 May 2026 11:03:57 -0400 Subject: [PATCH 10/10] chore: release v3.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4ddcf6f..3c10dcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rouzer", - "version": "3.1.0", + "version": "3.2.0", "type": "module", "exports": { ".": {