REST Client
Your backend already describes its API once, as a Contract — and that same class is the client. Give
new SomeApi(transport) a transport and it hands you one typed caller per route under .client, each
returning the route's 2xx body inferred straight from the schemas (and throwing on a non-2xx).
Change the contract, and TypeScript lights up every call site that no longer matches. No code generation, no second source of truth, no drift between client and server.
The contract is frontend-safe: it imports only @standard-schema/spec (boundary validation) — no server
code, no compiler. The transports live in @heximon/client, also frontend-safe. You can ship both to
the browser.
Install the package
pnpm add @heximon/client @heximon/contract
npm install @heximon/client @heximon/contract
yarn add @heximon/client @heximon/contract
bun add @heximon/client @heximon/contract
The main entry has no peer dependencies. The optional ./vue-query subpath needs @tanstack/vue-query and
vue — install those only when you import the bindings.
Build a client
Construct the contract with a transport — the same Contract class your controller binds to — and it folds
the prefix into each route, then assembles one caller per route key under .client. Each caller's argument
is that route's request payload; each result is its 2xx body.
import { ClientTransport } from "@heximon/client";
import { ProductsApi } from "../server/products/product.api"; // a shared @heximon/contract class
const api = new ProductsApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
const product = await api.client.find({ params: { sku: "abc-123" } });
console.log(product.name); // body narrowed to the 2xx schema's output type — throws on a non-2xx
await api.client.create({ body: { sku: "new-1", name: "Widget", priceInCents: 999 } });
// mutation: JSON body + content-type set automatically
The caller returns the 2xx body and throws a ContractError on any non-2xx or network failure, so
the happy path reads straight. params / query / body appear in the argument only when the route
declares them — and the whole argument is optional when every field is. That's the contract paying off: the
client's surface is derived, not maintained.
The path-parameters key accepts both params and pathParams — they are aliases, and either name works.
pathParams aligns with the descriptor field name (route.pathParams); params is the shorter form. When
both are present, pathParams takes precedence.
// Both resolve to the same URL — choose whichever reads more clearly:
await api.client.find({ params: { sku: "abc-123" } });
await api.client.find({ pathParams: { sku: "abc-123" } });
When you need every status (not just the 2xx body), reach for api.raw("find", …) — covered below.
baseUrl. The client folds contract.prefix into each route path once at
assembly, then concatenates baseUrl with a single de-duplicated slash. So a contract with
prefix: "/api/products" already produces https://api.example.com/api/products/:sku from a bare host.Handle every status with raw()
The .client callers return only the 2xx body and throw otherwise. When you need to branch on every
status the route declares — read a 404 body, inspect a 409 — call api.raw(key, args). It never
throws and returns the full { status, body, headers } discriminated union, narrowed by status:
import { ClientTransport } from "@heximon/client";
import { ProductsApi } from "../server/products/product.api";
const api = new ProductsApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
const result = await api.raw("find", { params: { sku: "abc-123" } });
if (result.status === 200) {
console.log(result.body.name); // body narrowed to the 200 schema's output type
} else if (result.status === 404) {
// result.body is the declared 404 shape — handle the miss without a try/catch
}
A link is api.url("find", { sku: "abc-123" }), and api.scoped({ headers }) returns a fresh instance
with merged options. api.url and api.raw share the same caller args as .client, so a contract drives
its own routes three ways from one class.
Add authentication
A request interceptor (registered with api.on("request", …)) sees the assembled request before it goes
out — the natural place to attach a token. It may mutate the request in place or return a replacement. A
static base header that's the same on every call can instead ride on the transport's headers option:
import { ClientTransport } from "@heximon/client";
import { ProductsApi } from "../server/products/product.api";
const api = new ProductsApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }), {
headers: { "x-app": "storefront" }, // base headers merged into every request
});
api.on("request", (request) => {
request.headers["authorization"] = "Bearer token";
return request;
});
api.on(...) returns the instance, so it chains; api.scoped({ headers: { ... } }) returns a fresh
instance with the extra header merged in, carrying the interceptors over.
Validate responses
Response validation is always on and runs for every transport: each JSON body is checked against the
route's response schema for the returned status, so a server that drifts from the contract is caught at the
client boundary, not three screens later in your UI. A mismatch throws a ContractError carrying the
offending status and body.
import { ClientTransport } from "@heximon/client";
import { ContractError } from "@heximon/contract";
import { ProductsApi } from "../server/products/product.api";
const api = new ProductsApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
try {
await api.client.find({ params: { sku: "abc-123" } });
} catch (error) {
if (error instanceof ContractError) {
console.error(error.status, error.body); // a non-2xx, or a body that doesn't match the contract
}
}
Validation is synchronous, because a response schema is checked inline as the body decodes — an async response schema throws rather than silently skipping the check.
Download and text responses
A route whose 2xx body is binary or text declares its wire type on the contract — .download() (→
application/octet-stream), .html() (→ text/html), or .responseContentType(type). The client then
decodes the success body to a Blob (binary) or string (text/*) and skips response-schema validation
for it: a 2xx schema declared on such a route is informational only (OpenAPI), never
run.
(Declared error statuses are unaffected — they stay JSON and are still validated.)
import { ClientTransport } from "@heximon/client";
import { UploadsApi } from "../server/uploads/upload.api"; // download: Route.get("/:key").download()...
const api = new UploadsApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
const file: Blob = await api.client.download({ params: { key: "report-2026" } });
This fixes the old failure where validating a Blob against a JSON response schema always threw on a
download route.
Call the app in-process
For tests and server-to-server calls, ClientTransport.internal dispatches through the live app with no network
hop. The host (for example @heximon/nitro) supplies a localFetch, and the transport bridges it
into the contract client:
import { ClientTransport, type LocalFetch } from "@heximon/client";
import { ProductsApi } from "../server/products/product.api";
declare const localFetch: LocalFetch; // host-supplied: (input, init) => Promise<Response>
const api = new ProductsApi(ClientTransport.internal(localFetch));
const product = await api.client.find({ params: { sku: "abc-123" } });
Because interceptors live in the contract client (not the transport), an api.on("request", …) hook runs
regardless of transport — your in-process and network calls share the same request decoration. For a
unit test with no host at all, ClientTransport.mock returns canned responses keyed by "METHOD /path":
import { ClientTransport } from "@heximon/client";
import { ProductsApi } from "../server/products/product.api";
const api = new ProductsApi(ClientTransport.mock({
"GET /api/products/abc-123": { status: 200, headers: {}, body: { sku: "abc-123", name: "Widget", priceInCents: 999 } },
}));
const product = await api.client.find({ params: { sku: "abc-123" } });
Use it with Vue Query
The optional ./vue-query subpath builds a per-route binding tree from the same contract: query routes (GET /
HEAD) expose useQuery / useInfiniteQuery, and mutation routes expose useMutation. The query keys, fetch
args, and result shapes are all typed from the contract — you write Vue components against routes, not URLs.
import { ClientTransport } from "@heximon/client";
import { VueQuery } from "@heximon/client/vue-query";
import { ProductsApi } from "../server/products/product.api";
// Pass the class + a transport (VueQuery.initClient constructs the instance):
const productsQuery = VueQuery.initClient(ProductsApi, ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
// Or pass an already-constructed instance:
const api = new ProductsApi(ClientTransport.fetch({ baseUrl: "https://api.example.com" }));
const productsQuery2 = VueQuery.initClient(api);
// a query route
const found = productsQuery.find.useQuery(["product", "abc-123"], () => ({ params: { sku: "abc-123" } }), {
enabled: true,
});
if (found.data.value?.status === 200) {
const name: string = found.data.value.body.name;
}
// a mutation route
const create = productsQuery.create.useMutation();
create.mutate({ body: { sku: "new-1", name: "Widget", priceInCents: 999 } });
Real-time: typed SSE and WebSocket clients
ClientTransport and .client cover request/response REST calls.
A ServerSentEvents broadcast feed or a WebSocketContract connection is a different shape — a long-lived
stream instead of one call — so those get their own typed clients: @heximon/client/sse's SseClient and
@heximon/client/ws's WsClient, each typed off the same contract/event-map class your server declares.
See Typed real-time clients for both.
See also
- Contracts — author the
Contractclass both the client and the server bind to, with theRoutebuilder and sharedresponses. - Controllers — serve that same contract from a controller that
implements Controller<SomeApi>on the backend, so request and response are typed end to end. - Validation & DTOs — the response schemas the client checks on every call are your DTOs, any Standard Schema (valibot, Zod, …).
- OpenAPI — document the same contract as an OpenAPI 3.1 spec for consumers that aren't on TypeScript.
Contracts
Describe an HTTP API once with the Contract class and Route builder, then bind the server, the REST client, and OpenAPI to the same route table — Route.get/post, body, responses, pathParams, scopes, shared responses, contract-mode controllers.
Database
Wire a real SQLite database into a CRUD API with no install and no compiler plugin — the unified DrizzleLibSQLConfig, a named database DI token, a repository over getOrm(), and boot-time table creation.