Installation
npm install @noverax/courier-server @noverax/courierTo 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
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.
| Runtime | HTTP | SSE | WS |
|---|---|---|---|
| Express, Hono, Fastify, NestJS | in-process | in-process | in-process |
| Next.js App Router | in-process | in-process | standalone server (required) |
| Cloudflare Workers | in-process | in-process | standalone server or Durable Objects |
| Vercel Edge / AWS Lambda | in-process | in-process | standalone server (required) |
Standalone @noverax/courier-server | in-process | in-process | in-process |
For runtimes in the bottom half of the table, run @noverax/courier-server alongside your app and configure the browser client like this:
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.
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
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
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
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:
// 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
| Method | Description |
|---|---|
count | Number 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
| Method | Description |
|---|---|
count | Number 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:
# 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:
- Readiness probe flips to
503immediately - SSE streams receive a retry hint and close gracefully (3s window)
- WebSocket connections receive close frame with code
1012(5s window) - HTTP server stops accepting new connections
- The process exits with code
0— or1if a server failed to stop cleanly - 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:
await server.stop()CLI
Run a server directly from the command line:
npx @noverax/courier-server --config ./courier.config.ts --port 3001 --host 0.0.0.0The config file must export sources:
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.
| Flag | Default | Description |
|---|---|---|
--config | courier.config.* in the working directory | Path to config file, relative to the working directory |
--port | server.port from the config, then 3001 | Server port |
--host | server.host from the config, then 127.0.0.1 | Address to listen on |
--allowed-origins | server.allowedOrigins from the config | Comma-separated origins browser apps may call from |
--allowed-hosts | server.allowedHosts from the config | Comma-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.
export default defineConfig({
sources: { api },
server: { port: 3001, maxBodyBytes: 2_097_152, maxUploadBytes: 67_108_864 },
})