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).

JavaScript
// 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.

JavaScript
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

JavaScript
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.

JavaScript
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.

JavaScript
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:

JavaScript
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:

JavaScript
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.

Last updated on
Edit this page