WebSockets
esrun ships the classic WHATWG WebSocket on both sides: a client as a global (like fetch) and a server as the runtime:websocket module. Both speak ws: and wss: (client); messages ride the runtime's event loop — no extra threads.
Client
Construct a WebSocket and listen for open, message, close, and error. Opening a connection needs the Net capability (the esrun CLI grants it).
// WebSocket is a global — like fetch, no import. Opening requires Net. const ws = new WebSocket("wss://example.com/feed", ["json"]); ws.addEventListener("open", () => ws.send(JSON.stringify({ hello: "world" }))); ws.addEventListener("message", (e) => { const msg = JSON.parse(e.data); // e.data is a string for text frames console.log("got", msg); }); ws.addEventListener("close", (e) => console.log("closed", e.code, e.reason)); ws.addEventListener("error", () => console.log("connection failed"));
Binary data
Send strings, ArrayBuffer, typed arrays, or Blob. Set binaryType to choose how binary frames arrive.
const ws = new WebSocket("ws://localhost:9001/"); ws.binaryType = "arraybuffer"; // default is "blob" ws.addEventListener("open", () => ws.send(new Uint8Array([1, 2, 3]).buffer)); ws.addEventListener("message", (e) => { if (typeof e.data === "string") console.log("text", e.data); else console.log("binary", new Uint8Array(e.data)); // ArrayBuffer });
Closing
ws.close(); // normal (1000) ws.close(1000, "bye"); // code 1000 or 3000–4999; reason ≤ 123 UTF-8 bytes // send() while CONNECTING throws InvalidStateError; after close it is ignored.
Server
serve() binds a server (needs NetListen) and yields each accepted connection as an async iterable — every connection has the same send/close and message/close surface as a client socket, already open.
import { serve } from "runtime:websocket"; const server = serve({ hostname: "127.0.0.1", port: 9001 }); const { port } = await server.addr; // resolves once listening console.log("listening on", port); for await (const ws of server) { // each accepted connection ws.addEventListener("message", (e) => ws.send(e.data)); // echo }
Broadcast (chat)
For chat-style fan-out, keep a set of connections and use broadcast() — it delivers one message to the whole room in a single host crossing with one payload copy, so it stays fast and lossless where a per-connection .send() loop would lag under load.
import { serve, broadcast } from "runtime:websocket"; const clients = new Set(); const server = serve({ hostname: "127.0.0.1", port: 9001 }); for await (const ws of server) { clients.add(ws); // broadcast() sends to the whole room in ONE host crossing — far cheaper than // a c.send() loop, with full delivery and coalesced socket writes. ws.addEventListener("message", (e) => broadcast(clients, e.data)); ws.addEventListener("close", () => clients.delete(ws)); }
See the WebSocket benchmarks for fan-out throughput. Pub/sub topics are on the roadmap; wss: works today by upgrading on an HTTPS server, below.
One port for your API and your sockets
The serve() above binds a port of its own. If you already have an HTTP server, take the connection over instead — one port, one certificate, both protocols:
import { serve } from "runtime:http"; import { upgradeWebSocket, broadcast } from "runtime:websocket"; const room = new Set(); serve({ port: 443, secureTransport: "on", cert, key }, (request) => { if (request.headers.get("upgrade") === "websocket") { const { response, socket } = upgradeWebSocket(request); room.add(socket); socket.onmessage = (e) => broadcast(room, e.data); socket.onclose = () => room.delete(socket); return response; } return new Response("api"); // everything else is an ordinary request });
Return the response to accept, anything else to decline. What you get back is an ordinary connection, so broadcast() reaches it alongside any from serve().
Slow peers
send() never stalls — the WebSocket API has no way to report a full buffer, so writing faster than a peer reads queues on the host instead. bufferedAmount is how a sender notices:
for await (const chunk of source) { if (ws.bufferedAmount > 1 << 20) break; // a megabyte behind: stop feeding it ws.send(chunk); }
Ignoring it is bounded rather than fatal: past maxBufferedAmount (8 MiB by default) the host closes that connection with 1013, so one peer that stopped reading costs a connection and not the process. That applies to broadcast() too — the peer that fell behind is the one closed for it.