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.

5 min readproxy mode

Import

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

Signature

createProxyCaller takes the name the proxy addresses the source by, and defaults — optional for an API of plain requests, required with a boundary (or the source) for one with streams or channels — and returns a typed client. The type argument is your typeof api: it gives the client its groups, methods, variables, and response types.

ts
import type { InferClient, ProxyCallerArguments } from "@noverax/courier"

declare function createProxyCaller<TApi>(
  source: string,
  ...defaults: ProxyCallerArguments<TApi>
): InferClient<TApi>

Parameters

ProxyCallerDefaults

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 ProxyCallerDefaults = {
  proxyPath?: ProxyPath
  source?: Record<string, unknown>
  boundary?: ProxyBoundary
  timeout?: number
  deduplicate?: 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 — per-transport proxy endpoint configuration. Each field accepts either a relative path (resolved against location.origin) or an absolute URL. Caller-level fields are merged per-transport over source.options.proxyPath from defineApi.
  • source — the result of defineApi(), or boundary — the result of defineBoundary(), which keeps the definition out of the browser bundle. One of them is required when the API has streams or channels: the type argument names each endpoint's transport, but only a runtime value tells the client which endpoints to open as a stream or a socket, so omitting both does not compile. The caller reads proxyPath from whichever is given as the source-level defaults.
  • deduplicate — cancel a call still in flight when the same endpoint is called again. Defaults to the source's own setting when source is given.
  • 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<typeof api> — a typed object with dot-notation access to your API groups and endpoints:

  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.
ts
import type { InferClient } from "@noverax/courier"
import type { api } from "@/api"

type Client = InferClient<typeof api>

Basic Usage

lib/query.ts
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api"
import { boundary } from "@/api/boundary"

const caller = createProxyCaller<typeof api>("api", {
  boundary,
  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 Defaults

lib/query.ts
const caller = createProxyCaller<typeof api>("api", {
  boundary,

  // Per-transport proxy endpoint paths.
  // Either side may be partial — missing transports fall back to
  // source.options.proxyPath, then to built-in defaults.
  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 — an endpoint built from a reading verb, or declared .reads() — 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. A write goes as POST, its variables in the body. A client that knows neither — built without source or boundary — sends every call as POST.

ts
// 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 goes as POST instead 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; the proxy refuses it over GET:

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

A client built from a boundary cannot see the definition, so the boundary lists those reads under sensitive. 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.