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.

Learn more:

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 });