js/net/http/websocket
js/net/http/websocket.ts
WebSocket client and server.
Implements RFC 6455 in two composable layers: WebSocketConnection, the
protocol engine that server handlers and advanced clients drive directly,
and WebSocket, the WHATWG facade installed as a global for browser-style
client code. Both layers are exported from fino:net/http/websocket.
WebSocketConnection (lower-level engine)
Extends EventTarget. Handles framing, masking, UTF-8 validation, close
handshakes, PING/PONG, and subprotocol negotiation. Used directly by
server code or wrapped by the WHATWG WebSocket facade.
import { WebSocketConnection } from 'fino:net/http/websocket';
import { serve } from 'fino:net/http/server';
// CLIENT
const conn = WebSocketConnection.connect('wss://example.com/ws', {
protocols: ['chat.v1'],
});
conn.addEventListener('open', () => conn.send('hello'));
conn.addEventListener('message', (e) => console.log(e.data));
conn.addEventListener('close', (e) => console.log(e.code));
// SERVER (inside a serve() handler)
serve({ port: 3000 }, async (incoming) => {
if (incoming.kind === 'websocket') {
const ws = await incoming.accept({ protocol: 'chat.v1' });
ws.addEventListener('message', (e) => ws.send(`echo: ${e.data}`));
return;
}
await incoming.reject(new Response('hello'));
});
WebSocket (WHATWG facade)
WHATWG-compatible global facade. Wraps a WebSocketConnection and exposes the
browser API surface: constructor validation, CONNECTING/OPEN/CLOSING/
CLOSED states, url, protocol, extensions, bufferedAmount,
binaryType, event handler properties, send(), and close().
const ws = new WebSocket('wss://example.com/ws', ['chat.v1']);
ws.binaryType = 'arraybuffer';
ws.onopen = () => ws.send('hello');
ws.onmessage = (e) => console.log(e.data);
ws.onclose = (e) => console.log('closed', e.code, e.wasClean);
WebSocket API conformance matrix
| Area | Baseline | Coverage |
|---|---|---|
| Constructor | Accepts ws: and wss: URLs, rejects fragments and duplicate requested protocols synchronously. |
tests/net/websocket.test.ts |
| Lifecycle | Starts at CONNECTING, forwards open, message, error, and close, and exposes browser ready-state constants. |
tests/net/websocket.test.ts |
| Sending | send() accepts strings, binary buffers, typed arrays, and blobs once open; pre-open sends throw because this release does not buffer before OPEN. |
tests/net/websocket.test.ts |
| Binary receive | binaryType defaults to blob; arraybuffer switches binary messages to copied ArrayBuffers; invalid assignments throw TypeError without changing the previous value. |
tests/net/websocket.test.ts |
| Close | close() validates application close codes and the 123-byte UTF-8 reason limit synchronously before starting the close handshake. |
tests/net/websocket.test.ts |
| Negotiation properties | protocol reflects the accepted subprotocol, extensions reflects accepted server-side permessage-deflate, and bufferedAmount is numeric. |
tests/net/websocket.test.ts |
| Extensions | Server-side permessage-deflate is negotiated when offered. Unsupported RSV bits still fail protocol validation. |
tests/net/websocket.test.ts |
| HTTP/2 and HTTP/3 | Intentional limit: WebSocket over HTTP/2 (RFC 8441) and HTTP/3 are deferred; this release uses HTTP/1.1 Upgrade only. | tests/net/http2.test.ts and research docs |
Close handshake
Both sides share the same handshake: the initiator sends a CLOSE frame, the peer echoes one back, then the underlying socket is closed. A 5-second timeout is applied after the initiator sends its CLOSE — if the peer does not echo within that time, the socket is torn down unilaterally.
Spec compliance (RFC 6455)
Server-side permessage-deflate follows RFC 7692 with no context takeover
in either direction. The client path does not offer extensions by default
and still rejects unsolicited Sec-WebSocket-Extensions responses.
RFC 8441 WebSocket over HTTP/2 and WebSocket over HTTP/3 are also deferred.
WebSocketConnection is an HTTP/1.1 Upgrade takeover today; HTTP/2 and
HTTP/3 requests that try to hand over a non-H2/H3-compatible takeover are
rejected by those protocol drivers.
- RSV1 is accepted only for data messages when
permessage-deflatewas negotiated; all other unexpected RSV bits fail with 1002 - Unknown/reserved opcodes → 1002
- Control frames must not be fragmented, payload ≤ 125 → 1002
- Client→server frames must be masked; server→client must not → 1002
- CONTINUATION without an active fragment → 1002
- New data frame during an active fragment → 1002
- Payload > maxPayloadSize (default 16 MiB) → 1009
- Invalid UTF-8 in TEXT messages → 1007
- PING → automatic PONG with echoed payload
Learn more:
- WebSocket API: https://websockets.spec.whatwg.org/
- WebSocket Protocol: https://www.rfc-editor.org/rfc/rfc6455
- WebSocket Compression Extensions: https://www.rfc-editor.org/rfc/rfc7692
Classes
class CloseEvent extends Event {
WebSocket close event carrying close code, reason, and cleanliness.
Instances are dispatched for both protocol close handshakes and local
teardown. Code 1006 may be used internally to report abnormal closure.
ws.onclose = (event) => console.log(event.code, event.reason, event.wasClean);
Constructors
constructor(type: string, init?: CloseEventInit)
Create a close event.
Missing fields default to code 0, empty reason, and wasClean: false.
const event = new CloseEvent('close', { code: 1000, reason: 'done', wasClean: true });
Getters
get code()
Close status code.
console.log(event.code);
get reason()
UTF-8 close reason string.
console.log(event.reason);
get wasClean()
Whether the close handshake completed cleanly.
console.log(event.wasClean);
class ErrorEvent extends Event {
WebSocket error event carrying the underlying error value when available.
The error value may be any thrown value, or null when no concrete error
was captured.
ws.onerror = (event) => console.log(event.error);
Constructors
constructor(type: string, init?: {
error?: unknown;
})
Create an error event.
const event = new ErrorEvent('error', { error: new Error('failed') });
Getters
get error()
Underlying error value, or null.
console.log(event.error);
class WebSocketError extends DOMException {
Error object used by the tentative WebSocket stream API.
The class follows the WebSocket close-code validation rules: close codes are
either absent, 1000, or in the application range 3000 through 4999.
A non-empty reason without an explicit close code defaults to 1000.
const error = new WebSocketError('closed', { closeCode: 3000, reason: 'done' });
console.log(error.name, error.closeCode, error.reason);
Constructors
constructor(message = '', init: WebSocketErrorInit = {})
Create a WebSocket stream error.
Invalid close codes throw InvalidAccessError. Reasons longer than 123
UTF-8 bytes throw SyntaxError, matching WebSocket close frames.
new WebSocketError('closed', { reason: 'done' });
Getters
get closeCode(): number | null
WebSocket close code, or null when no code is attached.
get reason(): string
UTF-8 close reason.
class WebSocketConnection extends EventTarget implements ConnectionTakeover {
A WebSocket connection, client or server side. Extends EventTarget.
Events dispatched: 'open', 'message' (MessageEvent), 'close' (CloseEvent),
'error' (ErrorEvent), 'ping', 'pong'. Instances are also async iterable —
for await (const message of conn) yields WebSocketMessage values until
the connection closes — and support await using for scoped cleanup.
Use the static factories:
- WebSocketConnection.connect(url, opts) — client (async via events)
- WebSocketConnection.accept(req, opts) — server (from a serve() handler)
import { WebSocketConnection } from 'fino:net/http/websocket';
const conn = WebSocketConnection.connect('wss://example.com/ws');
conn.onmessage = (event) => console.log(event.data);
Static Readonly Properties
static readonly CONNECTING
Ready state before the handshake completes.
if (conn.readyState === WebSocketConnection.CONNECTING) console.log('connecting');
static readonly OPEN
Ready state while messages may be sent.
if (conn.readyState === WebSocketConnection.OPEN) await conn.send('hello');
static readonly CLOSING
Ready state after close has started.
if (conn.readyState === WebSocketConnection.CLOSING) console.log('closing');
static readonly CLOSED
Ready state after the connection is closed.
if (conn.readyState === WebSocketConnection.CLOSED) console.log('closed');
Readonly Properties
readonly compatibleProtocols: ReadonlySet<string>
HTTP protocols this takeover can run under.
WebSocketConnection currently supports HTTP/1.1 upgrade takeovers.
console.log(conn.compatibleProtocols.has('http/1.1'));
Constructors
constructor()
Create an unconnected WebSocketConnection.
Prefer connect() or accept() because the constructor does not perform a
handshake or attach I/O.
const conn = new WebSocketConnection();
Getters
get role(): 'client' | 'server'
Role: client sends masked frames; server sends unmasked frames.
console.log(conn.role);
get readyState(): number
Current ready state.
console.log(conn.readyState);
get url(): string
WebSocket URL string for this connection.
console.log(conn.url);
get protocol(): string
Negotiated subprotocol, or an empty string.
console.log(conn.protocol || 'none');
get extensions(): string
Negotiated extension string.
console.log(conn.extensions);
get bufferedAmount(): number
Best-effort count of bytes queued for writing.
console.log(conn.bufferedAmount);
get socket(): Socket | null
The underlying socket, available once open fires and null before that.
conn.onopen = () => console.log(conn.socket?.fd);
get onopen()
Callback for open events.
conn.onopen = () => conn.send('hello');
get onmessage()
Callback for message events.
conn.onmessage = (event) => console.log(event.data);
get onerror()
Callback for error events.
conn.onerror = (event) => console.log(event.error);
get onclose()
Callback for close events.
conn.onclose = (event) => console.log(event.code);
Setters
set onopen(fn: ((e: Event) => void) | null)
Set the open callback, or null to clear it.
conn.onopen = null;
set onmessage(fn: ((e: MessageEvent) => void) | null)
Set the message callback, or null to clear it.
conn.onmessage = null;
set onerror(fn: ((e: ErrorEvent) => void) | null)
Set the error callback, or null to clear it.
conn.onerror = null;
set onclose(fn: ((e: CloseEvent) => void) | null)
Set the close callback, or null to clear it.
conn.onclose = null;
Methods
send(data: string | ArrayBuffer | ArrayBufferView | Blob): Promise<void>
Send a text, binary, or Blob message. Returns a Promise that resolves when the frame has been written. Throws if readyState is CONNECTING; silently returns if CLOSING or CLOSED.
await conn.send('hello');
await conn.send(new Uint8Array([1, 2, 3]));
ping(data?: Uint8Array): Promise<void>
Send a PING control frame. The peer should respond with a PONG. Payload must be ≤ 125 bytes.
await conn.ping(new Uint8Array([1]));
pong(data?: Uint8Array): Promise<void>
Send a PONG control frame. Payload must be ≤ 125 bytes.
await conn.pong();
async close(code: number = 1e3, reason: string = ''): Promise<void>
Initiate the WebSocket close handshake.
code defaults to 1000 and must be 1000 or 3000-4999. reason must encode
to at most 123 UTF-8 bytes. Resolves when the peer close arrives or the
close timeout tears down the socket.
await conn.close(1000, 'done');
Static Methods
static accept(req: {
method: string;
url: string;
headers: Headers;
}, opts: WebSocketAcceptOptions = {}): WebSocketConnection
SERVER FACTORY — Synchronously validate an HTTP upgrade request and return a WebSocketConnection in the CONNECTING state. The 'open' event fires after serve() writes the 101 response and hands over the socket.
Throws a plain Error (name='SyntaxError') if the request is not a valid WebSocket upgrade. The handler can catch this and return a 400 Response.
import { serve } from 'fino:net/http/server';
serve({ port: 3000 }, async (incoming) => {
if (incoming.kind === 'websocket') {
const ws = await incoming.accept({ protocol: 'chat.v1' });
ws.addEventListener('message', (e) => ws.send(`echo: ${e.data}`));
return;
}
await incoming.reject(new Response('hello'));
});
static connect(url: string | URL, opts: WebSocketConnectOptions = {}): WebSocketConnection
CLIENT FACTORY — Open a WebSocket connection to a remote URL. Returns a WebSocketConnection immediately in the CONNECTING state. The 'open' event fires when the handshake completes; 'error' + 'close' fire if the connection fails.
import { WebSocketConnection } from 'fino:net/http/websocket';
const ws = WebSocketConnection.connect('wss://example.com/chat', {
protocols: ['chat.v1'],
});
ws.addEventListener('open', () => ws.send('hello'));
ws.addEventListener('message', (e) => console.log(e.data));
class WebSocket extends EventTarget {
WHATWG-compatible WebSocket facade.
For server-side or lower-level control, use WebSocketConnection directly.
This facade intentionally does not buffer send() calls before OPEN.
Client connections do not offer extensions by default, and use the HTTP/1.1
Upgrade path provided by WebSocketConnection; HTTP/2 and HTTP/3 WebSocket
transports are deferred.
const ws = new WebSocket('wss://example.com/ws', ['chat.v1']);
ws.onopen = () => ws.send('hello');
WebSocket API standard: https://websockets.spec.whatwg.org/
Static Readonly Properties
static readonly CONNECTING
Ready state before the handshake completes.
if (ws.readyState === WebSocket.CONNECTING) console.log('connecting');
static readonly OPEN
Ready state while messages can be sent.
if (ws.readyState === WebSocket.OPEN) ws.send('hello');
static readonly CLOSING
Ready state after close has started.
if (ws.readyState === WebSocket.CLOSING) console.log('closing');
static readonly CLOSED
Ready state after the connection is closed.
if (ws.readyState === WebSocket.CLOSED) console.log('closed');
Constructors
constructor(url: string | URL, protocols?: string | string[])
Create a WebSocket and immediately start connecting.
http: and https: inputs are converted to ws: and wss:. Relative URLs
resolve against globalThis.location. URL parse failures, unsupported
schemes, fragments, and duplicate requested protocols throw synchronously.
const ws = new WebSocket('wss://example.com/chat', 'chat.v1');
Getters
get url(): string
WebSocket URL string.
console.log(ws.url);
get readyState(): number
Current ready state.
console.log(ws.readyState);
get bufferedAmount(): number
Best-effort bytes queued for sending.
console.log(ws.bufferedAmount);
get extensions(): string
Negotiated extensions, currently an empty string.
console.log(ws.extensions);
get protocol(): string
Negotiated subprotocol, or an empty string.
console.log(ws.protocol);
get binaryType(): 'blob' | 'arraybuffer'
Binary message conversion mode.
Defaults to blob. Set to arraybuffer to receive binary messages as
ArrayBuffer values.
ws.binaryType = 'arraybuffer';
get onopen()
Callback for open events.
ws.onopen = () => ws.send('hello');
get onmessage()
Callback for message events.
ws.onmessage = (event) => console.log(event.data);
get onerror()
Callback for error events.
ws.onerror = (event) => console.log(event.error);
get onclose()
Callback for close events.
ws.onclose = (event) => console.log(event.code);
Setters
set binaryType(v: 'blob' | 'arraybuffer')
Set binary message conversion mode.
Throws TypeError for values other than blob or arraybuffer.
ws.binaryType = 'blob';
set onopen(fn: ((e: Event) => void) | null)
Set the open callback, or null to clear it.
ws.onopen = null;
set onmessage(fn: ((e: MessageEvent) => void) | null)
Set the message callback, or null to clear it.
ws.onmessage = null;
set onerror(fn: ((e: ErrorEvent) => void) | null)
Set the error callback, or null to clear it.
ws.onerror = null;
set onclose(fn: ((e: CloseEvent) => void) | null)
Set the close callback, or null to clear it.
ws.onclose = null;
Methods
send(data: string | ArrayBuffer | ArrayBufferView | Blob): void
Queue data to be sent. Throws InvalidStateError if CONNECTING; silently drops if CLOSING or CLOSED.
Errors after queuing are surfaced through error events, matching the
fire-and-forget WHATWG API shape.
ws.send('hello');
ws.send(new Uint8Array([1, 2, 3]));
close(code: number = 1e3, reason: string = ''): void
Initiate the close handshake.
code defaults to 1000 and must be 1000 or 3000-4999. reason must encode
to at most 123 UTF-8 bytes. Invalid values throw synchronously.
ws.close(1000, 'done');
Interfaces
interface WebSocketMessage {
Parsed WebSocket message payload used by lower-level connection helpers.
Text messages expose a string. Binary messages expose raw bytes and are not
converted to Blob by WebSocketConnection.
for await (const message of conn) console.log(message.type, message.data);
Properties
type: 'text' | 'binary'
Message kind.
if (message.type === 'binary') console.log(message.data);
data: string | Uint8Array
Message payload.
if (message.type === 'text') console.log(message.data.toUpperCase());
interface WebSocketAcceptOptions {
Options used when accepting a WebSocket upgrade from an HTTP handler.
If both protocol and selectProtocol are omitted, no subprotocol is
selected. Invalid upgrade requests throw synchronously.
const conn = WebSocketConnection.accept(req, { protocol: 'chat.v1' });
Properties
protocol?: string
Single subprotocol to accept; must be offered by the client when present.
WebSocketConnection.accept(req, { protocol: 'chat.v1' });
selectProtocol?: (offered: string[]) => string | null
Callback to select a subprotocol from the list offered by the client.
Return null to accept no subprotocol.
WebSocketConnection.accept(req, { selectProtocol: (offered) => offered[0] ?? null });
maxPayloadSize?: number
Maximum payload size in bytes; defaults to 16 MiB.
Frames exceeding this cause close code 1009.
WebSocketConnection.accept(req, { maxPayloadSize: 1024 * 1024 });
interface WebSocketConnectOptions {
Options used when opening a WebSocket client connection.
Headers are added to the HTTP upgrade request. Duplicate protocol names throw synchronously.
const conn = WebSocketConnection.connect('wss://example.com/ws', { protocols: ['chat.v1'] });
Properties
protocols?: string | string[]
Requested subprotocols, joined as Sec-WebSocket-Protocol.
WebSocketConnection.connect(url, { protocols: ['chat.v1', 'chat.v2'] });
headers?: Record<string, string> | Headers
Extra request headers sent with the upgrade request.
WebSocketConnection.connect(url, { headers: { authorization: 'Bearer token' } });
maxPayloadSize?: number
Maximum incoming payload size in bytes; defaults to 16 MiB.
WebSocketConnection.connect(url, { maxPayloadSize: 1024 * 1024 });