Hosting safely

  1. Screening messages
  2. Durable call ids
  3. Connection limits
  4. Binding
  5. Threat model

@nonchalant/host defaults to a local app: it listens on loopback only, accepts browser pages served from this machine only, and asks for no credentials. A local example needs no setup, and a host that has not been configured cannot be reached from the network. Do not treat that default as a deployment policy. A host that other machines reach should set the interface it listens on, an origin policy, and an authorization function:

import { serve } from '@nonchalant/host'

const host = await serve(definitions, {
  port: 4321,
  host: '0.0.0.0',
  allowedOrigins: ['https://app.example'],
  authorize: async (request) => Boolean(await sessionFromRequest(request)),
})

allowedOrigins checks the WebSocket handshake's Origin header. Without it, the host accepts pages whose origin is a loopback address (localhost, 127.x.x.x or [::1], any port) and clients that send no Origin at all, and refuses every other site with 403. That stops a page on another site from opening a socket to a host on its visitor's machine. A DNS-rebinding page keeps its own hostname as its origin, so it fails the same check even when its name resolves to 127.0.0.1. An array is appropriate for browser-only applications and rejects clients that omit the header. A callback receives undefined for clients without an origin, which lets you make an explicit decision for command-line, server, or native clients:

allowedOrigins: (origin, request) =>
  origin === 'https://app.example' ||
  (origin === undefined && isTrustedService(request))

An origin is not an identity. Origin policy helps stop another website from opening a socket with a visitor's ambient credentials. authorize should validate the session, bearer token, client certificate information supplied by your proxy, or another real credential. It also protects GET /schema.

Connection authorization is only the first layer. authorize accepts or rejects a connection, but it does not pass an identity to individual processes. The schema only limits available lookup names, so an authenticated client could still submit arguments that refer to another user's data. Use scope to create a gateway for each accepted connection. The server then controls which processes the session can reach:

const host = await serve(definitions, {
  port: 4321,
  allowedOrigins: ['https://app.example'],
  authorize: async (request) => Boolean(await sessionFromRequest(request)),
  scope: async (request, reg) => {
    const session = await sessionFromRequest(request)
    if (session === null) throw new Error('unauthorized') // rejects the upgrade
    return {
      lookup: (name: string) => {
        if (name !== 'cart') throw new Error(`not exposed: ${name}`)
        // the server supplies the arguments, so a client cannot name another
        // user's cart, whatever it sends
        return reg.lookup('cart', { userId: session.userId })
      },
      principal: session.userId,
    }
  },
})

The gateway provides one place to enforce tenancy, quotas, and auditing. It can count lookups, record which resources a session accessed, or delegate policy decisions to a process. Nonchalant does not define users, roles, or tokens. Without scope, all accepted connections share the host registry, so exposed processes must validate tenant, record, and operation access themselves.

Screening messages

A process's message type is a TypeScript type, and nothing checks it at run time. A reducer that trusts it (msg.text.trim()) throws on a hostile { type: 'post', text: 5 }, and a thrown reducer is a crashed process: every client watching it goes stale and its state is lost. The host therefore delivers only messages that are objects with a string type, and the gateway's admit screens the rest before delivery:

import type { Json } from '@nonchalant/core'

type Wire = { type: string } & { [key: string]: Json }

// rebuilt rather than passed through, so unknown fields never reach the room
const admitPost = (msg: Wire, from: string): Json | undefined =>
  msg.type === 'post' && typeof msg['text'] === 'string'
    ? { type: 'post', from, text: msg['text'] }
    : undefined

const host = await serve(definitions, {
  scope: async (request, reg) => {
    const session = await sessionFromRequest(request)
    if (session === null) throw new Error('unauthorized')
    return {
      lookup: (name: string) => {
        if (name !== 'room') throw new Error(`not exposed: ${name}`)
        return reg.lookup('room', { name: session.team })
      },
      admit: (_name, msg) => admitPost(msg, session.displayName),
      principal: session.userId,
    }
  },
})

admit(name, msg) receives the schema name the ref was looked up under and returns the message to deliver, a replacement for it, or undefined to refuse. A refused call rejects on the client with a WireError; a refused cast is dropped. Returning a replacement is how a server stamps facts the client must not choose, such as the sender of a chat line, so they come from the session rather than from the message. A throwing admit counts as a refusal.

Durable call ids

A durable process records each call's answer under the callId the caller supplied, so a retry with the same id receives the recorded answer (see Processes on the server). Over the wire the caller is a client, and a client can pick any id. If ids were used as sent, a client that guessed another user's order-7 would read that user's result.

The host prevents this at the boundary. Before delivery, a string callId at the top level of a call's message is rewritten into the connection's principal namespace: order-7 becomes ["alice","order-7"]. The principal is the gateway's principal when scope returns one. Otherwise the host generates a random principal for each connection, so no two connections ever share an answer record. That default is safe but forgets across reconnects: a client whose call was cut off by a disconnect retries on a new connection, under a new principal, and the durable callee does the work again. Set principal to the user's stable id whenever retries must land on the same record, which for durable calls is almost always. The rewritten id is what the process sees, and what it replies under.

Connection limits

The host applies these per connection, or per client where noted:

The host also applies these across all connections, because a per-connection limit multiplies with the number of connections:

packages/host/test/host.test.ts checks the burst default, the watch cap default, that reconnecting does not refill a client's bucket, that principals sharing an address keep separate buckets, the shared bucket across several connections, and the registry cap; packages/host/test/units.test.ts checks the bucket arithmetic and the client table on their own.

close() ends every session and disposes every process in the host's registry, so no eviction timer outlives the host (close disposes every process the host spawned, eviction timers and all).

Binding

host is the interface the host listens on, and it defaults to '127.0.0.1': only programs on the same machine can connect, including a reverse proxy there. Set it when something else must reach the host: '0.0.0.0' (every IPv4 interface) or '::' (every interface, IPv4 too on most systems) inside a container or behind a proxy on another machine, or one interface's address to serve on that network alone. host.url and host.port are read from the address the server actually bound, with an IPv6 address in brackets, so 'localhost' reports whichever of 127.0.0.1 or [::1] it resolved to. A port that is already taken rejects serve with EADDRINUSE. host.test.ts checks each of these (the binding tests), including that the default is unreachable from the machine's network address.

To serve the socket from the app's own HTTP or HTTPS server, on the same port as its pages and API, pass the server instead of a port:

import { readFileSync } from 'node:fs'
import { createServer } from 'node:https'
import { serve } from '@nonchalant/host'

const app = createServer(
  { key: readFileSync('tls/key.pem'), cert: readFileSync('tls/cert.pem') },
  (_req, res) => res.end('the app'), // attach the app's handlers before serve
)
app.listen(443, '0.0.0.0')

const host = await serve(definitions, {
  server: app,
  path: '/live',
  allowedOrigins: ['https://app.example'],
  authorize: (request) => hasSession(request.headers.cookie),
})

With server, the host stands in front of the app's request and upgrade handlers: it answers GET /schema and WebSocket upgrades on path and passes every other request and upgrade to them, so neither sees the host's traffic. Its upgrades still pass the same path, origin and authorization checks. The app binds the server, so port and host are refused alongside server, and serve resolves at once, before the server need be listening; host.url throws until it is. close() removes only what serve attached and ends its sessions, and leaves the app's server listening with its own handlers. A handler the app attaches after serve would receive the host's traffic as well, so attach them first. host.test.ts checks this (the sharing an app server tests).

For an internet-facing service, also terminate TLS (wss://), at the reverse proxy or with an HTTPS server as above; set request and connection limits at the reverse proxy; keep maxPayloadBytes appropriate for the application; and log rejected handshakes without logging credentials. The host defaults incoming payloads to 1 MiB and closes an oversized connection with WebSocket code 1009.

examples/shared-cart/server.ts and examples/chat/server.ts put all of this together: an origin policy, a token or handshake-derived identity, a scope that supplies arguments, an admit that validates every message, and a principal.

Threat model

The host stands between untrusted clients and the processes in a registry. What a client controls: whether and from where it connects, the handshake's headers and URL, and every frame after it (lookup names and arguments, cast and call messages, callIds, and how fast it reads). What the host must keep: other users' processes out of its reach, the processes it may reach alive, and the host's own memory and event loop. Each layer, with its default and the test that enforces it:

layer default what it stops enforced by
host '127.0.0.1' Connections from any other machine. host.test.ts: listens on loopback only by default, and url names the bound address
allowedOrigins loopback pages and no-Origin clients A page on another site (or a rebinding one) opening a socket from its visitor's browser. host.test.ts: with no allowedOrigins, accepts loopback pages and clients without an Origin, and refuses other sites; units.test.ts: loopbackOrigin
authorize none Connections and GET /schema without a credential. A throwing authorize is a 500, and a client that drops mid-check cannot crash the host. host.test.ts: checks browser origins and authorization before accepting a connection, an authorize that throws is a 500 …, a client that resets its socket while authorize is pending does not crash the host
scope every connection shares the registry A client naming another user's process: the server chooses the arguments. host.test.ts: scope gives each connection its own lookup gateway closed over the request
message check and admit objects with a string type only; no admit A malformed message crashing a process everyone watches; a client stamping facts (a sender) it must not choose. packages/wire/test/wire.edges.test.ts: a message that is not an object with a string type never reaches the process, admit refuses a malformed message …, admit can replace a message …
principal a random one per connection One client reading or colliding with another's durable call answers by reusing a callId. host.test.ts: client callIds are namespaced per connection unless scope names a principal
lookupRate, totalLookupRate 100 per 10 s (burst 500) per client; 1000 per s (burst 10 000) in all A client, or many, filling the registry with spawns. host.test.ts: the default per-connection burst …, reconnecting does not refill …, totalLookupRate bounds …
maxWatchesPerConnection 1000 One connection pinning processes open without bound. host.test.ts: caps a connection at 1000 watched refs by default; the 1001st raises
maxEntries 10 000 Unwatched processes accumulating. host.test.ts: maxEntries evicts the least recently looked-up unwatched entry
maxPayloadBytes 1 MiB One frame exhausting memory on the way in. host.test.ts: a message over maxPayloadBytes closes the connection with 1009
maxBufferedBytes 8 MiB A client that stops reading growing the host's outbound queue. host.test.ts: a client that stops reading is terminated …
heartbeatMs 30 s Half-open connections holding watches. host.test.ts: heartbeat leaves responsive connections alone and reclaims half-open ones

What the host does not do, and where it belongs:

By deployment shape:

Leaks under sustained load are measured by bench/soak.ts, which runs the chat and shared-cart hosts under synthetic client churn and samples the heap:

node --experimental-strip-types --no-warnings --expose-gc --conditions=source bench/soak.ts --duration 24h

This page's source on GitHub