ADR 0020 — A raw WebSocket lane composed beside Socket.IO
Context
stitchkit’s realtime layer is Socket.IO (ADR 0008) — it carries binary fine, and for most streams a binary event is enough. But a truly high-throughput binary channel (video, large transfers) may want a raw WebSocket with no Socket.IO framing, on the same port as the rest of the API.
On Bun this is awkward. Bun.serve exposes a single websocket handler, and
@socket.io/bun-engine (via createSocketIOServer().websocket) claims it. A
project that also wants a raw lane has to hand-compose that one handler:
discriminate each socket and route it to the engine or to its own handlers.
The fragile part is the discriminator. Routing to Socket.IO means asking “is
this an engine socket?”, which forces inspecting the engine’s opaque
ws.data ({ transport }) — a brittle guard, hard to keep cast-free. This was
the recurring pain when projects tried it by hand.
Decision
Ship the composition as a small, typed, cast-free primitive in
stitchkit/server, and invert the discriminator so the engine’s data is never
inspected.
composeWebSocketHandlers(lanes, config?)builds oneWebSocketHandler<unknown>from an ordered list of lanes. On each callback (open/message/close/drain/ping/pong) the first lane whosematchclaims the socket handles it;configcarries the server-wide tuning.webSocketLane({ match, handlers })is the typed lane builder.matchis a type-predicate that narrowsServerWebSocket<unknown>to the lane’s data type, so the typed handlers are called without a singleas. This is the one bridge from Bun’s untyped single handler to per-lane typed handlers.socketIoLane(socket.websocket)wraps the engine handler as the catch-all lane (matched last). It claims every socket no raw lane matched — so the engine owns whatever a raw marker did not, and the opaque enginews.datais never read.- Inversion is the key. A raw lane stamps its own marker onto
ws.dataat upgrade and is matched positively; Socket.IO is the fallback. The brittle “is this an engine socket?” check disappears. - The upgrade stays a plain
RawRoute. The app’s upgrade route callsctx.server.upgrade(req, { data })with its marker and returnsnew Response(null)— symmetric with the ready-madesocket.route. No newcreateServerconfig field.
Alternatives considered
rawWebSocketfield oncreateServer(auto-register the upgrade route and compose internally). Rejected: it growscreateServerbeyond its thinBun.servewrapper and hides the upgrade. The composition primitive plus a normalRawRouteis enough and stays explicit.- Inspecting the engine
ws.datato route to Socket.IO. Rejected: brittle and cast-prone — the exact problem this ADR removes.
Consequences
- Not a competing engine (upholds ADR 0008). stitchkit does not ship a
WebSocket transport — the raw lane is the app’s own
Bun.serveWebSocket. The framework only composes the single handler, type-safely. - Cast-free. No new
as; the design was verified againsttsc. The one allowed Socket.IO emitter cast (perAGENTS.md) is untouched. - Bun-only. On Node, Socket.IO drives sockets through the
node:http.Serverupgradeevent (serveNode({ socket })); a raw lane there is a separate upgrade handler, not this composition. Consistent with ADR 0013 (Bun-first server, Socket.IO engine Bun-only). - Tuning is server-wide.
maxPayloadLength/idleTimeout/… apply to every socket (Bun cannot scope them per-lane); set them to the most permissive lane.