Realtime with WebSockets
A +server.ts file that exports websocket accepts WebSocket connections at its URL. The handlers are Bun's own WebSocket handlers, so everything Bun's sockets can do, including topics, works here. This guide builds a small chat room.
The endpoint
import type { ServerWebSocket } from "bun";
type Socket = ServerWebSocket<{ name: string }>;
export function upgrade({ url }: { url: URL }) {
const name = url.searchParams.get("name")?.slice(0, 20) || "anonymous";
return { name };
}
export const websocket = {
open(ws: Socket) {
ws.subscribe("room");
ws.publish("room", `${ws.data.name} joined`);
},
message(ws: Socket, message: string | Buffer) {
ws.publish("room", `${ws.data.name}: ${message}`);
ws.send(`you: ${message}`);
},
close(ws: Socket) {
ws.publish("room", `${ws.data.name} left`);
},
};upgraderuns before the connection is accepted. Whatever object it returns is merged intows.data, next toparams. Throwing refuses the connection with 426.open,message,closeanddrainare called with the socket.ws.subscribe(topic)andws.publish(topic, message)are Bun's pub/sub:publishsends to every other subscriber of the topic.
The same file can also export GET, POST and the other methods for plain HTTP requests to the URL (Server routes).
Connecting from a component
const Chat = () => {
let lines = $state<string[]>([]);
let draft = $state("");
let socket: WebSocket | undefined;
$effect(() => {
const scheme = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${scheme}://${location.host}/chat?name=ada`);
ws.onmessage = (e) => lines.push(String(e.data));
socket = ws;
return () => ws.close();
});
function send(e: SubmitEvent) {
e.preventDefault();
if (!draft) return;
socket?.send(draft);
draft = "";
}
return (
<main>
<ul>
{lines.map((line) => (
<li>{line}</li>
))}
</ul>
<form onSubmit={send}>
<input bind:value={draft} placeholder="Say something" />
<button>Send</button>
</form>
</main>
);
};
export default Chat;A page and a +server.ts can share a folder: requests that ask for a WebSocket go to websocket, and the page renders as usual for everything else.
$effect only runs in the browser, so the socket opens after the page hydrates. The function it returns runs when the page is torn down, so navigating away closes the socket.
Who's connecting
upgrade receives request, params, url and locals. hooks.server.ts has already run by then, so a signed-in user set by handle (Sessions and auth) is on locals:
export function upgrade({ locals }: { locals: App.Locals }) {
if (!locals.user) throw new Error("not signed in");
return { name: locals.user.email };
}The browser sends the site's cookies with the WebSocket request, so the session cookie is there as for any page.
Several workers
sigil start runs one worker by default, and every socket lives in it. With more workers (--scale, see Deploying), each socket is handled by one worker, picked from the visitor's cookies so a returning visitor lands on the same one. publish only reaches subscribers in the same worker, so a room spread across workers needs a shared channel between them, such as Redis pub/sub.