WebSocket from scratch — RFC 6455 handshake, framing, and close handshake in dependency-free TypeScript, tested against native clients.
Every realtime feature I'd shipped treated new WebSocket(url) as magic.
So I read the RFC and built the protocol layer by hand: the HTTP upgrade
and its SHA-1 challenge, the 2–14 byte frame headers, client masking,
fragment reassembly, ping/pong, and the close handshake — then proved it
interoperates with the WebSocket client shipped inside Node itself.
The study notes in docs/ are the other half
of the repo.
import { createWsServer, connect } from "./src/index.js";
const ws = createWsServer((conn) => {
conn.send("welcome"); // works with any browser
conn.onMessage = (data) => conn.send(data); // WebSocket client
});
ws.server.listen(8080);
const conn = await connect({ host: "localhost", port: 8080 });
conn.onMessage = (data) => console.log(data); // → "welcome" one HTTP request… …then never HTTP again
┌──────────────────┐ ┌─────────────────────────────────┐
│ GET / HTTP/1.1 │ │ ┌─┬─────────┬─────────────────┐ │
│ Upgrade: websocket│ │ │F│ opcode │ len (7/16/64) │ │
│ Sec-WebSocket-Key│ ───▶ │ ├─┴─────────┴─────────────────┤ │
├──────────────────┤ │ │ mask-key (client→server) │ │
│ 101 + Accept = │ │ ├─────────────────────────────┤ │
│ base64(sha1(key+ │ │ │ payload (XOR-masked if client)│ │
│ GUID)) │ │ └─────────────────────────────┘ │
└──────────────────┘ │ text · binary · ping · pong · │
│ close (a handshake of its own)│
└─────────────────────────────────┘
| Layer | File | What's in it |
|---|---|---|
| Frames | src/frame.ts |
encode + streaming parse, masking XOR, minimal length encoding, control-frame rules |
| Handshake | src/handshake.ts |
Key↔Accept (RFC sample vector pinned), request validation, subprotocols |
| Connection | src/connection.ts |
fragment reassembly, interleaved control frames, UTF-8 validation (1007), close handshake, close codes |
| Server | src/server.ts |
http.Server upgrade handling, connection set, broadcast, the pause/unshift/resume handoff |
| Client | src/client.ts |
raw TCP (node:net), speaks the handshake itself, masked frames |
| Resiliency | src/resiliency.ts |
what the browser API doesn't give you: reconnect (full-jitter backoff) + heartbeat |
git clone https://github.com/wjdjdakf17/mini-ws && cd mini-ws
npm install
npm test # 35 tests: unit (in-memory duplex pairs) + real-TCP e2e
npm run demo # → http://localhost:8080 — a browser page (native WebSocket)
# chatting with this from-scratch server, RTT probe includedThe e2e suite includes the compliance proof: Node's built-in (undici) WebSocket client connecting, exchanging messages, and closing cleanly against this server. If the handshake math or framing were off by a byte, it would refuse us.
Three of those tests exist because the bugs were real: a first-frame loss
at the HTTP→WS handoff, a close-handshake deadlock (the server must hang
up TCP first — RFC §7.1.1), and http.Server.closeAllConnections()
silently skipping upgraded sockets. Each story is in
docs/05.
- Why WebSockets exist — polling → long-poll → SSE → WS, and the cost of full-duplex
- The handshake — Upgrade mechanics, why SHA-1 isn't for security, subprotocols
- Framing — byte-level anatomy, why clients must mask, fragmentation, close codes
- What the browser adds (and doesn't) — API limits, Socket.IO's trade, WebSocketStream, WebTransport Baseline 2026
- Production realities — heartbeats, jittered backoff, LB/proxy quirks, the three bugs this repo's tests caught
- Design decisions — ADRs, and what to build next
- References
No permessage-deflate, no extensions negotiation, no HTTP/2 bootstrap
(RFC 8441), no rooms/pub-sub, no reconnect UI — each is a deliberate,
documented next step (docs/06). For
production use ws or Socket.IO; read this first so you know what they're
doing for you.
Companion repo: mini-harness — the same from-scratch treatment for AI agent harnesses.