API reference
Subpath exports: @eddy-works/never-rest (errors), ./contract, ./server, ./client, ./node, ./local, ./policy, ./limits, ./testing, ./openapi, ./query, ./mcp.
| Module | Status |
|---|---|
. | Shipped |
./contract | Shipped |
./server | Shipped |
./client | Shipped |
./node | Shipped |
./local | Shipped (in-process dispatch; no Request / Response) |
./policy | Shipped (construction-time operation coverage) |
./limits | Shipped (no runtime dependencies; no node:*) |
./testing | Shipped (test-time helpers; not for production handlers) |
./openapi | Shipped |
./query | Shipped (cache-layer adapters; no React/TanStack dependency) |
./mcp | Shipped (@modelcontextprotocol/sdk optional peer) |
Types import Result / ResultAsync from neverthrow.
@eddy-works/never-rest — errors and HTTP mapping
RailIssue
interface RailIssue {
readonly path: readonly (string | number)[];
readonly message: string;
}Validation or domain issue with a JSON-pointer-style path.
const issue: RailIssue = { path: ['email'], message: 'Invalid email' };RailError (interface)
interface RailError<TCode extends string = string> {
readonly code: TCode;
readonly message: string;
readonly issues?: readonly RailIssue[];
readonly cause?: RailError;
readonly origin?: string;
readonly retryable?: boolean;
readonly retryAfterMs?: number;
readonly nextStep?: string;
readonly ctx?: Readonly<Record<string, unknown>>;
}ctx carries structured context for whoever reads the error — most often an
agent deciding what to do next. The named fields cover what every caller needs
(retryable, nextStep, origin); ctx carries what only the tool raising the
error knows: which gate rejected, which category, which files were involved.
It is disclosed at full and internal and always stripped at public, because
the keys are caller-defined and cannot be vetted for leakage the way the named
fields can. Do not reach for issues instead — that means validation paths, and
overloading it corrupts the meaning for every other consumer.
railError (function)
function railError<TCode extends string>(
code: TCode,
message: string,
extra?: Omit<RailError<TCode>, 'code' | 'message'>,
): RailError<TCode>;railError('not_found', 'User not found', { nextStep: 'Check the id and retry' });chain
function chain<TCode extends string>(
outer: Omit<RailError<TCode>, 'cause'>,
cause: RailError,
): RailError<TCode>;Wrap a downstream error as the cause of a caller-facing error.
const downstream = railError('not_found', 'Row missing', { origin: 'inventory' });
const gateway = chain(
{ code: 'order_failed', message: 'Could not fulfil order', origin: 'orders' },
downstream,
);flatten
function flatten(error: RailError): readonly RailError[];Root-first list of every hop in the chain.
flatten(gateway); // [gateway, downstream]formatChain
function formatChain(error: RailError): string;One line per hop for logs.
formatChain(gateway);
// [orders] order_failed: Could not fulfil order
// [inventory] not_found: Row missingWhen origin is absent, the line is code: message only.
HostStatuses
interface HostStatuses {
readonly validation_error: number;
readonly internal: number;
readonly route_not_found: number;
}
const HOST_STATUSES: HostStatuses = {
validation_error: 400,
internal: 500,
route_not_found: 404,
};Host codes stay off RouteDef.errors. Override defaults with serve(..., { hostStatuses }).
BOUNDARY_ERRORS
Reserved boundary.* codes. Domain codes must not use this prefix — compileContract rejects them. Existing domain codes such as unauthorized keep working.
| Code | HTTP | Retryable |
|---|---|---|
boundary.unauthenticated | 401 | no |
boundary.forbidden | 403 | no |
boundary.request_timeout | 408 | no |
boundary.payload_too_large | 413 | no |
boundary.rate_limited | 429 | yes |
boundary.overloaded | 503 | yes |
boundary.timeout | 504 | no |
boundary.cancelled | none (local / client abort) | no |
boundaryError(code) builds the public message. HTTP statuses are owned by serve and cannot be overridden.
StatusMap
type StatusMap<TCode extends string> = { readonly [K in TCode]: number };statusFor
function statusFor<TCode extends string>(
map: StatusMap<TCode>,
error: RailError<TCode>,
): number;statusFor({ not_found: 404, conflict: 409 }, railError('not_found', '…')); // 404toDeclaredResponse
function toDeclaredResponse<TCode extends string, TStatus extends number>(
error: RailError<TCode>,
map: StatusMap<TCode>,
declared: readonly TStatus[],
): { status: TStatus | 500; body: RailError<TCode> };Undeclared statuses degrade to 500.
toDeclaredResponse(
railError('not_found', '…'),
{ not_found: 404 },
[200, 404],
);
// { status: 404, body: … }
toDeclaredResponse(
railError('not_found', '…'),
{ not_found: 404 },
[200], // 404 not declared
);
// { status: 500, body: … }Disclosure
type Disclosure = 'full' | 'internal' | 'public';disclose
function disclose<TCode extends string>(
error: RailError<TCode>,
level: Disclosure,
): RailError<TCode>;disclose(chain(outer, cause), 'public'); // no cause, no internal issue pathsmessage and issues[].message are public data and are always disclosed. The nextStep regex is a defensive heuristic, not redaction. Secrets belong in cause or ctx. retryAfterMs is public-safe and is kept at every level.
| Field | full | internal | public |
|---|---|---|---|
code, message | kept | kept | kept |
issues | kept | kept | paths emptied; messages kept |
retryable, retryAfterMs | kept | kept | kept |
nextStep | kept | kept | kept only when advisory |
ctx | kept | kept | dropped |
cause, origin | kept | dropped | dropped |
RespondOptions
interface RespondOptions<TCode extends string, TSuccess extends number, TStatus extends number> {
readonly success: TSuccess;
readonly statuses: StatusMap<TCode>;
readonly declared: readonly TStatus[];
readonly disclosure?: Disclosure; // defaults to 'full'
}respond
function respond<TValue, TCode extends string, TSuccess extends number, TStatus extends number>(
result: Result<TValue, RailError<TCode>>,
options: RespondOptions<TCode, TSuccess, TStatus>,
): { status: TSuccess; body: TValue } | { status: TStatus | 500; body: RailError<TCode> };respond(ok({ id: '1' }), {
success: 200,
statuses: { not_found: 404 },
declared: [200, 404],
disclosure: 'internal',
});@eddy-works/never-rest/contract
RouteDef
interface RouteDef {
readonly method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
readonly path: string;
readonly params?: StandardSchemaV1;
readonly query?: StandardSchemaV1;
readonly body?: StandardSchemaV1;
readonly headers?: StandardSchemaV1;
readonly output?: StandardSchemaV1; // omit only when success is 204
readonly success?: number; // 200 | 201 | 202 | 204; default 200
readonly errors: { readonly [code: string]: number };
readonly summary?: string;
}Each input source is optional. Declare params when the path has :param segments; declare query for query-string fields; declare body for JSON body fields on POST, PUT, and PATCH; declare headers for request-header fields. GET and DELETE may use params, query, and headers but not body. POST may combine query and body. Fields that share a name across sources stay distinct — params.id and body.id never merge.
errors is a code → HTTP-status map ({ not_found: 404 }). Use {} when the route declares no domain errors. Host codes (validation_error, internal, route_not_found) must not appear here. The same domain code may map to different statuses on different routes. output is required unless success is 204.
method, path, success, and the status numbers in errors are the HTTP projection of the route. createLocalClient / createDispatcher address the operation by name and ignore those statuses — HTTP is not a local concern. Keep the fields anyway: the same object still compiles, still exports OpenAPI, and still mounts on serve.
ContractDef
interface ContractDef {
readonly [operation: string]: RouteDef;
}Plain object literal — no builder.
export const contract = {
getUser: {
method: 'GET',
path: '/users/:id',
params: z.object({ id: z.string() }),
output: userSchema,
errors: { not_found: 404 },
},
} as const satisfies ContractDef;Use as const satisfies ContractDef so error-code keys stay literal. Without as const, errors widens and domain codes stop being literal. Keep the object in its own module (or shared package) and import it from handlers, serve, createClient, createLocalClient, and toOpenAPI — see the gateway example.
compileContract
function compileContract<TContract extends ContractDef>(
contract: TContract,
): CompiledContract<TContract>;Validates the contract once at serve / createClient / toOpenAPI construction. Rejects duplicate literal paths, trailing-slash aliases, duplicate compiled matchers (for example /users/:id and /users/:userId), duplicate path parameter names within a route, a path with :param segments without a params schema, params on a static path, body on GET or DELETE, reserved domain error codes (including any code starting with boundary.), host codes on route.errors, error statuses outside 400–599, success codes other than 200/201/202/204, output on 204 routes, missing output on other success codes, and duplicate error codes within a route. Throws ContractConfigurationError naming the conflicting operations.
isContractPath
function isContractPath(
compiled: CompiledContract<ContractDef>,
pathname: string,
): boolean;True when pathname matches any compiled route, regardless of method. Uses compiled matchPath matchers — not a Set of template strings — so /users/:id matches /users/ada. Malformed percent-encoding (invalid_encoding) still counts as a match so the host dispatches to serve, which returns validation_error.
Prefer cooperative handle() for shared-process mounts (SvelteKit hooks.server.ts, Workers). Callable serve() always returns a Response, including JSON route_not_found for unmatched paths, so calling it for every request would steal non-contract traffic. handle() returns { matched: false } only outside basePath or the contract path set — wrong method on a known path stays { matched: true } with route_not_found. Sibling uploads and SSE belong on that unmatched path — files and streams.
isContractPath remains for hosts that must decide membership without invoking serve.
import { serve } from '@eddy-works/never-rest/server';
const handler = serve(contract, handlers, { origin: 'users-api', basePath: '/api' });
const result = await handler.handle(request, context);
if (result.matched) {
return result.response;
}ContractConfigurationError
Thrown when compileContract finds an invalid contract. Fix the contract before construction proceeds.
assertHandlersComplete
function assertHandlersComplete<TContract extends ContractDef>(
contract: TContract,
handlers: Record<string, unknown>,
): asserts handlers is Handlers<TContract, unknown>;Ensures every operation key in the contract maps to a function handler. Called by serve at construction.
ClientArgsOf
type ClientArgsOf<TRoute extends RouteDef> = {
readonly params?: …; // when route declares params — InferInput
readonly query?: …; // when route declares query — InferInput
readonly body?: …; // when route declares body — InferInput
readonly headers?: …; // when route declares headers — InferInput
};Wire-shaped client args from declared params, query, body, and headers schemas. Each key appears only when the route declares that source. Use InferInput so transforms and coerces type correctly on the wire side. Prefer a named alias from the contract:
export type CreateUserArgs = ClientArgsOf<(typeof contract)['createUser']>;
// { body: { name: string } }Routes with no input sources have an empty ClientArgsOf — the client method takes no args.
HandlerArgsOf
type HandlerArgsOf<TRoute extends RouteDef> = {
readonly params?: …; // when route declares params — InferOutput
readonly query?: …; // when route declares query — InferOutput
readonly body?: …; // when route declares body — InferOutput
readonly headers?: …; // when route declares headers — InferOutput
};Parsed args inside handlers after server-side parseRouteSources (InferOutput per source — coerced/transformed).
OutputOf
type OutputOf<TRoute extends RouteDef> =
TRoute['output'] extends StandardSchemaV1
? StandardSchemaV1.InferOutput<TRoute['output']>
: void;void when the route omits output (success: 204).
ErrorOf
type ErrorOf<TRoute extends RouteDef> = RailError<keyof TRoute['errors'] & string>;Handler and server domain surface — declared route error-code keys only.
ClientErrorOf
type ClientErrorOf<TRoute extends RouteDef> =
| ErrorOf<TRoute>
| RailError<'validation_error' | 'internal' | 'unavailable'>
| RailError<BoundaryErrorCode>;Client operation error union — domain codes, host codes, transport unavailable, and reserved boundary codes.
ServerErrorOf
type ServerErrorOf<TRoute extends RouteDef> =
| ErrorOf<TRoute>
| RailError<'validation_error' | 'internal'>;Server-side error union for handlers (no client-synthesized unavailable).
parseRouteSources
function parseRouteSources<TRoute extends RouteDef>(
route: TRoute,
sources: {
readonly params?: Record<string, string>;
readonly query?: unknown;
readonly body?: unknown;
readonly headers?: unknown;
},
): ResultAsync<HandlerArgsOf<TRoute>, RailError<'validation_error'>>;await parseRouteSources(contract.createUser, {
body: { name: 'Ada' },
});
// Ok({ body: { name: 'Ada' } })
await parseRouteSources(contract.getUser, {
params: { id: 'u1' },
});
// Ok({ params: { id: 'u1' } })
await parseRouteSources(contract.listUsers, {});
// Ok({}) — routes without input sources skip validationValidates each declared source independently — params, query, body, and headers never merge. Works with any Standard Schema validator (Zod 4, Valibot, ArkType). Validation never throws; rejections and thrown validators become Err(validation_error).
parseOutput
function parseOutput<TRoute extends RouteDef>(
route: TRoute,
value: unknown,
): ResultAsync<OutputOf<TRoute>, RailError<'internal'>>;Parse a handler success value through the route output schema. Routes without output (success: 204) resolve Ok(undefined) without running a schema. serve serialises the parsed value — not the handler's raw return.
Transport stability: the client re-parses the JSON body with the same schema. Output schemas must survive parse → JSON.stringify → JSON.parse → parse with equal values. Type-changing transforms (for example z.number().transform(String)) break the client. Prove compliance in tests with checkContractOutputs (every operation) or checkTransportStability from ./testing.
CompiledPath
interface CompiledPath {
readonly regex: RegExp;
readonly paramNames: readonly string[];
}Precompiled matcher for a contract path string.
compilePath
function compilePath(path: string): CompiledPath;Compile /users/:id into a regex and param names. Exact segments and single :param segments only; invalid segments (empty :, multiple : in one segment) throw.
normalizePath
function normalizePath(path: string): string;Strip trailing slashes so /users and /users/ share one matcher. compileContract rejects contracts where both forms appear.
const compiled = compilePath('/users/:id');
// { regex: /^\/users\/([^/]+)$/, paramNames: ['id'] }matchPath
type PathMatch =
| { readonly kind: 'match'; readonly params: Record<string, string> }
| { readonly kind: 'miss' }
| { readonly kind: 'invalid_encoding'; readonly param: string };
function matchPath(compiled: CompiledPath, pathname: string): PathMatch;Match a pathname against a compiled path; return decoded params, a miss, or an invalid percent-encoding capture.
matchPath(compiled, '/users/u1'); // { kind: 'match', params: { id: 'u1' } }
matchPath(compiled, '/orders/1'); // { kind: 'miss' }
matchPath(compiled, '/users/%zz'); // { kind: 'invalid_encoding', param: 'id' }serve uses compileRoutes / matchPath (via ./server), matching routes in contract declaration order. Static segments before dynamic (/users/me before /users/:id) is intentional — not a compile error.
@eddy-works/never-rest/server
CompiledRoute
interface CompiledRoute {
readonly key: string;
readonly route: RouteDef;
readonly compiledPath: CompiledPath;
}RouteMatch
interface RouteMatch {
readonly key: string;
readonly route: RouteDef;
readonly params: Record<string, string>;
}compileRoutes
function compileRoutes(contract: ContractDef): readonly CompiledRoute[];Precompile every operation in a contract for routing.
matchRoute
function matchRoute(
routes: readonly CompiledRoute[],
method: string,
pathname: string,
): RouteMatch | undefined;First match by method and pathname in declaration order, or undefined.
Handler
type Handler<TRoute extends RouteDef, TContext, TPrincipal = unknown> = (
args: HandlerArgsOf<TRoute> & {
request: Request;
context: TContext;
principal: TPrincipal | undefined;
signal: AbortSignal;
},
) => Result<OutputOf<TRoute>, ErrorOf<TRoute>>
| ResultAsync<OutputOf<TRoute>, ErrorOf<TRoute>>
| Promise<Result<OutputOf<TRoute>, ErrorOf<TRoute>>>;Sync ok / err, ResultAsync, or Promise<Result> are all accepted. For success: 204, OutputOf is void — return ok(undefined) (or ok()). HTTP handlers receive request, the verified principal (when a policy is set), and a signal that combines request.signal with optional timeouts. For the same function on ./local, write a LocalHandler (no request) — it is assignable to Handler.
Handlers
type Handlers<TContract extends ContractDef, TContext, TPrincipal = unknown> = {
readonly [K in keyof TContract]: Handler<TContract[K], TContext, TPrincipal>;
};ServeOptions
interface ServeOptions<TPrincipal = unknown> {
readonly disclosure?: Disclosure | ((request: Request) => Disclosure);
readonly origin?: string;
readonly basePath?: `/${string}`;
readonly hostStatuses?: Partial<HostStatuses>;
readonly limits?: { readonly maxBodyBytes?: number; readonly bodyTimeoutMs?: number };
readonly timeouts?: { readonly defaultMs?: number; readonly operations?: { readonly [name: string]: number } };
readonly policy?: Policy<ContractDef, TPrincipal>;
readonly admission?: AdmissionOptions<TPrincipal>;
}Omitted disclosure defaults to public. respond still defaults to full. Passing both policy and disclosure throws. basePath is stripped before matching (no trailing slash). Domain statuses come from each route's errors map; host codes use HOST_STATUSES merged with hostStatuses. Boundary HTTP statuses are always declared and cannot be overridden. limits default to 1 MiB and 30 s. When retryAfterMs is set, serve sends Retry-After in whole seconds, rounded up.
ServeHandler
interface ServeHandler<TContext> {
(request: Request, context: TContext): Promise<Response>;
handle(
request: Request,
context: TContext,
): Promise<{ matched: false } | { matched: true; response: Response }>;
}Callable serve() always answers. handle() is the cooperative mount: matched: false only outside basePath or the contract path set. Wrong method on a known path is matched: true with route_not_found. Use the unmatched branch for sibling non-JSON handlers (uploads, SSE) — files and streams. Do not put those paths on the served contract.
serve
function serve<TContract extends ContractDef, TContext>(
contract: TContract,
handlers: Handlers<TContract, TContext>,
options?: ServeOptions,
): ServeHandler<TContext>;const handlers: Handlers<typeof contract, { requestId: string }> = {
getUser: ({ params }) => ok({ id: params.id, name: 'Ada' }),
createUser: ({ body }) => ok({ id: 'new', name: body.name }),
};
const handler = serve(contract, handlers, {
origin: 'users-api',
basePath: '/api',
disclosure: 'public',
});
await handler(new Request('http://localhost/api/users/u1'), { requestId: 'r1' });Construction: compileContract(contract) runs at serve() call time; invalid contracts (duplicate routes, reserved domain codes, invalid statuses) throw ContractConfigurationError.
Routing: exact paths and single :param segments, declaration order (static before dynamic when both match). Unmatched requests → route_not_found at HOST_STATUSES.route_not_found (or hostStatuses.route_not_found). Path captures are percent-decoded; invalid_encoding → validation_error. basePath is stripped before matching.
Input: route match and principal verification run before any body read. Each declared source is then read from the wire independently. Path :param values populate params before parseRouteSources. GET and DELETE read query into query (arrays as key[]=, unrepresentable shapes → validation_error before handler). POST, PUT, and PATCH read a bounded JSON body into body when declared (content-length over the limit → 413 with no bytes read; streamed overflow → 413; time limit → 408; fatal decode → validation_error); POST may also read query. Declared headers are read from request.headers. Empty or missing path params → validation_error. Sources never merge — params.id and body.id stay distinct.
Declared statuses per route: route.success ?? 200, each value in route.errors, every boundary HTTP status, validation_error when the route declares any input source (params, query, body, or headers), and internal. Undeclared mapped statuses degrade to 500. Handler error codes not declared on the route — including forged internal — are normalised to wire internal with the original error under cause; reserved boundary.* codes pass through. public disclosure shows a constant top-level message for host internal. success: 204 returns an empty body with no Content-Type. boundary.cancelled has no HTTP status and degrades to 500.
Origin: options.origin stamps errors when absent (recursive on cause). Existing origin on an error is preserved.
Disclosure: omitted disclosure defaults to public. Per-request functions are supported; a throwing callback falls back to public. Prefer principal-derived disclosure from definePolicy. Do not raise disclosure from a caller header.
Throws: handler exceptions and library escape paths become internal with diagnostics under cause — never an unhandled rejection. Ultimate fail-safe returns a constant JSON body.
Output validation: always on for routes with output. Successful handler values are validated through parseOutput; the parsed schema value is serialised. Handler Err results are not validated. Schemas must be transport-stable (see parseOutput). Failures become internal with a generic top-level message and diagnostic detail under cause.
Tests: src/server/serve.test.ts — scenarios from specs/cause-chaining.spec.md, specs/graded-disclosure.spec.md, and specs/server-output-validation.spec.md.
@eddy-works/never-rest/client
ClientOptions
interface ClientOptions {
readonly baseUrl: string;
readonly fetch?: typeof fetch;
readonly headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
readonly credentials?: RequestCredentials;
readonly timeoutMs?: number;
}
interface ClientCallOptions {
readonly signal?: AbortSignal;
readonly idempotencyKey?: string;
}When credentials is omitted, the underlying fetch implementation's default applies (typically same-origin in browsers). timeoutMs sets a default deadline combined with per-call signal. idempotencyKey is sent as Idempotency-Key.
Client
type Client<TContract extends ContractDef> = {
readonly [K in keyof TContract]: ClientMethod<TContract[K]>;
};
// Routes with input sources: (args, options?) => ResultAsync<…>
// Routes without: (args?, options?) => ResultAsync<…>One mapped type over the contract, one level deep — no recursion. Error type is ClientErrorOf (domain codes plus validation_error, internal, unavailable, and boundary.*).
createClient
function createClient<TContract extends ContractDef>(
contract: TContract,
options: ClientOptions,
): Client<TContract>;const client = createClient(contract, {
baseUrl: 'https://api.example.com',
fetch: customFetch, // optional
});
await client
.getUser({ params: { id: 'u1' } })
.andThen((user) => client.loadOrders({ params: { userId: user.id } }))
.map((orders) => orders.orders.length);Behaviour: compileContract runs at construction. Validates client args per source via parseRouteSources (ClientArgsOf); path params must be non-empty; query arrays use key[]=. Per-call args.headers merge over ClientOptions.headers (per-call wins). Only route.success ?? 200 is treated as Ok; any other 2xx is validation_error before the body is trusted. success: 204 resolves Ok(undefined) without reading a body. A valid envelope must agree with the HTTP status: a domain code matches route.errors[code], a boundary code matches its table status; mismatch → internal with the remote error under cause. No valid envelope (empty, HTML, foreign JSON) and status 401 / 403 / 408 / 413 / 429 / 503 / 504 → the matching boundary error; 502 → unavailable; any other status → internal. The raw body is never copied into message; ctx.httpStatus records the status. Retry-After is parsed as delta-seconds or HTTP-date and clamped. Abort → boundary.cancelled (no longer unavailable). Network failure → Err(unavailable, { retryable: true }). Header/body serialization failures → Err(internal). Never throws.
withRetry
function withRetry<T, E extends RailError>(
call: () => ResultAsync<T, E>,
options: {
readonly idempotent: boolean;
readonly attempts?: number;
readonly baseMs?: number;
readonly capMs?: number;
readonly signal?: AbortSignal;
},
): ResultAsync<T, E>;idempotent is required. Attempts default to 3 (max 10). Backoff is exponential with full jitter and honours retryAfterMs up to capMs. Retrying stops on abort.
buildRequest
interface BuiltRequest {
readonly url: string;
readonly init: RequestInit;
}
function buildRequest(
route: RouteDef,
compiledPath: CompiledPath,
baseUrl: string,
args: ClientArgsOf<RouteDef>,
headers: HeadersInit | undefined,
credentials?: RequestCredentials,
): Result<BuiltRequest, RailError<'validation_error' | 'internal'>>;Low-level fetch URL and RequestInit from declared sources. Used by createClient; exported for custom transports.
Tests: src/client/create.test.ts — scenarios from specs/client-results.spec.md and specs/wire-serialization.spec.md.
@eddy-works/never-rest/local
In-process dispatch for a contract. The contract still governs the call; nothing goes over HTTP. Use this when both sides live in the same process, or when a host already carries the operation as a string (NDJSON sockets, MCP stdio, agent tool calls) and only needs the railway, schemas, and disclosure.
No Request, Response, or JSON round-trip is constructed. Declared input and
output still validate through parseRouteSources /
parseOutput. There is no unavailable — there is no network.
This is a production transport. For an in-process client that exercises the real
HTTP path in tests, use createTestClient instead.
Per-route errors status maps are ignored here — HTTP status is not a local
concern. createLocalClient defaults to full because it is module-to-module.
createDispatcher defaults to public because it serves foreign transports.
Passing both policy and disclosure throws.
LocalOptions
interface LocalOptions<TContext, TPrincipal = unknown> {
readonly context?: TContext;
readonly origin?: string;
readonly disclosure?: Disclosure;
readonly policy?: Policy<ContractDef, TPrincipal>;
readonly admission?: AdmissionOptions<TPrincipal>;
}
interface LocalCallOptions<TContext, TPrincipal = unknown> {
readonly context?: TContext;
readonly principal?: TPrincipal;
readonly signal?: AbortSignal;
}origin is stamped onto any error that does not already carry one.
LocalHandler
type LocalHandler<TRoute extends RouteDef, TContext, TPrincipal = unknown> = (
args: HandlerArgsOf<TRoute> & {
readonly context: TContext;
readonly principal: TPrincipal | undefined;
readonly signal: AbortSignal;
},
) =>
| Result<OutputOf<TRoute>, ErrorOf<TRoute>>
| ResultAsync<OutputOf<TRoute>, ErrorOf<TRoute>>
| Promise<Result<OutputOf<TRoute>, ErrorOf<TRoute>>>;
type LocalHandlers<TContract extends ContractDef, TContext, TPrincipal = unknown> = {
readonly [K in keyof TContract]: LocalHandler<TContract[K], TContext, TPrincipal>;
};A Handler without request. LocalHandler is assignable to
Handler, so the same function mounts under serve or through local
dispatch with no change.
createLocalClient
function createLocalClient<TContract extends ContractDef, TContext = undefined>(
contract: TContract,
handlers: LocalHandlers<TContract, TContext>,
options?: LocalOptions<TContext>,
): LocalClient<TContract>;One typed method per operation: (args, { signal, principal }?). Each call
returns ResultAsync<OutputOf<Route>, LocalErrorOf<Route>> — declared domain
codes plus the host codes local dispatch can raise (validation_error,
internal, route_not_found, and boundary.*). There is no unavailable.
An abort returns boundary.cancelled.
import { createLocalClient } from '@eddy-works/never-rest/local';
const claims = createLocalClient(claimContract, claimHandlers, {
context: { agent: 'agent-9' },
origin: 'atc',
});
const result = await claims.getClaim({ params: { zone: 'core' } });createDispatcher
interface LocalDispatcher<TContract extends ContractDef, TContext, TPrincipal = unknown> {
readonly operations: readonly (keyof TContract & string)[];
dispatch(
operation: string,
args: unknown,
options?: LocalCallOptions<TContext, TPrincipal>,
): ResultAsync<unknown, RailError<string>>;
}
function createDispatcher<TContract extends ContractDef, TContext = undefined>(
contract: TContract,
handlers: LocalHandlers<TContract, TContext>,
options?: LocalOptions<TContext>,
): LocalDispatcher<TContract, TContext>;The same machinery addressed by operation name. operations lets a host
enumerate what it can serve. An operation outside the contract returns
route_not_found. dispatch takes args: unknown because the host decoded the
frame; validation still runs against the declared sources.
import { createDispatcher } from '@eddy-works/never-rest/local';
const dispatcher = createDispatcher(claimContract, claimHandlers, {
origin: 'atc',
});
// Inside an existing NDJSON socket loop:
const result = await dispatcher.dispatch(frame.method, frame.params, {
context,
principal,
signal,
});Both constructors throw ContractConfigurationError when an operation has no
handler. A handler that throws becomes an internal rail error with the thrown
message as its cause, so nothing escapes the railway.
LocalErrorOf / LocalHostErrorCode
type LocalHostErrorCode =
| 'validation_error'
| 'internal'
| 'route_not_found'
| BoundaryErrorCode;
type LocalErrorOf<TRoute extends RouteDef> =
| ErrorOf<TRoute>
| RailError<LocalHostErrorCode>;createLocalClient methods are typed with LocalErrorOf for that route.
dispatch widens to RailError<string> because the operation is a runtime
string. route_not_found appears on dispatch for an unknown operation name;
typed client methods cannot name an operation that is not on the contract.
Tests: src/local/dispatch.test.ts — scenarios from specs/local-dispatch.spec.md.
@eddy-works/never-rest/testing
Test-time helpers — not for production request paths.
createTestClient
interface CreateTestClientOptions<TContext, TPrincipal = unknown> {
readonly context?: TContext;
readonly baseUrl?: string;
readonly basePath?: `/${string}`;
readonly headers?: HeadersInit;
readonly disclosure?: Disclosure;
readonly policy?: Policy<ContractDef, TPrincipal>;
readonly admission?: AdmissionOptions<TPrincipal>;
readonly limits?: ServeLimits;
readonly timeouts?: ServeTimeouts;
}
function createTestClient<TContract extends ContractDef, TContext>(
contract: TContract,
handlers: Handlers<TContract, TContext>,
options?: CreateTestClientOptions<TContext>,
): Client<TContract>;Typed in-process client through the real serve path. Default baseUrl is http://never-rest.test. basePath is applied to both the handler and the client. This is not createLocalClient — that skips HTTP entirely.
import { createTestClient } from '@eddy-works/never-rest/testing';
const client = createTestClient(contract, handlers);
const result = await client.getUser({ params: { id: 'u1' } });assertProtocolResponse
Re-exported from ./testing and ./server. Asserts serve protocol invariants (status, JSON envelope, declared codes, disclosure) in tests.
checkTransportStability
function checkTransportStability<T extends StandardSchemaV1>(
schema: T,
sample: StandardSchemaV1.InferInput<T>,
): ResultAsync<void, RailError<'transport_unstable'>>;Verify a schema survives JSON wire round-trip: parse the sample, serialise, parse again, and compare values. Use in contract tests for every output schema (and any input schema with transforms you rely on).
import { checkTransportStability } from '@eddy-works/never-rest/testing';
const result = await checkTransportStability(userSchema, { id: 'u1', name: 'Ada' });
result.isOk(); // true when transport-stableFails for schemas whose transforms change the wire shape — for example z.number().transform(String).
checkContractOutputs
function checkContractOutputs<TContract extends ContractDef>(
contract: TContract,
samples: ContractOutputSamples<TContract>,
): ResultAsync<void, RailError<'transport_unstable'>>;Run checkTransportStability on every contract output. The samples object must include one value per operation that declares output — omitting a key is a type error. Routes with success: 204 (no output schema) are skipped.
import { checkContractOutputs } from '@eddy-works/never-rest/testing';
const result = await checkContractOutputs(contract, {
getUser: { id: 'u1', name: 'Ada' },
listUsers: [{ id: 'u1', name: 'Ada' }],
});@eddy-works/never-rest/node
Thin Node http / Express bridge. Not middleware, auth, or a framework adapter suite — only Request/Response ↔ IncomingMessage/ServerResponse.
FetchHandler
type FetchHandler = (request: Request) => Response | Promise<Response>;NodeHttpHandler
type NodeHttpHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void>;toNodeHandler
function toNodeHandler(
handler: FetchHandler,
options?: { readonly maxBodyBytes?: number; readonly bodyTimeoutMs?: number },
): NodeHttpHandler;import { createServer } from 'node:http';
import { serve } from '@eddy-works/never-rest/server';
import { toNodeHandler } from '@eddy-works/never-rest/node';
const handler = serve(contract, handlers, { origin: 'users-api' });
createServer(toNodeHandler((request) => handler(request, undefined))).listen(3000);
// Express
app.use(toNodeHandler((request) => handler(request, undefined)));Close over serve context when needed: toNodeHandler((req) => handler(req, ctx)).
Incoming bodies are streamed through a counting transform. Oversized Content-Length is rejected before the fetch handler runs. Close events abort Request.signal. Unread bodies are destroyed after the response. Register Express upload routes before this bridge if a sibling handler needs the live stream. Outgoing SSE/downloads can still pipe via writeWebResponse. See files and streams.
Tests: src/node/to-node-handler.test.ts.
@eddy-works/never-rest/openapi
OpenAPI 3.1 projection from the compiled contract. No statuses argument — domain statuses come from RouteDef.errors; host statuses from HOST_STATUSES.
toOpenAPI
function toOpenAPI<TContract extends ContractDef>(
contract: TContract,
options: {
readonly info: { readonly title: string; readonly version: string; readonly description?: string };
readonly servers?: readonly { readonly url: string; readonly description?: string }[];
readonly boundary?: { readonly auth?: boolean; readonly limits?: boolean };
},
): Record<string, unknown>;Compiles the contract, converts Standard Schema via ~standard.jsonSchema (draft 2020-12), maps :param paths to {param}, and emits a public RailError component plus RouteNotFound once per path. Routes with a body gain 408 and 413 responses. boundary.auth adds shared 401 / 403; boundary.limits adds 429 / 503 and documents Retry-After. Validators that cannot convert to JSON Schema (for example Valibot) throw OpenApiExportError — the exporter never invents {}.
import { toOpenAPI } from '@eddy-works/never-rest/openapi';
const document = toOpenAPI(contract, {
info: { title: 'Users API', version: '1.0.0' },
});OpenApiExportError
Thrown when a schema cannot be converted to JSON Schema, or when converted schema shape is unusable for parameters.
Tests: src/openapi/to-openapi.test.ts — scenarios from specs/openapi-export.spec.md.
@eddy-works/never-rest/query
Result-preserving cache-layer adapters. Structurally compatible with TanStack Query; no React or TanStack dependency. Each queryFn / mutationFn resolves with a Result and never rejects, so the railway survives the cache boundary. TanStack's isError will not fire for domain failures — branch on data.isOk() / data.isErr().
createQueryOptions
function createQueryOptions<TContract extends ContractDef>(
client: Client<TContract>,
): {
readonly [K in keyof TContract]: (args?: ClientArgsOf<TContract[K]>) => {
readonly queryKey: readonly unknown[];
readonly queryFn: () => Promise<Result<OutputOf<TContract[K]>, ClientErrorOf<TContract[K]>>>;
};
};Query key is ['never-rest', operation, args ?? {}].
createMutationOptions
function createMutationOptions<TContract extends ContractDef>(
client: Client<TContract>,
): {
readonly [K in keyof TContract]: () => {
readonly mutationFn: (
args?: ClientArgsOf<TContract[K]>,
) => Promise<Result<OutputOf<TContract[K]>, ClientErrorOf<TContract[K]>>>;
};
};isRetryable
function isRetryable(error: RailError): boolean;true only for retryable: true, unavailable, boundary.rate_limited, and boundary.overloaded. Never retries internal, authentication failures, validation errors, or cancellations.
Tests: src/query/options.test.ts.
@eddy-works/never-rest/policy
Construction-time operation policy. No node:* imports. Optional on serve and createDispatcher; when present it must cover every operation.
definePolicy
function definePolicy<TContract extends ContractDef, TPrincipal = unknown>(
contract: TContract,
options: {
readonly verify?: (request: Request) => Result<TPrincipal | undefined, RailError<string>> | ResultAsync<TPrincipal | undefined, RailError<string>>;
readonly operations: {
readonly [K in keyof TContract]:
| 'public'
| ((input: { principal: TPrincipal | undefined; args: HandlerArgsOf<TContract[K]> }) => PolicyDecision);
};
readonly disclosure?: (principal: TPrincipal | undefined) => Disclosure;
},
): Policy<TContract, TPrincipal>;Throws ContractConfigurationError for a missing operation, an unknown key, or an entry that is neither a function nor 'public'. HTTP verify reads headers only. Authorization runs after parse. A verifier that throws returns boundary.unauthenticated; an authorizer that throws returns boundary.forbidden. See advanced-usage.md.
Tests: src/policy/define.test.ts.
@eddy-works/never-rest/limits
Limiter and concurrency interfaces. No runtime dependencies and no node:*, so the subpath runs on Workers, Bun, and Deno. ./client never imports this module.
Limiter
interface Limiter {
consume(
key: string,
cost: number,
): ResultAsync<
{ kind: 'allowed' } | { kind: 'limited'; retryAfterMs: number },
RailError<'limiter_unavailable'>
>;
}createMemoryLimiter
function createMemoryLimiter(options: {
readonly points: number;
readonly durationMs: number;
readonly maxKeys: number;
}): Limiter;Single-process. Keys expire after durationMs. At maxKeys, new keys are limited. State resets on restart and is not shared between replicas.
fromRateLimiterFlexible
function fromRateLimiterFlexible(limiter: {
consume(key: string, points?: number): Promise<unknown>;
}): Limiter;Structural adapter — the package does not import rate-limiter-flexible. A rejection with msBeforeNext means limited; an Error rejection means the store failed.
createConcurrencyGate
function createConcurrencyGate(options: {
readonly maxInFlight: number;
readonly maxQueue: number;
readonly queueTimeoutMs: number;
}): {
acquire(
signal: AbortSignal,
cost?: number,
): ResultAsync<() => void, RailError<string>>;
};Calling release more than once is safe. Aborting removes a waiter from the queue.
Admission on serve / createDispatcher
admission: {
anonymous?: { limiter: Limiter; key: string | ((source) => string) };
principal?: { limiter: Limiter; key: (principal) => string };
cost?: (operation: string) => number;
concurrency?: ConcurrencyGate;
onStoreFailure: 'reject' | { fallback: Limiter };
}onStoreFailure is required. A store failure never silently allows unlimited work. never-rest does not parse X-Forwarded-For. See security-boundaries.md.
Tests: src/limits/memory.test.ts, src/limits/flexible.test.ts, src/limits/concurrency.test.ts.