Skip to content

WebSockets

A WebSocket endpoint is declared with ws() and lives in the same controllers as routes. Its handshake runs the ordinary hooks, and what they establish becomes the socket’s typed data.

ws() takes the handshake’s path, its hooks and schemas, and Bun’s own socket handlers:

const named = hook.beforeParse((ctx) => {
const
const name: string | null
name
= new URL(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
url: string

The url read-only property of the Request interface contains the URL of the request.

MDN Reference

url
).
searchParams: URLSearchParams

The searchParams read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL.

MDN Reference

searchParams
.get("name");
if (!
const name: string | null
name
) throw new HttpError(401);
return {
name: string
name
};
});
const ChatMessage = z.object({ text: z.string().min(1).max(500) });
export const chatController = controller("Chat", () => ({
room: ws({
path: "/chat/:room"

Path of the handshake, :param segments included.

path
: "/chat/:room",
hooks: { beforeParse: [named] },
schema: { message: ChatMessage },
open: (socket) => socket.subscribe(socket.
data: {
name: string;
readonly params: {
room: string;
};
}

Custom data you can assign to a client. It can be read and written at any time.

data
.
params: {
room: string;
}
params
.
room: string
room
),
message: (socket,
message: {
text: string;
}
message
) => {
socket.publish(
socket.
data: {
name: string;
readonly params: {
room: string;
};
}

Custom data you can assign to a client. It can be read and written at any time.

data
.
params: {
room: string;
}
params
.
room: string
room
,
JSON.stringify({
from: string
from
: socket.data.
name: string
name
,
text: string
text
:
message: {
text: string;
}
message
.
text: string
text
}),
data: {
name: string;
readonly params: {
room: string;
};
}
);
},
close: (socket) => socket.unsubscribe(socket.
data: {
name: string;
readonly params: {
room: string;
};
}

Custom data you can assign to a client. It can be read and written at any time.

data
.
params: {
room: string;
}
params
.
room: string
room
),
}),
}));

The socket is Bun’s own ServerWebSocket: send, subscribe, publish and the rest are the platform’s, unwrapped. Only data is typed by the framework.

The handshake is an ordinary GET and runs the ordinary pipeline: beforeParse hooks, validation of params, query and headers, and beforeHandle hooks. Authentication and rate limiting are the same hooks as on any route, mounted on the endpoint, a group or the application.

A refused handshake is a normal HTTP response. A hook that throws HttpError(401) answers 401 in the error envelope, through onError and the response hooks, and the client’s WebSocket reports a failed connection. A successful handshake has no response, so the response hooks do not run for it.

A plain GET on the path that is not a handshake is answered 426 UPGRADE_REQUIRED.

socket.data is the handshake’s context without the parts that belong to the finished request: path parameters, the validated query and headers, and every field the handshake’s hooks added. An auth hook that adds ctx.user gives every handler a typed socket.data.user.

req, server, out, route and startedAt are left out. A socket can live for hours, and holding the request would hold its headers and body. A hook that needs something from the request adds it as a field, as named does above.

Handler Runs when
open(socket) the socket is open and has its data
message(socket, message) a frame arrived, validated when schema.message is declared
invalid(socket, issues) a frame failed schema.message
close(socket, code, reason) the socket closed, with the peer’s code and reason
drain(socket) backpressure eased and the socket is writable again
ping(socket, data) a ping frame arrived
pong(socket, data) a pong frame arrived

A handler may return anything; a returned promise is awaited only to catch its rejection. A handler that throws or rejects is reported to reportError with source: "websocket", and the socket stays open.

Without schema.message, message receives the frame as Bun gives it: string | Buffer.

With schema.message, the protocol is JSON. A text frame is parsed and validated, and message receives the schema’s output. A frame that is not valid JSON or fails the schema never reaches message, and neither does a binary frame:

  • without invalid, the socket is closed with 1007 and the first issue’s message as the reason;
  • with invalid, that handler receives the issues and decides what to do.

A validator that throws or rejects, rather than reporting issues, is the server’s failure. The socket is closed with 1011 and the error is reported as websocket.

Frames reach message in the order they arrived, even with an asynchronous schema, and none arrives after the socket has closed.

Bun takes one WebSocket handler for the whole server. The application carries it as app.websocket, and Bun.serve({ ...app }) picks it up. It is always present, even when nothing declares a socket.

Bun’s server-level WebSocket options, such as idleTimeout, maxPayloadLength and backpressureLimit, are not proxied. Spread the ones you need over the handler:

const
const app: App<RoutesOf<object[]>>
app
= createApp({
routes: object[]

The topology: a group, a controller, or an array of either.

routes
});
Bun.serve({ ...
const app: App<RoutesOf<object[]>>
app
,
websocket: Bun.WebSocketHandler<unknown>

Enable websockets with

Bun.serve

Upgrade a

Request

to a

ServerWebSocket

with

Server.upgrade

Pass data in

Server.upgrade

to attach data to the

ServerWebSocket.data

property

websocket
: { ...
const app: App<RoutesOf<object[]>>
app
.
websocket: Bun.WebSocketHandler<SocketState>

The WebSocket handler of every socket endpoint in the table.

Always present, even when nothing declares a socket: Bun.serve only lets a route hand a connection over when the server has one, and an application whose type depended on whether it happens to contain a socket endpoint would be a worse trade than an inert handler.

Bun's server-level options — idleTimeout, maxPayloadLength, backpressureLimit — are not proxied, for the same reason tls is not: spread what you need over it.

Bun.serve({ ...app, websocket: { ...app.websocket, idleTimeout: 30 } });

websocket
,
idleTimeout?: number | undefined

Sets the number of seconds to wait before timing out a connection due to no messages or pings.

@default ― 120

idleTimeout
: 30 } });

server.stop() waits for every open socket, and a socket stays open as long as its client wants, so one idle socket can hold a deploy for the whole grace period.

until closes the endpoint’s sockets with 1001 (going away) when a signal fires, and closes any socket opened after that at once. The client reconnects to a server that stays, and close runs as usual. It takes a signal, or a function that returns one, called as each socket opens. The function is the usual form, because the endpoint is declared before the server and its signal exist: until: () => shutdown.draining.

If the function returns nothing, or throws, that socket is not closed by it; a throw is reported as websocket. Health checks and shutdown wires it to the draining signal of @tetsujs/lifecycle.

Socket endpoints are absent from the OpenAPI document and from the typed client’s route map. docs on ws() takes only a summary and a description. The handshake still takes the GET of its path in app.entries, marked with a ws field.