Skip to content

Core ConceptsRequest Lifecycle

Request Lifecycle

How requests flow through callers, middleware, and the HTTP client in direct and proxy modes.

4 min readboth modes

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

FactoryModeRuntimeUse Case
createCaller(api)DirectAny (Node.js, Bun, Deno, browser)Server Components, Server Actions, scripts, CLI
createProxyCaller<typeof api>("api")ProxyBrowserClient 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 → backend

In 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

lib/caller.ts
import { createCaller } from "@noverax/courier"
import { api } from "@/api/sources"

export const caller = createCaller(api)
ts
// Works anywhere with access to the api object
const users = await caller.users.getAll()
//    ^? User[]

const user = await caller.users.getById({ id: "1" })
//    ^? User

When 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

lib/caller.ts
import { createProxyCaller } from "@noverax/courier"
import type { api } from "@/api/sources"

export const caller = createProxyCaller<typeof api>("api")
ts
// 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:

ts
// 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:

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

ts
// 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

OptionTypeDescription
streambooleanReturn the unread body, a ReadableStream, for streaming
method"GET" | "POST"Proxy HTTP method (GET for cacheable reads)
signalAbortSignalCancel the request
timeoutnumberRequest timeout in ms
headersHeadersInitAdditional headers for this call
onUploadProgress(progress) => voidUpload progress callback (XHR in the browser)
onDownloadProgress(progress) => voidDownload progress callback
forwardVariablesbooleanPass 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:

  1. Resolve source — looks up the source object in the sources map
  2. Resolve endpoint — finds the group and endpoint function by parsing the "group:method" key
  3. Validate variables — if the endpoint has a schema attached (via .variables(schema)), validates the input
  4. Run middleware chain — executes middleware in array order, each wrapping the next
  5. Execute endpoint — creates HttpClient, calls the endpoint function with http methods and variables
  6. Validate response — if the endpoint has a response schema, validates the output
  7. 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:

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

PropertyTypeDescription
reqRequestIncoming request (for header forwarding)
resResponseWhen set, executeRequest returns a Response object
headersHeadersAdditional headers to merge

Next Steps