Skip to content

Proxy ModedefineBoundary

defineBoundary

Declare which endpoints the browser may call, and keep everything else off the wire and out of the bundle.

5 min readproxy mode

Overview

The proxy addresses endpoints by name. A browser call carries ?_s=api&_g=users&_m=getAll, and the server resolves those names against the sources your application registered — all of them, including the ones only server code was ever meant to call.

Nothing in that mechanism can tell the two apart. An endpoint written for a cron job is reachable from a page, and reachable with the server's credentials, which is the point of proxy mode.

defineBoundary is where you say which endpoints the browser may reach. The same declaration does both halves of the job: the server refuses anything it does not name, and the browser receives it instead of your API definition.

lib/boundary.ts
import { defineBoundary } from "@noverax/courier/define"

export const boundary = defineBoundary({
  source: "api",
  proxyPath: { http: "/api/request", sse: "/api/sse", ws: "wss://realtime.example.com" },
  expose: {
    "users.getAll": "query",
    "users.getById": "query",
    "users.create": "mutation",
    "notifications.feed": "stream",
    "chat.room": "duplex",
  },
})

Anything absent is refused. admin.exportPayouts above is not named, so the server answers it exactly as it answers a name that does not exist. An endpoint of a group inside another is named by the whole path, "billing.invoices.list", and the browser's client reaches only the groups that path passes through.

A read goes to the proxy as GET, its variables in the address. A read whose definition declares sensitiveVariables goes as POST, and the proxy refuses it over GET; the browser cannot see the definition, so the boundary lists it:

ts
defineBoundary({
  source: "api",
  expose: { "users.search": "query", "users.byEmail": "query" },
  sensitive: ["users.byEmail"],
})

Wiring the server

The server refuses what the boundary does not name only if it has the boundary. Give it to the API it gates:

api/index.ts
import { defineApi } from "@noverax/courier/define"
import { boundary } from "@/lib/boundary"

export const api = defineApi({
  baseURL: process.env.API_URL!,
  boundary,
  groups: { users, notifications, chat, admin },
})

The proxy resolves every call against the source it was registered as, so this one line gates HTTP calls, streams and sockets alike. Without it, the client keeps to the boundary and the server serves everything the API holds to anyone who sends the name.

The API also checks the boundary here: a path it does not declare, or a shape it declares otherwise, does not compile — see the shapes. The boundary itself names no API, so the two do not have to be typed from each other, and it needs no annotation; annotating it ProxyBoundary would throw away the list of what it exposes, which the client below is typed from.

A source must carry its own boundary: one declared with another source is refused when the sources are registered, with BOUNDARY_SOURCE_MISMATCH.

Wiring the client

Pass the boundary where you would have passed the API object.

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

export const client = createProxyCaller<typeof api>("api", { boundary })
lib/query.ts
import { createProxyQueryCaller } from "@noverax/courier-react"
import type { api } from "@/api"
import { boundary } from "./boundary"

export const q = createProxyQueryCaller<typeof api>("api", { boundary })

The API is imported as a type. That is the whole trick, and it is load-bearing rather than stylistic: a value import pulls your defineApi call into the module, and from there into the bundle — the upstream host, your middleware bodies and whatever literal sits inside them, and the path of every endpoint including the ones you held back.

Since the API holds the boundary, the client typed from it has the endpoints the boundary names and no others: client.admin.exportPayouts() does not compile, and the hooks of q offer no endpoint the proxy would refuse. An API that holds no boundary gives the full client.

The shapes

Each endpoint says how the client reaches it. The API that holds the boundary checks the answer, so a wrong one does not build.

shapethe endpoint it describes
"query"an HTTP endpoint that reads: declared with get, head, options or query, or marked .reads()
"mutation"an HTTP endpoint that writes: every other verb
"stream"a server-sent event stream
"channel"a socket the server pushes on
"duplex"a socket the caller may also send on — a channel with .send()
ts
import { courier, defineApi, defineBoundary, defineGroup } from "@noverax/courier/define"

const { endpoint, stream } = courier()

const api = defineApi({
  name: "api",
  baseURL: "https://api.example.com",
  groups: {
    posts: defineGroup({
      prefix: "posts",
      endpoints: {
        list: endpoint.get("").response<string[]>(),
        feed: stream.get("feed").response<string>(),
        create: endpoint.post("").variables<{ title: string }>().response<string>(),
      },
    }),
  },
})

const misspelt = defineBoundary({ source: "api", expose: { "posts.lsit": "query" } })
const notARead = defineBoundary({ source: "api", expose: { "posts.feed": "query" } })
const aWrite = defineBoundary({ source: "api", expose: { "posts.create": "query" } })

// @ts-expect-error — no such endpoint
defineApi({ baseURL: "https://api.example.com", boundary: misspelt, groups: api.options.groups })

// @ts-expect-error — it is a stream
defineApi({ baseURL: "https://api.example.com", boundary: notARead, groups: api.options.groups })

// @ts-expect-error — a POST writes
defineApi({ baseURL: "https://api.example.com", boundary: aWrite, groups: api.options.groups })

Renaming an endpoint breaks the boundary that still names the old one, so the two cannot drift apart. There is no build step and nothing to generate.

What the browser learns

A boundary is plain data, and it is all the client gets:

json
{
  "source": "api",
  "proxyPath": { "http": "/api/request", "sse": "/api/sse" },
  "endpoints": {
    "users.getAll": { "type": "query" },
    "users.create": { "type": "mutation" },
    "notifications.feed": { "transport": "sse" },
    "chat.room": { "transport": "ws", "bidirectional": true }
  }
}

No upstream host. No middleware. No endpoint you did not name. That makes it something you can read in review and diff in CI: it is the answer to "what could the browser call in this release", in one file.

Server-side callers are unaffected

A boundary gates calls that arrive over the proxy. Code running in your own process holds the API object and never addresses anything by name, so createCaller(api) reaches every endpoint as before.

Without a boundary

A source that declares none behaves as it always has: everything it holds is reachable through the proxy. For shutting an API off wholesale there is a coarser switch that needs no list.

ts
defineApi({
  expose: false,
  groups: {
    public: defineGroup({ expose: true, prefix: "public", endpoints: { /* … */ } }),
  },
})

A boundary takes precedence wherever it is declared.

Direct mode is a different trade

In direct mode the browser calls your backend itself, so it must hold the API definition — the URL and the endpoint paths are in the bundle by construction. A boundary cannot change that, and is not meant to. Proxy mode is the one that keeps them on the server.