Client SDK#

Type-safe RPC client for calling your API from the frontend or other services. Define route contracts once, get full type inference everywhere.

Imports#

typescript
import { createClient } from "@buntok/core/client";
import type { RouteContract } from "@buntok/core/client";

Define Contracts#

Each route is declared as a RouteContract<Params, Query, Body, Response>:

typescript
const routes = {
  getUser: {
    method: "GET",
    path: "/users/:id",
  } as RouteContract<
    { id: string },              // params
    undefined,                   // query
    undefined,                   // body
    { id: string; name: string } // response
  >,

  listUsers: {
    method: "GET",
    path: "/users",
  } as RouteContract<
    undefined,
    { page?: number; limit?: number },  // query
    undefined,
    { data: { id: string; name: string }[]; total: number }
  >,

  createUser: {
    method: "POST",
    path: "/users",
  } as RouteContract<
    undefined,
    undefined,
    { name: string; email: string },  // body
    { id: string; name: string }
  >,
};

Create Client#

typescript
const api = createClient(routes, "http://localhost:1212");

// Fully typed — params, body, and return type are all inferred
const user = await api.getUser({ params: { id: "1" } });
// user: { id: string; name: string }

const list = await api.listUsers({ query: { page: 2, limit: 10 } });
// list: { data: { id: string; name: string }[]; total: number }

const created = await api.createUser({
  body: { name: "Tok", email: "tok@example.com" },
});
// created: { id: string; name: string }

Options#

OptionTypeDefaultDescription
headersRecord<string, string>Headers sent on every request
timeoutnumber30000Request timeout in ms
retriesnumber0Number of retry attempts
retryDelaynumber1000Delay between retries in ms
retryOnnumber[][408, 429, 500, 502, 503, 504]Status codes to retry on
onRequest(req) => RequestRequest interceptor
onResponse(res) => ResponseResponse interceptor
fetchtypeof fetchglobalThis.fetchOverride fetch (useful for testing)

ClientError#

Typed error thrown when a request fails (non-2xx status or network error):

typescript
import { ClientError } from "@buntok/core/client";

try {
  await api.getUser({ params: { id: "999" } });
} catch (err) {
  if (err instanceof ClientError) {
    console.log(err.status);  // 404
    console.log(err.method);  // "GET"
    console.log(err.path);    // "/users/999"
    console.log(err.body);    // response body (string or null)
    console.log(err.message); // "GET /users/999 failed with status 404"
  }
}

How It Works#

  1. createClient() iterates over contract keys and creates typed functions
  2. Each function:
    • Substitutes :params in the path (e.g. /users/:id/users/1)
    • Appends ?query params to URL
    • Serializes body as JSON
    • Adds Content-Type: application/json header if body present
    • Applies onRequest interceptor before fetch
    • Applies onResponse interceptor after fetch
    • Retries on failure if retries > 0 and status matches retryOn
    • Parses response as JSON or text based on Content-Type
    • Throws ClientError on non-2xx if not retryable

RouteContract Type#

typescript
interface RouteContract<
  TParams = undefined,   // URL params (e.g. { id: string })
  TQuery = undefined,    // Query params (e.g. { page?: number })
  TBody = undefined,     // Request body (e.g. { name: string })
  TResponse = unknown,   // Response type
> {
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
  path: string;          // e.g. "/users/:id"
  params?: TParams;      // type-only, never read at runtime
  query?: TQuery;
  body?: TBody;
  response?: TResponse;
}
Info
The RouteContract type is type-only — params, query, body, and response fields are never read at runtime. They only exist for TypeScript inference.