Add a WebSocket endpoint
r.webSocketRoute(definition) mounts a WebSocket endpoint that belongs to a
feature, the same way r.httpRoute mounts a plain HTTP route. Reach for it
when a client and the server have to exchange messages in both directions for
a while, for example audio going up and partial transcripts coming back. For
server-to-client updates only, a stream handler
over SSE is simpler.
Prerequisites
Section titled “Prerequisites”- A server started through
runProdApporrunDevApp. Both serve WebSocket upgrades out of the box. An app with its ownBun.serveneeds one extra argument, see Your own Bun.serve. - An ingress that passes WebSocket upgrades through.
The code
Section titled “The code”import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
export const echoFeature = defineFeature("echo", (r) => { r.webSocketRoute({ path: "/api/ws/echo", maxMessageBytes: 16 * 1024, maxConnectionsPerUser: 2, connect: (_c, { user }) => ({ onOpen: (connection) => connection.send(`hello ${user.id}`), onMessage: (data, connection) => connection.send(data), onClose: (code, reason) => { // release whatever onOpen allocated }, }), });});path must start with /api/ws/. :param segments are allowed, * is not.
Two features that declare the same path fail at boot.
connect(c, deps) runs once per connection attempt, after authentication and
the origin check and before the upgrade. deps carries the caller (user),
query and write functions that dispatch as that user, and clientIp
resolved the same way as for HTTP routes. Return the session handlers to
accept the socket, or a Response to refuse it with that response.
Allocate per-connection resources in onOpen, not in connect. If the
upgrade fails after connect returned, no onClose runs, so anything opened
in connect would leak.
What the framework does
Section titled “What the framework does”Authentication. The route sits under /api/*, so the normal session, PAT
and rate-limit chain runs on the upgrade request. An anonymous caller gets
401. A plain GET without an upgrade header gets 426
(websocket_upgrade_required).
Origin check. Browsers send cookies on cross-site WebSocket handshakes, and the CSRF and origin middlewares skip GET requests. The route therefore checks the origin itself whenever the caller authenticated with the session cookie:
- With
auth.allowedOriginsset, theOriginheader must be on that list. There is no same-host fallback. - Without an allowlist, the host in
Originmust match the request’sHost. - A missing or
nullorigin is refused.
A refused origin gets 403 origin_not_allowed. Callers that send a bearer
token instead of the cookie skip this check. If your app serves the client
from another origin than the API, add that origin to allowedOrigins.
Limits.
| Limit | Default | What happens above it |
|---|---|---|
maxMessageBytes per message | 64 KiB, at most 1 MiB | The socket closes with 1009 |
maxConnectionsPerUser per route, user and tenant | 5, at most 100 | The upgrade gets 429 websocket_connection_limit |
| Frames queued behind a running handler | 1 MiB, or maxMessageBytes if larger | The socket closes with 1013 and the queue is dropped |
| Unsent outbound data | 4 MiB | The socket closes |
The connection limit is counted per server process, so with several replicas it applies per pod.
Ordering. onOpen and onMessage run one after another in arrival order.
onClose runs as soon as the socket closes, without waiting for a slow
onMessage, and queued messages never start after it. A handler that throws
closes the socket with 1011.
Heartbeat and re-validation. Every 25 seconds the server pings the client
and checks the session again: the JWT expiry, the session store, the caller’s
roles and the tenant’s lifecycle status. An expired token closes the socket
with 1008 session expired. A revoked session, changed roles or a tenant in
teardown close it with 1008 session changed, because query and write
stay bound to the user from the upgrade. Three failed checks in a row, for
example while the session store is down, close it with 1013. The 25 seconds
stay below the 60 second default read timeout of ingress-nginx, so idle
sockets are not cut by the proxy.
Common gotchas
Section titled “Common gotchas”-
Check
connection.signalafter an await.onClosecan run whileonOpenoronMessageis still awaiting. If that await opened something, for example an upstream session, checkconnection.signal.abortedafterwards and release it yourself. The signal aborts when the socket closes.onOpen: async (connection) => {const upstream = await openUpstream();if (connection.signal.aborted) {upstream.close();return;}connection.signal.addEventListener("abort", () => upstream.close());}, -
Binary data is a copy. Binary messages arrive as a fresh
Uint8Array, so a handler queued behind a slow one never sees a reused buffer. -
Do not log message payloads that carry user data. The framework logs oversized messages by size, never by content.
Your own Bun.serve
Section titled “Your own Bun.serve”runProdApp with the default autoListen wires WebSockets itself. An app
that passes autoListen: false and starts Bun.serve on its own has to hand
the upgrade path to buildBunServeOptions. Without it every WebSocket route
answers 501 websocket_upgrade_not_wired.
import { buildBunServeOptions, runProdApp } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
const app = await runProdApp({ ...options, autoListen: false });
Bun.serve( buildBunServeOptions(port, app.fetch, maxRequestBodySize, { upgradeFetch: app.webSocketUpgradeFetch, }),);webSocketUpgradeFetch receives Bun’s original request, which the upgrade
needs. Rejected upgrades are ordinary responses and get the same security
headers as every other response.
See also
Section titled “See also”r.httpRoute(...), a feature-owned HTTP route.r.streamHandler(...), server-to-client streaming over SSE.- The Assistant, whose live dictation runs over a WebSocket route.