Hosting safely
@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:
maxWatchesPerConnectioncaps the number of refs a connection may watch; lookups beyond the cap return an error. The default is 1000, above the lookup burst, so a page load fits; passInfinityfor no cap. Watched entries are never evicted, so without a cap one connection could hold processes open at the lookup rate for as long as it stays connected.lookupRatecaps how fast one client may look processes up. Each distinct lookup may spawn a process, so without a rate a client could fill the registry by looking upcartwith a thousand different arguments. The rate is a token bucket:maxtokens refill evenly over eachperMs, and up toburstof them can be held at once. The default is 100 per 10 seconds with a burst of 500. The burst exists for page loads: a page that looks up a few hundred remote refs when it opens gets them all at once, and after that the client is held to 10 lookups a second. Lookups with no token left return an error. A scoped gateway that derives arguments from the session removes the problem for its names entirely.The bucket belongs to the client, not the connection, so reconnecting does not refill it: otherwise one client could drain
totalLookupRate(below) for everyone by reconnecting after every burst. A client is theprincipalitsscopereturns, or else the connection's remote address, and all of a client's connections (two tabs, say) draw on the one bucket. The host keeps these buckets across connections in a table capped at 10 000 clients; a bucket leaves the table once it has refilled, which loses nothing, since a new bucket starts full. Behind a reverse proxy every connection shares the proxy's address, so therescopeshould return a principal (the user's id, or the forwarded client address) or the whole site shares one bucket.maxBufferedBytes(default 8 MiB) bounds what the host queues for a client that is not reading. Past it the host terminates the socket rather than growing its memory. Nothing is lost by terminating: the client reconnects, re-looks-up, and receives full snapshots.heartbeatMs(default 30 seconds;0disables) pings each socket and terminates it after a missed pong. This releases watches from half-open connections instead of waiting for the operating system to detect them.
The host also applies these across all connections, because a per-connection limit multiplies with the number of connections:
totalLookupRateis the same kind of bucket, shared by every connection. The default is 1000 per second with a burst of 10 000, which admits a reconnect storm of a few thousand clients re-looking up a handful of refs each. Lookups past it returnhost lookup rate exceeded. It counts every lookup, including re-lookups of processes that already exist, so size it for your reconnect peak.maxEntriescaps the host registry (default 10 000). Past it the least recently looked-up entries that nobody is watching are disposed, and a later lookup spawns them afresh. Watched entries are never evicted, so live processes can exceed the cap by the number that clients hold open; setmaxWatchesPerConnectionto bound that too. A busy entry is also kept: one holding unanswered calls or aself.busy()hold, which a durable process takes while a message is unacknowledged. One asleep, or plain and busy with a cast, is not. A plain process evicted that way loses its state. A process whose state must outlive eviction should be durable, so the respawn resumes from its journal, and a sleeping one comes back when aschedulerwakes it (see Processes on the server). PassInfinityfor no cap.
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:
- It does not cap the number of connections or handshakes. Each idle connection costs a session and a heartbeat timer. Limit connections per client address at the reverse proxy or load balancer.
- It does not time out
authorizeorscope. A socket waits for as long as they take, so give a session lookup its own timeout. - It knows no identities. An address-keyed lookup bucket behind a proxy is
shared by everyone, so return a
principalfromscopethere. - Loopback is not a user boundary. On a machine shared by several users, any
of them can connect to a loopback host; set
authorizethere too. GET /schemalists the definition names to anyone who passesauthorize. With noauthorize, that is anyone who can connect.
By deployment shape:
- A local app (a dev server, a desktop app's backend). The defaults are
the policy: loopback only, loopback pages only. Set
authorizeif the machine has other users or the processes hold anything private. - Behind a reverse proxy. On the same machine, keep the loopback default
so only the proxy can connect; in a container or on another machine, set
host: '0.0.0.0'and keep the port off the public network. SetallowedOriginsto the site's real origins (the proxy passesOriginthrough),authorize, and ascopethat returns aprincipal, since every connection arrives from the proxy's address. TLS and connection limits live at the proxy. - Internet-facing, no proxy. Set
host,allowedOrigins,authorize, andscopewithprincipalandadmit, asexamples/shared-cart/server.tsdoes. Terminate TLS with an HTTPSserver, and bound connection counts in the operating system or a firewall, since the host does not.
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