Heximon Logo
Essentials

REST Client

Call a shared Contract from the frontend by constructing it with a transport — ClientTransport.fetch, ClientTransport.internal, and ClientTransport.mock, throwing 2xx-body callers, raw() for the full status union, request/response interceptors, and vue-query bindings.

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

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.

products.client.ts
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.

Don't repeat the prefix in 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:

products.client.ts
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:

client.ts
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.

client.ts
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.)

download.ts
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:

in-process.ts
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":

test.ts
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.

products.vue-query.ts
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 Contract class both the client and the server bind to, with the Route builder and shared responses.
  • 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.
Copyright © 2026