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:
constnamed= hook.beforeParse((ctx) => {
const
constname:string|null
name=newURL(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.
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.
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
constapp:App<RoutesOf<object[]>>
app=createApp({
routes: object[]
The topology: a group, a controller, or an array of either.
routes });
Bun.serve({ ...
constapp: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: { ...
constapp: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.
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.