Overview
Every API call in Courier follows a well-defined lifecycle. There are two transport modes — Direct and Proxy — but both converge on the same internal executeRequest function. Understanding this flow helps you debug issues, write middleware, and choose the right mode for your use case.
Two Modes
| Factory | Mode | Runtime | Use Case |
|---|---|---|---|
createCaller(api) | Direct | Any (Node.js, Bun, Deno, browser) | Server Components, Server Actions, scripts, CLI |
createProxyCaller<typeof api>("api") | Proxy | Browser | Client Components — hides backend URL, handles CORS, carries the cookies you name |
Request Flow
Direct: createCaller(api) → caller.users.getAll() → executeRequest → middleware → HttpClient → fetch → backend
Proxy: createProxyCaller("api") → caller.users.getAll() → GET /api/request → handler → executeRequest → middleware → HttpClient → fetch → backendIn direct mode, the caller invokes executeRequest directly with the api object. In proxy mode, the browser sends the request payload to your server's proxy route (e.g., /api/request), which then calls executeRequest on the server side.
Setting Up Callers
Direct mode — server-side direct mode
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"
export const caller = createCaller(api)// Works anywhere with access to the api object
const users = await caller.users.getAll()
// ^? User[]
const user = await caller.users.getById({ id: "1" })
// ^? UserWhen to use direct mode
Use direct mode whenever you have access to the api object: Server Components, Server Actions, API routes, CLI scripts, cron jobs, or any non-browser runtime.
Proxy mode — browser-side proxy mode
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api/sources"
export const caller = createProxyCaller<typeof api>("api")// Browser sends GET /api/request → your server → backend
const users = await caller.users.getAll()Type-only import
Proxy mode imports type { api } — only the type information crosses to the client. The actual api object (with your backend URL and secrets) stays on the server.
The Dot-Notation API
Both createCaller and createProxyCaller return a typed client with dot-notation access to your API groups and endpoints:
// Access endpoints via group.method notation
const users = await caller.users.getAll()
const user = await caller.users.getById({ id: "1" })TypeScript validates group and method names at compile time:
caller.users.getAll() // group: "users", method: "getAll"
caller.users.getById({ id: "1" }) // group: "users", method: "getById"
caller.posts.create(vars) // group: "posts", method: "create"Call Options
Each endpoint supports runtime options that control how the request is executed:
// Raw response — returns { status, headers, data } instead of just data
const response = await caller.users.raw.getAll()
// ^? { status: number; headers: Headers; data: User[] }
// Stream mode — the body arrives as a ReadableStream instead of parsed
const body = await caller.events.with({ stream: true }).subscribe({ channel: "builds" })
// A read goes to the proxy as GET; POST keeps this call's variables out of the address
const users = await caller.users.with({ method: "POST" }).getAll()
// Upload with progress tracking
await caller.files
.with({
onUploadProgress: ({ percent }) => console.log(`${Math.round(percent * 100)}%`),
})
.upload(formData)
// Abort signal
const controller = new AbortController()
await caller.users.with({ signal: controller.signal }).getAll()
// Custom headers and timeout per call
await caller.users
.with({
headers: { "X-Request-Id": crypto.randomUUID() },
timeout: 5000,
})
.getAll()CallOptions Reference
| Option | Type | Description |
|---|---|---|
stream | boolean | Return the unread body, a ReadableStream, for streaming |
method | "GET" | "POST" | Proxy HTTP method (GET for cacheable reads) |
signal | AbortSignal | Cancel the request |
timeout | number | Request timeout in ms |
headers | HeadersInit | Additional headers for this call |
onUploadProgress | (progress) => void | Upload progress callback (XHR in the browser) |
onDownloadProgress | (progress) => void | Download progress callback |
forwardVariables | boolean | Pass variables to the HTTP method (default: true) |
The status and headers of an answer are not an option: call through the group's .raw accessor, caller.users.raw.getAll().
Internal Lifecycle
When executeRequest receives a request payload, it goes through these steps:
- Resolve source — looks up the source object in the
sourcesmap - Resolve endpoint — finds the group and endpoint function by parsing the
"group:method"key - Validate variables — if the endpoint has a schema attached (via
.variables(schema)), validates the input - Run middleware chain — executes middleware in array order, each wrapping the next
- Execute endpoint — creates
HttpClient, calls the endpoint function withhttpmethods and variables - Validate response — if the endpoint has a response schema, validates the output
- Build response — formats the result based on context (proxy Response vs direct data)
executeRequest
├─ resolve source & endpoint
├─ validate variables (if schema)
├─ middleware[0]
│ └─ middleware[1]
│ └─ middleware[n]
│ └─ via(http, vars) → HttpClient.fetch → backend
├─ validate response (if schema)
└─ return data (or Response for proxy)Context (Direct Mode)
In direct mode, a caller built with a Context forwards the incoming request's headers (useful in Server Components and API routes). A context belongs to one incoming request, so the caller is built for that request:
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"
// In a Server Component or API route
export async function GET(request: Request): Promise<Response> {
const caller = createCaller(api, { context: { req: request } })
const user = await caller.users.getById({ id: "1" })
return Response.json(user)
}The context object accepts:
| Property | Type | Description |
|---|---|---|
req | Request | Incoming request (for header forwarding) |
res | Response | When set, executeRequest returns a Response object |
headers | Headers | Additional headers to merge |
Next Steps
- Caller & Proxy — detailed options for both caller factories
- HTTP Client & Methods — how endpoint functions use HTTP methods
- Middleware — add retry, auth, logging to the request lifecycle