Skip to content

Proxy ModeServer Handler

Server Handler

The universal server-side handler that receives proxy requests and forwards them to your backend — works with any framework.

6 min readproxy mode

Overview

createHandler creates a universal request handler that acts as the server-side half of proxy mode. It receives requests from createProxyCaller, validates them, and forwards them to your backend via executeRequest.

typescript
import { createHandler } from "@noverax/courier"

const { handle, handleGet, handlePost } = createHandler({ sources })
// handle:     (request: Request) => Promise<Response>  — auto-routes GET/POST
// handleGet:  (request: Request) => Promise<Response>  — query endpoints
// handlePost: (request: Request) => Promise<Response>  — mutations + data

The handler works with Web Standard Request/Response objects, making it compatible with any JavaScript runtime and framework.

Setup

Step 1: Define your sources

The sources object maps source names to API definitions created with defineApi:

api/index.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

type User = { id: number; name: string; email: string }
type Post = { id: number; title: string; body: string }

const users = defineGroup({
  prefix: "users",
  endpoints: {
    getAll: endpoint.get("").response<User[]>(),
    getById: endpoint.get(":id").variables<{ id: number }>().response<User>(),
  },
})

const posts = defineGroup({
  prefix: "posts",
  endpoints: {
    list: endpoint.get("").response<Post[]>(),
    create: endpoint.post("").variables<{ title: string }>().response<Post>(),
  },
})

const api = defineApi({
  baseURL: process.env.API_URL ?? "http://localhost:4000",
  groups: { users, posts },
})

export const sources = { api }
export type Sources = typeof sources

Step 2: Create the server handler

Choose the adapter that matches your framework:

// instrumentation.ts
import { registerSources } from "@noverax/courier"
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { sources } = await import("./api")
    registerSources(sources)
  }
}

// app/api/request/route.ts
import { apiRoute } from "@noverax/courier-next"
export const { GET, POST } = apiRoute()

All adapters use createHandler

Every framework adapter is a thin wrapper around createHandler() from @noverax/courier. Sources are read lazily from the core global registry populated by registerSources() (or by attachRuntime() auto-loading courier.config.ts). If your framework is not listed, use createHandler directly with Web Standard Request/Response.

How it works

All adapters delegate to createHandler(), which returns three methods:

  • handle(request) — auto-routes GET and POST, returns 405 for other methods
  • handleGet(request) — checks the origin, parses query parameters (_s, _g, _m + variables) for cacheable read endpoints
  • handlePost(request) — checks the origin, parses JSON or FormData body for mutations

Origin validation

Every call the proxy serves — GET and POST here, and the SSE and WebSocket routes too — is checked for where it comes from, so a page on another site cannot drive the proxy with the user's cookies:

  • Sec-Fetch-Site decides when the browser sends it, and a current browser sends it on every request to a secure origin: same-origin, and none for what the user typed, pass; same-site and cross-site pass only from an origin listed in allowedOrigins.
  • Origin is compared with the origin the request was addressed to where the browser says nothing else; a foreign origin passes only when listed.
  • A request with neither header — a server, a CDN, a script — is not a browser's cross-origin request, and is served.
  • In development, while allowedOrigins is empty, a page on another port of the local machine is admitted when the app itself is served from that machine, so Studio can inspect the app. An app on any other address never admits one — a server started without NODE_ENV counts as in development, and a page on a visitor's own machine, a local tool or another dev server, is not the application's. Configuring allowedOrigins replaces the allowance with the list.

A refused call is answered 403 with { "success": false, "message": "Origin not allowed" }; a refused socket is closed with 4403. A pattern is an exact origin, https://*.example.com for the sub-domains of a host over one scheme and port, or *.example.com for them over any scheme on its default port:

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

const handler = createHandler({
  allowedOrigins: ["https://admin.example.com", "https://*.example.com"],
})

An entry a browser could never send — https://admin.example.com/ with its trailing slash, a path, :443 on HTTPS — stops the handler from being created, with INVALID_ALLOWED_ORIGIN naming the form to use.

Content-Type handling

The POST handler accepts two content types and rejects everything else:

typescript
// The handler accepts two content types:

// The endpoint is named in the URL for both: ?_s=api&_g=users&_m=create

// 1. application/json — standard requests
// Body: { variables, options } — any other field is refused with 400

// 2. multipart/form-data — file uploads
// Fields: _options first, then the user's data and files

// Anything else returns 415 Unsupported Media Type
// { success: false, message: "Unsupported Content-Type" }

Cookies

An API that keeps the user's session describes it with session: the proxy then sends it the cookies the session is kept in and nothing else of the browser's, repeats its double-submit token on writes, and hands the browser the session cookies it sets, made for your application's origin. For an API that keeps none, cookies cross the proxy only where you name them, one direction at a time:

  • cookie — the browser's cookies are forwarded to the backend.
  • set-cookie — the cookies the backend sets are passed back to the browser.

Name them where the rest of the header allowlist lives: on the source with allowedHeaders, on a base with courier().extend({ allowedHeaders }), or on one endpoint with .headers([...]). Left unnamed, neither crosses. The browser sends the proxy route every cookie of your application's origin, its own session among them, and a backend a source points at, a third party's included, receives them and sets cookies on your origin only where you say so.

A backend-for-frontend sign-in names the session where it travels:

api/index.ts
import { courier, defineApi, defineGroup } from "@noverax/courier/define"

const { endpoint } = courier()

const auth = defineGroup({
  prefix: "auth",
  endpoints: {
    // The backend answers a sign-in with its session cookie
    login: endpoint.post("login").variables<{ email: string; password: string }>().headers(["set-cookie"]),
    // Calls made as the user send the session back
    me: endpoint.get("me").response<{ id: string; name: string }>().headers(["cookie"]),
    // Signing out sends the session and clears it
    logout: endpoint.post("logout").headers(["cookie", "set-cookie"]),
  },
})

export const api = defineApi({ baseURL: process.env.AUTH_API_URL!, groups: { auth } })
Browser
const caller = createProxyCaller<typeof api>("api")

await caller.auth.login({ email, password }) // the session cookie reaches the browser
const me = await caller.auth.me()            // and goes back to the backend with this call

When the whole backend is your own, name both once for every endpoint: defineApi({ baseURL, allowedHeaders: ["cookie", "set-cookie"], groups }). The browser sends its cookies to the proxy route because credentials: "include" is the proxy client's default.

set-cookie handling

A named set-cookie is passed back on every answer, an error included, so a sign-out that fails still clears the session. Courier uses getSetCookie() to keep each cookie apart, avoiding the common bug of splitting cookie values on commas.

Cancelled calls

When the browser cancels a call — an aborted upload, a query it no longer needs — the handler stops its own request to the backend. It learns that the client has gone from the request's signal, and not every framework fires it:

ServerA cancelled call stops upstream
Express, Fastify, NestJS, the standalone server, Hono, Next.js, @astrojs/nodeYes
SvelteKit with @sveltejs/adapter-nodeYes: the adapter watches the socket from platform.req
SolidStart, dev and productionYes: the adapter watches the response on nativeEvent
React Router 7Only with future: { v8_passThroughRequests: true }: without it a resource route receives a rebuilt copy of the request whose signal does not reliably follow the original
Nuxt, dev and productionYes: the adapter converts Node's request so its signal follows the connection, and a Web request h3 1.x hands over is watched through the event's Node response
vite dev for SvelteKit, astro devNo: the dev server exposes neither a signal nor the connection

An integration of your own that can reach the Node socket or response passes it to handleWhileConnected, which gives the handler a request whose signal also fires when that connection closes:

typescript
import { createHandler, handleWhileConnected } from "@noverax/courier"

const { handle } = createHandler()

// res is the Node response your framework is answering on
const response = await handleWhileConnected(handle, request, res)

Error responses

The handler returns consistent JSON error responses with appropriate HTTP status codes:

typescript
// Error responses follow a consistent format:

// HTTP errors from the backend:
// Status: forwarded from backend (e.g., 404, 422, 500)
// Body:   parsed JSON from backend response

// Validation errors (missing source, endpoint not found):
// Status: 400
// Body:   { success: false, message: "[api][users][getById]: method not found" }

// Origin mismatch:
// Status: 403
// Body:   { success: false, message: "Origin mismatch" }

// Invalid JSON body:
// Status: 400
// Body:   { success: false, message: "Invalid JSON body" }

Next Steps