Skip to content

DeploymentStandalone Server

Standalone Server

Run a dedicated Courier HTTP + WebSocket server with createCourierServer — health checks, CORS, graceful shutdown, and connection registries.

8 min read

Installation

bash
npm install @noverax/courier-server @noverax/courier

To serve WebSocket channels, add ws too: npm install ws. A server without it serves HTTP and SSE, and answers a WebSocket upgrade with a 500 whose log line says to install it.

Quick Start

server.ts
import { createCourierServer } from "@noverax/courier-server"
import { sources } from "./api/sources"

const server = await createCourierServer({
  sources,
  port: 3001,
  host: "0.0.0.0",
  onReady: (url) => console.log(`Courier ready at ${url}`),
})

The server handles HTTP (GET/POST), SSE streaming, and WebSocket upgrades on a single port.

When you need a standalone server for WebSockets

Some runtimes can't host WebSocket upgrades in-process. In those cases the standalone server is the canonical answer for the WS transport — your app continues to handle HTTP/SSE, and proxyPath.ws points the browser at this server.

RuntimeHTTPSSEWS
Express, Hono, Fastify, NestJSin-processin-processin-process
Next.js App Routerin-processin-processstandalone server (required)
Cloudflare Workersin-processin-processstandalone server or Durable Objects
Vercel Edge / AWS Lambdain-processin-processstandalone server (required)
Standalone @noverax/courier-serverin-processin-processin-process

For runtimes in the bottom half of the table, run @noverax/courier-server alongside your app and configure the browser client like this:

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

export const caller = createProxyCaller<typeof api>("api", {
  boundary,
  proxyPath: {
    http: "/api/request",                // hosted by your Next.js / Cloudflare app
    sse: "/api/sse",                     // hosted by your app
    ws: process.env.NODE_ENV === "production"
      ? "wss://realtime.example.com"     // standalone courier-server
      : "ws://localhost:3001",
  },
})

proxyPath.ws is mandatory for WS endpoints

There is no built-in WS default. Calling a WS endpoint without proxyPath.ws throws ConfigurationError("WS_PATH_NOT_CONFIGURED") with a pointer back to this page. Earlier versions silently fell back to ws://localhost:3001/api/ws, which produced dev-only success and prod-only failures — gone in v2.

In dev you can run the standalone server alongside your Next.js dev server with concurrently or two terminals. In production, deploy it to any host that supports persistent TCP connections (Fly.io, Railway, Render, EC2, k8s).

Studio integration & appOrigin

Wire the standalone server into Studio with a single attachRuntime() call. In development it loads courier.config.ts from disk, registers its sources, and opens the connection to Studio; in production the server takes them from createCourierServer({ sources }). The standalone server announces its own listen origin to Studio automatically once httpServer.listen() resolves — no manual appOrigin plumbing needed for the standalone path.

server.ts
import { createCourierServer } from "@noverax/courier-server"
import { sources } from "./api/sources"

if (process.env.NODE_ENV !== "production") {
  const { attachRuntime } = await import("@noverax/courier-studio-core/runtime")
  await attachRuntime()
}

createCourierServer({ sources, port: 3001 })

In a split-host topology (Next.js hosts HTTP/SSE on 3000, standalone server hosts WS on 3001), each process calls attachRuntime() once at startup. Studio's binding table routes each Playground subscribe to the runtime that actually hosts the requested source × transport pair.

Configuration

server.ts
import type { PubSubAdapter } from "@noverax/courier/realtime"

const server = await createCourierServer({
  // Required — the sources this server answers for, and only these:
  // another server or registerSources() in the same process leaves them be
  sources,

  // Network
  port: 3001,                        // default: 3001
  host: "0.0.0.0",                   // default: "127.0.0.1" (IPv4 loopback; localhost URLs reach it)
  allowedOrigins: ["https://app.example.com"], // CORS + WebSocket origin check; left out = own origin only
  allowedHosts: ["api.example.com"], // names it is reached by; any other host gets 421 (4421 on a socket)

  // Request bodies (413 when exceeded)
  maxBodyBytes: 1_048_576,           // JSON envelope (default: 1 MB)
  maxUploadBytes: 67_108_864,        // multipart upload (default: 10 MB)

  // SSE
  sseHeaders: { "X-Accel-Buffering": "no" },  // custom response headers
  sseMaxLifetimeMs: 300_000,         // 5 min TTL, then client reconnects
  maxSSEConnections: 10_000,         // 503 when exceeded

  // WebSocket
  pubsub: redisPubSub,              // for multi-replica (see Redis page)
  heartbeatIntervalMs: 25_000,       // ping interval (default: 25s)
  heartbeatTimeoutMs: 10_000,        // pong timeout (default: 10s)
  maxConnectionsPerIP: 50,           // per address; raise behind a shared NAT (default: 50)
  maxSubscriptionsPerConnection: 100, // per connection; subscribe, unsubscribe and auth frames at most 2× this a second (default: 100)
  maxMessageSize: 65_536,            // larger messages get a 4413 error, the socket stays open (default: 64KB)
  maxMessagesPerSecond: 100,         // per connection (default: 100)
  authorize: ({ meta, topic }) => canSubscribe(meta, topic),        // per subscription
  authorizeMessage: ({ meta, topic }) => canSend(meta, topic),      // per channel message
  maxPayload: 1_048_576,             // larger frames close the socket with 1009, unbuffered (default: 1MB, or 2× maxMessageSize)

  // Lifecycle
  signals: true,                     // handle SIGTERM/SIGINT (default: true)
  onReady: (url) => console.log(`Ready: ${url}`), // announces the address in place of the server's "Listening on" line
})

localhost, *.localhost names and IP addresses are always served. Any other host name — the attacker's own, in a DNS-rebinding attack — is refused until allowedHosts lists it: always in development, and in production once the list is set. A server reached by another name in development, such as a Docker Compose service name (http://courier:3001), lists that name. See Host validation.

Lifecycle Hooks

SSE Hooks

server.ts
const server = await createCourierServer({
  sources,
  onSSEConnect: ({ connectionId, request, source, group, method }) => {
    const userId = extractUser(request)
    return { userId }  // stored as connection metadata
  },
  onSSEDisconnect: ({ connectionId, meta, reason, eventCount, totalBytes, duration }) => {
    console.log(`SSE ${connectionId} closed: ${reason}, ${eventCount} events in ${duration}ms`)
  },
  onSSEError: ({ connectionId, meta, error }) => {
    console.error(`SSE error for ${meta.userId}:`, error)
  },
})

The reason field is one of: "complete", "error", "disconnect", "shutdown", "max_lifetime".

What onSSEConnect returns is the stream's metadata, and it types meta in the SSE hooks written after it, so meta.userId above is a string. Return an object, or nothing for a stream with no metadata; anything else refuses the stream with 403, as a throw does.

WebSocket Hooks

server.ts
const server = await createCourierServer({
  sources,
  onConnect: ({ connectionId, request, auth }) => {
    const userId = extractUser(request, auth)
    if (!userId) throw new Error("Unauthorized")  // rejects connection
    return { userId }  // stored as connection metadata
  },
  onDisconnect: ({ connectionId, meta, code }) => {
    console.log(`WS ${connectionId} of ${meta.userId} closed with code ${code}`)
  },
  onError: ({ connectionId, meta, error }) => {
    console.error(`WS error for ${meta.userId}:`, error)
  },
})

onConnect runs once the client has said who it is: request is the upgrade request with the headers of the client's auth frame — what a browser cannot put on a WebSocket handshake — and auth is the client's ws.auth; see Who a connection is. What it returns is the connection's metadata, and it types meta in the hooks written after it — authorize, authorizeMessage, onReauth, onDisconnect, onError — so meta.userId above is a string. Return an object, or nothing for a connection with no metadata; anything else refuses the connection, as a throw does. ConnectionMeta<T> names that type, for a hook declared apart from the options.

Reject connections

Throw from onConnect to refuse a WebSocket connection: the server closes it with code 4401 and the reason "Connection rejected", which each channel and subscription on it reads as an AuthError, and the client does not reconnect. Throw from onSSEConnect to refuse a stream: it is answered 403 "Connection rejected", which the client reads as a ForbiddenError. What you threw is logged on the server and never sent, since it may carry a connection string or a token.

Connection Registries

The server exposes registries for monitoring and managing active connections:

monitoring.ts
// SSE connections
console.log(`Active SSE streams: ${server.sseConnections.count}`)
const userStreams = server.sseConnections.getByMeta((meta) => meta.userId === "user-1")
server.sseConnections.disconnect(userStreams[0].connectionId)

// WebSocket connections
console.log(`Active WS connections: ${server.wsConnections.count}`)
server.wsConnections.broadcast({ type: "announcement", text: "Maintenance in 5 min" })
server.wsConnections.broadcastToTopic("chat:general", { type: "message", text: "Hello" })

SSE Registry API

MethodDescription
countNumber of active streams
get(id)Get connection info by ID
has(id)Check if connection exists
list()All active connections
getByMeta(fn)Filter by metadata predicate
getByEndpoint(source, group?, method?)Filter by endpoint
disconnect(id, reason?)Force-close a stream
disconnectAll(reason?)Force-close all streams

WebSocket Registry API

MethodDescription
countNumber of active connections
get(id)Get connection info by ID
list()All active connections
getByMeta(fn)Filter by metadata predicate
send(id, data)Send to specific connection
broadcast(data, exclude?)Broadcast to all
broadcastToTopic(topic, data, exclude?)Broadcast to topic subscribers
broadcastToBaseTopic(baseTopic, data, exclude?)Broadcast to the subscribers of every qualified topic of an endpoint, whatever their variables
disconnect(id, code?, reason?)Force-close connection

A topic names its endpoint, source.group.method, qualified by a key as canonical JSON: the channel's .topic() key, or all of the variables its subscribers passed when it names none. qualifyTopic("api.chat.room", { room: "a" }) from @noverax/courier/realtime gives api.chat.room#{"room":"a"}, the topic a channel handler reads as ctx.topic. Every distinct key is a topic of its own.

With a pubsub adapter configured, the three broadcasts reach the connections of every replica, the same as a channel handler's ctx.broadcast. count, get, list, send and disconnect concern the connections of the replica you call them on.

Health Checks

Two built-in endpoints, no configuration needed:

bash
# Liveness — always 200
curl http://localhost:3001/health
# {"status":"ok"}

# Readiness — 503 during shutdown
curl http://localhost:3001/readyz
# "ready" (200) or "draining" (503)

Graceful Shutdown

When signals: true (default), the server handles SIGTERM and SIGINT:

  1. Readiness probe flips to 503 immediately
  2. SSE streams receive a retry hint and close gracefully (3s window)
  3. WebSocket connections receive close frame with code 1012 (5s window)
  4. HTTP server stops accepting new connections
  5. The process exits with code 0 — or 1 if a server failed to stop cleanly
  6. Second signal forces immediate exit

If your application handles SIGTERM or SIGINT itself, the server still drains on the signal but leaves ending the process to your handler, so your own cleanup runs to completion. To decide the order yourself, pass signals: false and call server.stop() from your handler.

Programmatic shutdown:

ts
await server.stop()

CLI

Run a server directly from the command line:

bash
npx @noverax/courier-server --config ./courier.config.ts --port 3001 --host 0.0.0.0

The config file must export sources:

courier.config.ts
import { sources } from "./api/sources"

export { sources }

The server loads this file in production, so keep development tools out of it. Studio's mock servers, for one, are declared in courier.mocks.ts beside it, which only Studio reads. A mocks file that reads a source from the config, as courierConfig.sources.api after import courierConfig from "./courier.config" (the form Studio's mock wizard writes), needs a config whose default export is defineConfig({ sources }); beside a config like the one above, which exports sources by name only, that import does not type-check, so import the source from ./api/sources instead. In a "type": "module" package whose tsconfig resolves modules as Node does (node16 or nodenext), the config import is ./courier.config.js.

FlagDefaultDescription
--configcourier.config.* in the working directoryPath to config file, relative to the working directory
--portserver.port from the config, then 3001Server port
--hostserver.host from the config, then 127.0.0.1Address to listen on
--allowed-originsserver.allowedOrigins from the configComma-separated origins browser apps may call from
--allowed-hostsserver.allowedHosts from the configComma-separated host names the server is reached by

The request body limits come from the config's server block, maxBodyBytes for a JSON envelope (1 MB by default) and maxUploadBytes for an upload (10 MB by default). A value that is not a whole number of bytes above 0 stops the command with a message naming the key.

courier.config.ts
export default defineConfig({
  sources: { api },
  server: { port: 3001, maxBodyBytes: 2_097_152, maxUploadBytes: 67_108_864 },
})