WebSockets@rhythmjs/ws
Serve and fan out
One fetch expression upgrades matching requests, one websocket behavior dispatches every connection, and fan-out is Bun's own pub/sub.
On this page
The upgrade contract#
The fetch-side half is upgrade(request, server), bound so you can pass it around. Its result is three-way: it returns null synchronously when the request is not a websocket upgrade or matches no route (which is what makes single-expression composition with ?? work); otherwise a promise that resolves to undefined once server.upgrade() has taken the socket, or to the Response that rejects the handshake (from a guard or from the route's upgrade). A refused server.upgrade() answers 500 Upgrade failed, Bun's own convention.
Bun.serve({
port: 3000,
fetch: (request, server) => ws.upgrade(request, server) ?? app(request),
websocket: ws.websocket,
});The websocket behavior#
ws.websocket is the one behavior object Bun.serve accepts. Each event looks up the connection's route through the shared registry keyed on ws.data identity and forwards to that route's handler: open, message, drain, close. The getter also carries the constructor's behavior tuning, passed through to Bun verbatim: idleTimeout, maxPayloadLength, backpressureLimit, closeOnBackpressureLimit, sendPings, publishToSelf, and perMessageDeflate.
Multiple apps, one server#
Because dispatch is keyed on data identity in a registry shared across instances, one websocket behavior serves connections upgraded by any RhythmWs on the server. Chain the upgrades and pass either instance's behavior:
Bun.serve({
fetch: (req, server) =>
support.upgrade(req, server) ?? sales.upgrade(req, server) ?? app(req),
websocket: support.websocket, // dispatches for sales too
});Prefer merge() when the apps share guards or a prefix; prefer chained upgrades when they are independent deployables. Note the behavior tuning comes from whichever instance provides websocket.
With @rhythmjs/router#
There is no server wrapper anywhere in the ecosystem: the router's app is a fetch handler, so both halves plug into the same hand-written Bun.serve call you already own. The sync-null contract keeps it one expression.
import { toFetchHandler } from "@rhythmjs/router/fetch";
const handler = toFetchHandler(app);
Bun.serve({
port: 3000,
fetch: (request, server) => ws.upgrade(request, server) ?? handler(request),
websocket: ws.websocket,
});Pushing from HTTP handlers#
Fan-out is Bun's native pub/sub. Inside handlers, ws.subscribe(topic) and ws.publish(topic, data) work as Bun documents them (publish excludes the sender unless publishToSelf is set). Outside any socket, the server itself publishes; an ordinary route can announce into a room:
router.post("/api/rooms/:id/announce", async (ctx) => {
server.publish(`room:${ctx.params.id}`, await ctx.request.text());
ctx.json({ ok: true }, 201);
});Scaling out, testing#
Bun's pub/sub is in-process: peer.publish and server.publish reach peers on the same instance. For replicas, relay publishes through a backplane you own (Redis pub/sub via Bun.redis, NATS) by calling server.publish in the subscriber.
Guards, upgrades, and handlers all run socketless under the harness in @rhythmjs/testing/ws; upgradeWs exercises this page's whole contract against a mock server.