Skip to content

API ReferencecreateProxyCaller

createProxyCaller

Factory function that creates a typed client with dot-notation access for proxy-mode HTTP calls from the browser.

6 min readUpdated proxy mode

Import

ts
import { createProxyCaller } from "@noverax/courier"

Signature

createProxyCaller takes the routes of an API — the endpoints a page may call and how it reaches each, as Courier writes them into courier.routes.ts from the endpoints marked .expose() — and options, and returns a typed client. The routes carry the type of the API they were written from, so the client has its groups, methods, variables and response types, for the endpoints the routes name alone.

ts
import type { InferClient, ProxyCallerOptions } from "@noverax/courier"
import type { CourierRoutes, RoutedApi, RouteShape } from "@noverax/courier/define"

declare function createProxyCaller<TApi, TEndpoints extends Readonly<Record<string, RouteShape>>>(
  routes: CourierRoutes<TApi, TEndpoints>,
  options?: ProxyCallerOptions,
): InferClient<RoutedApi<TApi, TEndpoints>>

Parameters

routes

The routes of one API, as the routes module exports them — apiRoutes for the source registered as api. See Exposing Endpoints for how Courier writes them, and how to write them by hand in a project without a courier.config.ts.

ts
interface CourierRoutes<TApi, TEndpoints> {
  source: string                    // the name the server registered the API under
  endpoints: TEndpoints             // each endpoint a page may call, by its path, and how it is reached
  cancelPrevious?: readonly string[] // the API's own cancelPrevious
  coalesce?: boolean                // the API's own coalesce
}

type RouteShape =
  | { type: "query"; get?: true }  // a read; GET when it may be cached
  | { type: "mutation" }            // a write
  | { transport: "sse" }            // a server-sent event stream
  | { transport: "ws"; bidirectional?: true } // a socket; one the caller also sends on

ProxyCallerOptions

All fields are optional.

ts
type ProxyPath = {
  http?: string  // default: "/api/request"
  sse?: string   // default: "/api/sse"
  ws?: string    // no default — required for WS endpoints
}

type ProxyCallerOptions = {
  proxyPath?: ProxyPath
  timeout?: number
  cancelPrevious?: readonly string[]
  coalesce?: boolean
  credentials?: RequestCredentials
  headers?: Record<string, string>
  onError?: (error: HTTPError) => void | Promise<void>
  onSuccess?: (data: unknown) => void | Promise<void>
  ws?: CallerWSOptions
}

type CallerWSOptions = {
  auth?: WSAuthParams | (() => WSAuthParams | undefined | Promise<WSAuthParams | undefined>)
  backoff?: { initialMs?: number; maxMs?: number; factor?: number; jitter?: boolean }
  heartbeat?: { intervalMs?: number; timeoutMs?: number }
  messageQueue?: { maxCount?: number; maxBytes?: number }
  protocols?: string[]
  maxMessageSize?: number
}

type WSAuthParams = Record<string, unknown>
  • proxyPath — where the app serves its proxy routes, per transport. Each field accepts either a relative path, resolved against the page's origin, or an absolute URL; a missing one falls back to the built-in default. The routes do not say it: it depends on where the page is served.
  • cancelPrevious — the reads a newer call cancels while they are still in flight, by their paths, such as ["users.search"]; the cancelled call rejects with an HTTPError of status 499. Only a read is ever cancelled: an endpoint the routes name a write is left to complete. Defaults to the API's own, which the routes carry — see Deduplication & Abort.
  • coalesce — whether identical reads in flight together reach the proxy once, each call with a copy of the answer. On by default; false sends every call alone. Defaults to the API's own, which the routes carry.
  • timeout — how long the browser waits for the proxy, in milliseconds. It is not sent to the server, so the endpoint's own .timeout() still bounds the upstream call; a timeout given with .with() for one call is sent.
  • credentials — whether the browser sends its cookies to the proxy route, with every call and stream. Defaults to "include". The proxy forwards them to the backend only where the source or the endpoint names cookie — see Cookies.
  • headers — default headers merged into every request and stream, and sent in the first frame of every socket its channels open, which the proxy reads as the socket's own — a browser cannot put them on a WebSocket's handshake.
  • onError — global error handler invoked on every HTTPError.
  • onSuccess — global success handler invoked on every successful response.
  • ws — options of the sockets this caller's channels and push subscriptions open; see Connection options.

WS has no default path

proxyPath.ws has no built-in default. Calling a WS endpoint without it throws ConfigurationError("WS_PATH_NOT_CONFIGURED", ...). In runtimes that cannot host WebSocket upgrades in-process (Next.js App Router, Cloudflare Workers, Vercel Edge, AWS Lambda), point this at a running @noverax/courier-server instance.

Return Type

Returns InferClient<RoutedApi<TApi, TEndpoints>> — the client of the API with the endpoints the routes name alone, a group they name nothing in left out:

  1. Group access — caller.users — accesses the endpoint group.
  2. Endpoint execution — caller.users.getById({ id: "123" }) — executes an endpoint with its variables, its only argument; an endpoint without variables takes none.
  3. Per-call options — caller.users.with({ timeout: 5_000 }).getAll() — headers, a timeout or a signal for the calls made through it; .raw resolves to the whole response instead of its body.

An endpoint the routes do not name is not on the type. Called anyway — from JavaScript, or past a cast — the client throws ConfigurationError("ENDPOINT_NOT_IN_ROUTES", ...) before anything is sent, since the proxy would refuse it.

Basic Usage

lib/caller.ts
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"

const caller = createProxyCaller(apiRoutes, {
  timeout: 10000,
  credentials: "include",
})

// Dot-notation: caller.group.method(variables)
const users = await caller.users.getAll()
const user = await caller.users.getById({ id: "123" })

Configuring Options

lib/caller.ts
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"

const caller = createProxyCaller(apiRoutes, {
  // Per-transport proxy endpoint paths.
  // A missing transport falls back to its built-in default.
  proxyPath: {
    http: "/api/courier",                         // override default /api/request
    sse: "/api/sse",                              // default
    ws: "wss://realtime.example.com",             // required for WS endpoints
  },

  // Request timeout in milliseconds
  timeout: 15000,

  // Cookie handling (default: "include")
  credentials: "include",

  // Default headers for every request
  headers: {
    "X-App-Version": "1.0.0",
    "Accept-Language": "en",
  },

  // Global error handler
  onError: (error) => {
    if (error.status === 401) {
      window.location.href = "/login"
    }
  },

  // Global success handler
  onSuccess: (data) => {
    console.log("Request succeeded:", data)
  },
})

Per-Request Options

Name CallOptions with .with(), then call the endpoint:

ts
const user = await caller.users
  .with({
    timeout: 5000,
    signal: controller.signal,
    method: "POST", // Keep this call's variables out of the address
  })
  .getById({ id: "123" })

Upload Progress

Pass onUploadProgress to track file upload progress. When set, Courier uses XMLHttpRequest for real network-level progress tracking.

ts
const result = await caller.files
  .with({
    onUploadProgress: (progress) => {
      console.log(`${Math.round(progress.percent * 100)}%`)
    },
  })
  .upload(formData)

XHR Transport

When onUploadProgress is set, Courier uses XMLHttpRequest instead of fetch to provide real network-level progress tracking on both HTTP/1.1 and HTTP/2.

Files go in a FormData. Any other variables travel to the proxy as JSON, so a call whose variables hold a Blob, an ArrayBuffer, a typed array or a stream is refused before it is sent, with ConfigurationError("PROXY_VARIABLE_NOT_SERIALIZABLE", ...) naming the value — see the upload guide.

The progress covers the browser's upload to the proxy route. The proxy then forwards the whole file to your backend, so the call resolves after progress has reached 100% — see progress in proxy mode.

GET or POST

A read that may be cached — one that declares .cache() and whose variables are not sensitive, which the routes mark get: true — goes to the proxy as GET, its variables as JSON in the _v parameter of the address, so a browser or a CDN can reuse the answer its .cache() allows. Every other call goes as POST, its variables in the body: GET would gain a read nothing that nothing on the way may keep, and POST keeps the variables of every other read out of the address.

ts
// search declares .cache({ revalidate: 60 }):
// GET /api/request?_s=api&_g=users&_m=search&_v={"q":"ada"}
const found = await caller.users.search({ q: "ada" })

// POST /api/request?_s=api&_g=users&_m=create, body {"variables":{"name":"Jane"}}
const user = await caller.users.create({ name: "Jane" })

A read that may be cached goes as POST all the same when its variables carry a body — form data, an upload whose progress is watched — or would make the address longer than 2 KB, which some servers and CDNs refuse. An address is written down by every access log it passes, so a read whose variables must not be — an email address, a search someone typed — declares sensitiveVariables, and always goes as POST, whatever its .cache(); the proxy refuses it over GET:

ts
byEmail: endpoint.get("customers", { sensitiveVariables: true }).variables<{ email: string }>()

A call can still ask for either verb with .with({ method: "GET" }) or .with({ method: "POST" }); a write asked for over GET is refused 405, and so is a read whose variables are sensitive.