Skip to content

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.

  • A server started through runProdApp or runDevApp. Both serve WebSocket upgrades out of the box. An app with its own Bun.serve needs one extra argument, see Your own Bun.serve.
  • An ingress that passes WebSocket upgrades through.
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.

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.allowedOrigins set, the Origin header must be on that list. There is no same-host fallback.
  • Without an allowlist, the host in Origin must match the request’s Host.
  • A missing or null origin 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.

LimitDefaultWhat happens above it
maxMessageBytes per message64 KiB, at most 1 MiBThe socket closes with 1009
maxConnectionsPerUser per route, user and tenant5, at most 100The upgrade gets 429 websocket_connection_limit
Frames queued behind a running handler1 MiB, or maxMessageBytes if largerThe socket closes with 1013 and the queue is dropped
Unsent outbound data4 MiBThe 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.

  • Check connection.signal after an await. onClose can run while onOpen or onMessage is still awaiting. If that await opened something, for example an upstream session, check connection.signal.aborted afterwards 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.

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.