# Standalone Server

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

## 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

```ts title="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.

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

```ts title="lib/caller.ts"
import { createProxyCaller } from "@noverax/courier"
import { apiRoutes } from "@/courier.routes"

export const caller = createProxyCaller(apiRoutes, {
  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`](https://www.npmjs.com/package/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.

```ts title="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

```ts title="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)

  // Calls one caller may make (429 past them); a store shares them across replicas
  rateLimit: { requests: 300, window: 60_000, writes: { requests: 20, window: 60_000 } },

  // 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, and Ctrl+Break and the console closing on Windows (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](/docs/proxy-mode/overview#host-validation).

## Lifecycle Hooks

### SSE Hooks

```ts title="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

```ts title="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](/docs/advanced/realtime#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:

```ts title="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

| 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()`](/docs/advanced/realtime#topics) 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` — and on Windows also Ctrl+Break (`SIGBREAK`) and its console window closing (`SIGHUP`), which Windows follows a few seconds later by ending the process:

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

Elsewhere `SIGHUP` is left to your application, which may reload on it, as the [certificate renewal](/docs/advanced/proxies-and-certificates#renewing-the-certificate) does. 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`:

```ts title="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`.

| 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.

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