Websocket
RFC 6455 WebSocket server on top of Node's 'upgrade' event, plus RFC 8441 Extended CONNECT for HTTP/2. Built on node:net + node:crypto + node:zlib with no npm runtime dep.
Three layers exposed to operators:
1. Handshake — handleUpgrade(req, socket, head, opts) for h1 and handleExtendedConnect(stream, headers, opts) for h2. Validate the request, enforce same-origin (default) or an operator-supplied allowlist, negotiate subprotocol + permessage-deflate, return a WebSocketConnection. Refuse credential-shaped query parameters (access_token, apikey, authorization, …) — query strings leak via access logs, Referer headers, and browser history.
2. Connection — WebSocketConnection is an EventEmitter mirroring the browser API. Read conn.readyState ('open' | 'closing' | 'closed'); call conn.send(data), conn.ping(payload?), conn.close(code?, reason?). Listen on 'message', 'ping', 'pong', 'close', 'error'.
3. Frame layer — FrameParser + serializeFrame exposed for tests and advanced callers (custom proxy / multiplexer use cases). Operator code rarely touches these directly.
Defenses wired in by default:
- Same-origin Origin check on browser-initiated upgrades. Cross- site WebSocket hijacking (CSWSH) requires explicit opt-in via origins: [...allowlist] or origins: "*". - Control-frame payload cap of 125 bytes (RFC 6455 §5.5). Without it, a 1 MiB PING echoes back as a 1 MiB PONG — a 2x outbound-bandwidth amplification DoS. - Strict UTF-8 validation on TEXT frames + close reasons (§5.6). - Close-code allowlist per §7.4.2 (1000–1011, 3000–4999; reserved 1004/1005/1006/1015 refused on the wire). - Frame + message length capped at maxMessageBytes (default 1 MiB). - Heartbeat: ping every 30s, abort after 35s without pong. - Cluster fan-out lives at the router/channel layer above this module; this primitive owns the per-connection protocol.
Configurable handshake GUID: closed-ecosystem clients with a custom magic string pass opts.handshakeGuid (UUID-shaped). The default is the RFC 6455 §1.3 value so RFC-compliant clients interoperate out of the box. SHA-1 used in Sec-WebSocket-Accept is a protocol marker, not a security primitive — its collision resistance is irrelevant to the connection's security.
Spec compliance notes (where naive implementations get it wrong):
1. Mask handling (§5.3). All client→server h1 frames MUST be masked; server→client frames MUST NOT be masked. The h2 transport (RFC 8441) flips both: frames MUST NOT be masked because h2 already provides the framing guarantees masking defends. 2. Close handshake reciprocity (§5.5.1). Peer-initiated close echoes a close frame back before ending the TCP socket. 3. Subprotocol negotiation. Server picks the FIRST entry from Sec-WebSocket-Protocol that's in the operator's allowlist. If none match, response omits the header (§11.3.4). 4. permessage-deflate (RFC 7692). Negotiated when the client offers it; runs in no_context_takeover mode in both directions so each message uses a fresh zlib state.
b.websocket.WebSocketError(code, message, closeCode) #
Framework-error subclass thrown for protocol violations + invalid caller input (send() on a closed connection, malformed frame payload, frame-too-large detected at parse time). Carries the RFC 6455 §7.4.1 closeCode the connection layer uses when aborting (1002 protocol error, 1007 invalid payload, 1009 message too big, 1011 internal error). Operators usually catch via the shared b.errors surface and never construct one directly.
try {
conn.send("late message");
} catch (err) {
if (err.isWebSocketError) {
console.log(err.code, err.closeCode);
// → "ws/closed" 1002
}
}
b.websocket.computeAcceptKey(secWebSocketKey, handshakeGuid) #
Compute the Sec-WebSocket-Accept value RFC 6455 §1.3 mandates: base64(SHA1(secWebSocketKey || handshakeGuid)). The SHA-1 is a protocol marker confirming both ends agree on the upgrade — it is NOT a security primitive, and the framework's other crypto stays SHA3 / SHAKE-based regardless. Pass handshakeGuid undefined to use the RFC value (258EAFA5-E914-47DA-95CA-C5AB0DC85B11); pass a UUID-shaped override only for closed-ecosystem clients with a matching custom magic string.
var accept = b.websocket.computeAcceptKey("dGhlIHNhbXBsZSBub25jZQ==");
// → "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
b.websocket.validateUpgradeRequest(req, opts) #
{
allowQueryAuthParams: boolean, // opt out of credential-query refusal
}
Strict shape check on the HTTP/1.1 upgrade request. Verifies the method is GET, Upgrade: websocket and Connection: upgrade tokens are present, Sec-WebSocket-Version: 13, and Sec-WebSocket-Key is a 24-character base64 of 16 random bytes (RFC 6455 §4.1). Refuses credential-shaped query parameters (access_token, apikey, authorization, …) unless the operator passes opts.allowQueryAuthParams: true with an audited reason. Returns { ok: true } on success or { ok: false, status, reason } on refusal — never throws, so the caller can write the refusal response and end the socket cleanly.
var v = b.websocket.validateUpgradeRequest(req, {});
if (!v.ok) {
// → { ok: false, status: 400, reason: "..." }
socket.write("HTTP/1.1 " + v.status + " Bad Request\r\n\r\n");
socket.destroy();
}
b.websocket.negotiateSubprotocol(req, supported) #
Pick the first client-offered subprotocol that appears in supported. Returns the chosen string, or null when there is no intersection (per RFC 6455 §11.3.4 the response then omits the Sec-WebSocket-Protocol header and the client decides whether to proceed). supported falsy or empty is treated as "no preference" and always returns null.
var req = { headers: { "sec-websocket-protocol": "chat.v2, chat.v1" } };
var picked = b.websocket.negotiateSubprotocol(req, ["chat.v1"]);
// → "chat.v1"
b.websocket.isOriginAllowed(req, origins) #
Browser-Origin policy gate. Behaviour by the origins shape:
- Array — strict allowlist; the request's Origin header must match one entry exactly. - "*" — explicit accept-all (operator opt-in to no checking). - null / undefined — DEFAULT same-origin: the Origin host must match the Host header. Closes the cross-site WebSocket hijacking (CSWSH) class on browser-targeted routes.
Non-browser clients (curl, server-to-server, native apps) don't send Origin and bypass the check — gating those callers is the operator's network-ACL / auth-middleware job, not Origin's.
var req = { headers: { origin: "https://app.example.com",
host: "app.example.com" } };
b.websocket.isOriginAllowed(req, undefined); // → true
b.websocket.isOriginAllowed(req, ["https://other.example"]); // → false
b.websocket.isOriginAllowed(req, "*"); // → true
b.websocket.buildUpgradeResponse(secWebSocketKey, subprotocol, extensionHeader, handshakeGuid) #
Format the HTTP/1.1 101 Switching Protocols response that completes the WebSocket handshake. Always emits Upgrade: websocket, Connection: Upgrade, and Sec-WebSocket-Accept. Adds Sec-WebSocket-Protocol when subprotocol is non-null, and Sec-WebSocket-Extensions when extensionHeader is non-null (e.g. the permessage-deflate; ... echo). Pass handshakeGuid undefined to use the RFC 6455 default. Returns the raw \r\n-delimited response string ready for socket.write().
var resp = b.websocket.buildUpgradeResponse(
"dGhlIHNhbXBsZSBub25jZQ==", "chat.v1", null);
// → "HTTP/1.1 101 Switching Protocols\r\n..."
socket.write(resp);
b.websocket.FrameParser(opts) #
{
maxFrameBytes: number, // single-frame payload cap (default 1 MiB)
}
Incremental RFC 6455 §5.2 frame parser. push(chunk) accepts arbitrary buffer slices straight from the socket and returns zero or more complete frames; partial frame state persists across calls. Each emitted frame is { fin, rsv1, rsv2, rsv3, opcode, masked, payload }. Throws a WebSocketError (closeCode = 1009 message-too-big) when a single frame's declared payload length exceeds opts.maxFrameBytes (default 1 MiB) — the caller catches it and aborts the connection. The parser does NOT enforce control-frame ≤125-byte caps, mask-direction policy, or RSV-bit-vs-extension consistency; those are the connection layer's job.
var parser = new b.websocket.FrameParser({ maxFrameBytes: 65536 });
var frames = parser.push(Buffer.from([0x81, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f]));
// → [{ fin: true, opcode: 1, payload: , ... }]
b.websocket.serializeFrame(opcode, payload, opts) #
{
fin: boolean, // FIN bit, default true (single-frame message)
mask: boolean, // mask the payload, default false (server side)
rsv1: boolean, // RSV1 bit (permessage-deflate), default false
}
Build a single RFC 6455 §5.2 frame. opcode is one of b.websocket.OPCODE_TEXT / OPCODE_BINARY / OPCODE_CLOSE / OPCODE_PING / OPCODE_PONG / OPCODE_CONTINUATION. payload is a Buffer or string (string is UTF-8 encoded). Server-side frames default to unmasked; pass mask: true only for client-shaped fixtures or test harnesses. rsv1: true marks the first frame of a permessage-deflate-compressed message (RFC 7692). Returns the framed Buffer.
var frame = b.websocket.serializeFrame(
b.websocket.OPCODE_TEXT, "hello");
// →
socket.write(frame);
b.websocket.WebSocketConnection(socket, opts) #
{
subprotocol: string, // negotiated value from handleUpgrade
transport: "h1" | "h2", // mask-direction policy, default "h1"
maxMessageBytes: number, // total reassembled-message cap, default 1 MiB
pingIntervalMs: number, // heartbeat interval, default 30s
pongTimeoutMs: number, // abort threshold without pong, default 35s
closeGraceMs: number, // peer-echo wait after close(), default 2s
permessageDeflate: object | null, // negotiated state, usually from handleUpgrade
}
EventEmitter wrapping a post-upgrade socket / h2 stream. State machine mirrors the browser WebSocket API:
- conn.readyState — 'open' | 'closing' | 'closed' - conn.send(data) — Buffer or string. Routes to BINARY or TEXT frame; throws WebSocketError if not OPEN. - conn.ping(payload?) — send PING (no-op if not OPEN). - conn.close(code?, reason?) — send CLOSE, wait closeGraceMs for the peer's echo, end the underlying socket.
Events: 'message' (data, isBinary), 'ping' (payload), 'pong' (payload), 'close' (code, reason, wasClean) (fires exactly once at lifecycle end), 'error' (err).
Cluster fan-out / channel broadcast lives at the router layer that owns the connection registry; this primitive owns the per- connection protocol. To broadcast, iterate the operator-side registry and call send on each.
server.on("upgrade", function (req, socket, head) {
var conn = b.websocket.handleUpgrade(req, socket, head, {
subprotocols: ["chat.v1"],
});
if (!conn) return;
conn.on("message", function (data, isBinary) {
conn.send(isBinary ? data : "echo: " + data);
});
conn.on("ping", function (payload) { void payload; }); // framework auto-pongs
conn.on("pong", function (payload) { void payload; }); // heartbeat reply
conn.on("close", function (code, reason, wasClean) {
// → 1000, "", true
});
});
b.websocket.handleUpgrade(req, socket, head, opts) #
{
origins: string[] | "*", // allowlist, or "*" accept-all; default same-origin
subprotocols: string[], // negotiation allowlist
handshakeGuid: string, // UUID-shape override of RFC 6455 §1.3 GUID
permessageDeflate: boolean, // RFC 7692 negotiation, default true
maxMessageBytes: number, // total message cap, default 1 MiB
pingIntervalMs: number, // heartbeat interval, default 30s
pongTimeoutMs: number, // abort-after-silence, default 35s
allowQueryAuthParams: boolean, // opt out of credential-query refusal
}
RFC 6455 HTTP/1.1 upgrade entry point. Wire it to the HTTP server's 'upgrade' event. Validates the handshake, enforces the Origin policy (same-origin by default), negotiates subprotocol + permessage-deflate, writes the 101 response, and returns a WebSocketConnection. Returns null and writes a refusal HTTP response on bad handshake / origin mismatch — the caller does not need a try/catch around the normal refusal paths. Throws synchronously only when opts.handshakeGuid is supplied with a malformed value (config-time typo).
var http = require("http");
var server = http.createServer();
server.on("upgrade", function (req, socket, head) {
var conn = b.websocket.handleUpgrade(req, socket, head, {
origins: ["https://app.example.com"],
subprotocols: ["chat.v1"],
});
if (!conn) return; // refusal already written + socket destroyed
conn.on("message", function (data, isBinary) {
// → "hello", false
conn.send("ack: " + data);
});
});
b.websocket.handleExtendedConnect(stream, requestHeaders, opts) #
{
origins: string[] | "*", // allowlist / accept-all; default same-origin
subprotocols: string[], // negotiation allowlist
maxMessageBytes: number, // total message cap, default 1 MiB
pingIntervalMs: number, // heartbeat interval, default 30s
pongTimeoutMs: number, // abort-after-silence, default 35s
}
RFC 8441 Extended CONNECT entry point for HTTP/2. Wire it to the h2 server's 'stream' event when :method is CONNECT and :protocol is websocket. Same Origin / subprotocol policy as handleUpgrade. Responds with :status 200 (NOT 101 — Extended CONNECT is a CONNECT, not an Upgrade) and returns a WebSocketConnection wrapping the h2 stream with mask-direction flipped (h2 frames MUST NOT be masked). The h2 server must advertise SETTINGS_ENABLE_CONNECT_PROTOCOL = 1; pass settings: { enableConnectProtocol: true } to http2.createSecureServer. Returns null on refusal.
var http2 = require("http2");
var server = http2.createSecureServer({
key: fs.readFileSync("/etc/blamejs/tls.key"),
cert: fs.readFileSync("/etc/blamejs/tls.crt"),
settings: { enableConnectProtocol: true },
});
server.on("stream", function (stream, headers) {
if (headers[":method"] !== "CONNECT") return;
var conn = b.websocket.handleExtendedConnect(stream, headers, {
origins: ["https://app.example.com"],
subprotocols: ["chat.v1"],
});
if (!conn) return;
conn.close(1000, "shutdown"); // → 1000, "shutdown", true on the peer's 'close'
});
Last updated 2026-08-08T16:39:49.652Z by seeder.