SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+407
View File
@@ -0,0 +1,407 @@
import { EventEmitter } from 'node:events';
import net from 'node:net';
import tls from 'node:tls';
import crypto from 'node:crypto';
import { URL } from 'node:url';
import type { Socket } from 'node:net';
import native from './native';
import {
type AcceptedPerMessageDeflate,
type PerMessageDeflateConfig,
offerPerMessageDeflate,
parseAcceptedPerMessageDeflate,
} from './extensions';
import {
WebSocket as InternalWebSocket,
CONNECTING,
OPEN,
CLOSING,
CLOSED,
isValidCloseCode,
type SendCallback,
type SendOptions,
} from './websocket';
interface ParsedHandshakeResponse {
statusCode: number;
statusMessage: string;
headers: Record<string, string>;
}
function parseHeaders(headerBlock: string): ParsedHandshakeResponse {
const lines = headerBlock.split('\r\n');
const status = lines.shift() as string;
const m = /^HTTP\/1\.1\s+(\d+)\s*(.*)$/.exec(status);
if (!m) {
throw new Error('Bad HTTP status line: ' + status);
}
const headers: Record<string, string> = Object.create(null);
for (const line of lines) {
if (!line) {
continue;
}
const idx = line.indexOf(':');
if (idx < 0) {
continue;
}
const k = line.slice(0, idx).trim().toLowerCase();
const v = line.slice(idx + 1).trim();
headers[k] = v;
}
return { statusCode: Number(m[1]), statusMessage: m[2] ?? '', headers };
}
export interface WebSocketClientOptions {
maxPayload?: number;
perMessageDeflate?: boolean | PerMessageDeflateConfig;
headers?: Record<string, string>;
socketOptions?: net.NetConnectOpts | tls.ConnectionOptions;
// Convenience: any TLS option also accepted at top-level (mirrors the `ws` API).
[key: string]: unknown;
}
export class WebSocketClient extends EventEmitter {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
static Server: unknown;
private readonly _url: URL;
private readonly _secure: boolean;
private _readyState: number;
private readonly _maxPayload: number | undefined;
private _ws: InternalWebSocket | null = null;
private _pendingQueue: Array<{
data: Parameters<InternalWebSocket['send']>[0];
options?: SendOptions;
cb?: SendCallback;
}> = [];
private readonly _perMessageDeflate: boolean | PerMessageDeflateConfig | undefined;
private readonly _protocols: string[] | undefined;
public protocol = '';
public extensions = '';
private readonly _socket: Socket;
private _handshakeBuf: Buffer = Buffer.alloc(0);
private _wsKey: string;
private _expectedAccept: string;
constructor(
address: string,
protocols?: string | string[] | WebSocketClientOptions,
options?: WebSocketClientOptions,
) {
super();
if (typeof protocols === 'object' && !Array.isArray(protocols) && protocols !== null) {
options = protocols as WebSocketClientOptions;
protocols = undefined;
}
options = options || {};
this._url = new URL(address);
if (this._url.protocol !== 'ws:' && this._url.protocol !== 'wss:') {
throw new Error('Only ws: and wss: URLs are supported');
}
this._secure = this._url.protocol === 'wss:';
this._readyState = CONNECTING;
this._maxPayload = options.maxPayload;
this._perMessageDeflate = options.perMessageDeflate;
this._protocols = Array.isArray(protocols)
? protocols.map(String)
: typeof protocols === 'string'
? [protocols]
: undefined;
const host = this._url.hostname;
const port = this._url.port ? Number(this._url.port) : this._secure ? 443 : 80;
// Generate random 16-byte Sec-WebSocket-Key per RFC 6455 §4.1.
const keyBytes = crypto.randomBytes(16);
this._wsKey = keyBytes.toString('base64');
this._expectedAccept = native.computeAcceptKey(this._wsKey);
const connectOpts: net.NetConnectOpts & tls.ConnectionOptions = {
host,
port,
...(options.socketOptions || {}),
} as net.NetConnectOpts & tls.ConnectionOptions;
const onConnect = () => {
if (this._readyState !== CONNECTING) {
return;
}
const pathAndQuery = (this._url.pathname || '/') + (this._url.search || '');
const hostHeader =
this._url.port && Number(this._url.port) !== (this._secure ? 443 : 80)
? `${host}:${this._url.port}`
: host;
const reqLines = [
`GET ${pathAndQuery} HTTP/1.1`,
`Host: ${hostHeader}`,
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Key: ${this._wsKey}`,
'Sec-WebSocket-Version: 13',
];
if (this._protocols && this._protocols.length > 0) {
reqLines.push(`Sec-WebSocket-Protocol: ${this._protocols.join(', ')}`);
}
const extensionOffer = offerPerMessageDeflate(this._perMessageDeflate);
if (extensionOffer) {
reqLines.push(`Sec-WebSocket-Extensions: ${extensionOffer}`);
}
if (options!.headers) {
for (const [k, v] of Object.entries(options!.headers)) {
reqLines.push(`${k}: ${v}`);
}
}
reqLines.push('', '');
this._socket.write(reqLines.join('\r\n'));
};
if (
this._secure &&
(connectOpts as tls.ConnectionOptions).servername === undefined &&
net.isIP(host) === 0
) {
(connectOpts as tls.ConnectionOptions).servername = host;
}
this._socket = this._secure
? tls.connect(connectOpts as tls.ConnectionOptions, onConnect)
: net.connect(connectOpts as net.NetConnectOpts, onConnect);
this._socket.setNoDelay?.(true);
this._socket.on('data', (chunk: Buffer) => this._onHandshakeData(chunk));
this._socket.on('error', (err: Error) => this._onEarlyError(err));
this._socket.on('close', () => this._onEarlyClose());
}
get readyState(): number {
return this._ws ? this._ws.readyState : this._readyState;
}
private _onHandshakeData(chunk: Buffer): void {
if (this._readyState !== CONNECTING) {
return;
}
this._handshakeBuf = Buffer.concat([this._handshakeBuf, chunk]);
const idx = this._handshakeBuf.indexOf('\r\n\r\n');
if (idx < 0) {
if (this._handshakeBuf.length > 16384) {
this._abort('Handshake response too large');
}
return;
}
const headBlock = this._handshakeBuf.slice(0, idx).toString('latin1');
const rest = this._handshakeBuf.slice(idx + 4);
let parsed: ParsedHandshakeResponse;
try {
parsed = parseHeaders(headBlock);
} catch (e) {
return this._abort((e as Error).message);
}
if (parsed.statusCode !== 101) {
return this._abort(`Unexpected response status ${parsed.statusCode}`);
}
if ((parsed.headers['upgrade'] || '').toLowerCase() !== 'websocket') {
return this._abort('Missing/invalid Upgrade header');
}
if (!/\bupgrade\b/i.test(parsed.headers['connection'] || '')) {
return this._abort('Missing Connection: Upgrade header');
}
if (parsed.headers['sec-websocket-accept'] !== this._expectedAccept) {
return this._abort('Invalid Sec-WebSocket-Accept');
}
let acceptedExtensions: AcceptedPerMessageDeflate | null;
try {
acceptedExtensions = parseAcceptedPerMessageDeflate(
parsed.headers['sec-websocket-extensions'],
this._perMessageDeflate,
);
} catch (e) {
return this._abort((e as Error).message);
}
if (parsed.headers['sec-websocket-extensions'] && !acceptedExtensions) {
return this._abort('Unexpected Sec-WebSocket-Extensions');
}
const protocol = parsed.headers['sec-websocket-protocol'] || '';
if (protocol && (!this._protocols || !this._protocols.includes(protocol))) {
return this._abort('Unexpected Sec-WebSocket-Protocol');
}
this._socket.removeAllListeners('data');
this._socket.removeAllListeners('error');
this._socket.removeAllListeners('close');
this._handshakeBuf = Buffer.alloc(0);
this._readyState = OPEN;
this._ws = new InternalWebSocket(this._socket, {
isServer: false,
maxPayload: this._maxPayload,
extensions: acceptedExtensions ? { perMessageDeflate: acceptedExtensions } : undefined,
protocol,
readyState: OPEN,
});
this.protocol = protocol;
this.extensions = acceptedExtensions ? 'permessage-deflate' : '';
this._ws.on('message', (d: Buffer, isBinary: boolean) => this.emit('message', d, isBinary));
this._ws.on('ping', (d: Buffer) => this.emit('ping', d));
this._ws.on('pong', (d: Buffer) => this.emit('pong', d));
this._ws.on('close', (c: number, r: string) => this.emit('close', c, r));
this._ws.on('error', (e: Error) => this.emit('error', e));
this.emit('upgrade', parsed);
this.emit('open');
if (rest.length > 0) {
this._ws._onData(rest);
}
if (this._ws.readyState !== OPEN) {
this._failPendingSends(new Error('WebSocket is not open'));
return;
}
const pending = this._pendingQueue;
this._pendingQueue = [];
for (const entry of pending) {
this._ws.send(entry.data, entry.options, entry.cb);
}
}
private _onEarlyError(err: Error): void {
if (this._ws || this._readyState === CLOSED) {
return;
}
this._stopHandshake(err);
this.emit('error', err);
}
private _onEarlyClose(): void {
if (this._ws || this._readyState === CLOSED) {
return;
}
this._stopHandshake(new Error('WebSocket closed before handshake completed'));
this._readyState = CLOSED;
this.emit('close', 1006, '');
}
private _failPendingSends(err: Error): void {
const pending = this._pendingQueue;
this._pendingQueue = [];
// Release queued payloads before invoking user code. Each callback is
// scheduled independently so reentrant sends cannot rejoin this queue.
for (const entry of pending) {
if (entry.cb) {
process.nextTick(entry.cb, err);
}
}
}
private _stopHandshake(err: Error): void {
if (this._readyState !== CONNECTING) {
return;
}
this._readyState = CLOSING;
this._handshakeBuf = Buffer.alloc(0);
this._failPendingSends(err);
this._socket.destroy();
}
private _abort(msg: string): void {
this._onEarlyError(new Error('WebSocket handshake failed: ' + msg));
this._onEarlyClose();
}
send(
data: Parameters<InternalWebSocket['send']>[0],
options?: SendOptions | SendCallback,
cb?: SendCallback,
): void {
if (this._ws) {
return this._ws.send(data, options, cb);
}
if (typeof options === 'function') {
cb = options;
options = undefined;
}
if (this._readyState !== CONNECTING) {
const err = new Error('WebSocket is not open');
if (cb) {
cb(err);
return;
}
throw err;
}
this._pendingQueue.push({ data, options, cb });
}
ping(data?: Buffer | string | SendCallback, mask?: unknown, cb?: SendCallback): void {
this._ws?.ping(data, mask, cb);
}
pong(data?: Buffer | string | SendCallback, mask?: unknown, cb?: SendCallback): void {
this._ws?.pong(data, mask, cb);
}
close(code?: number, reason?: string): void {
if (code !== undefined && !isValidCloseCode(code)) {
throw new RangeError(`Invalid close code: ${code}`);
}
if (this._ws) {
return this._ws.close(code, reason);
}
this._stopHandshake(new Error('WebSocket closed before handshake completed'));
}
terminate(): void {
if (this._ws) {
return this._ws.terminate();
}
this._stopHandshake(new Error('WebSocket terminated before handshake completed'));
}
}
+288
View File
@@ -0,0 +1,288 @@
import zlib from 'node:zlib';
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
export interface PerMessageDeflateConfig {
clientNoContextTakeover?: boolean;
serverNoContextTakeover?: boolean;
threshold?: number;
}
export interface AcceptedPerMessageDeflate {
enabled: true;
requestNoContextTakeover: boolean;
responseNoContextTakeover: boolean;
threshold: number;
}
interface ParsedOffer {
name: string;
params: Record<string, string | true>;
}
export function parseHeader(header: string | undefined | null): ParsedOffer[] {
const offers: ParsedOffer[] = [];
if (!header) {
return offers;
}
for (const rawOffer of String(header).split(',')) {
const parts = rawOffer
.split(';')
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) {
continue;
}
const name = (parts.shift() as string).toLowerCase();
const params: Record<string, string | true> = Object.create(null);
for (const part of parts) {
const eq = part.indexOf('=');
if (eq === -1) {
params[part.toLowerCase()] = true;
} else {
const key = part.slice(0, eq).trim().toLowerCase();
let value = part.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
params[key] = value;
}
}
offers.push({ name, params });
}
return offers;
}
function normalizePerMessageDeflateOptions(value: unknown): PerMessageDeflateConfig | null {
if (value === undefined || value === false || value === null) {
return null;
}
if (value === true) {
return { clientNoContextTakeover: true, serverNoContextTakeover: true };
}
if (typeof value === 'object') {
return {
clientNoContextTakeover: true,
serverNoContextTakeover: true,
...(value as PerMessageDeflateConfig),
};
}
return { clientNoContextTakeover: true, serverNoContextTakeover: true };
}
export function acceptPerMessageDeflate(
requestHeader: string | undefined,
options: unknown,
): { header: string; options: AcceptedPerMessageDeflate } | null {
const config = normalizePerMessageDeflateOptions(options);
if (!config) {
return null;
}
const offer = parseHeader(requestHeader).find((item) => item.name === 'permessage-deflate');
if (!offer) {
return null;
}
const responseParams: string[] = [];
if (config.serverNoContextTakeover !== false || offer.params.server_no_context_takeover) {
responseParams.push('server_no_context_takeover');
}
if (config.clientNoContextTakeover !== false || offer.params.client_no_context_takeover) {
responseParams.push('client_no_context_takeover');
}
const accepted: AcceptedPerMessageDeflate = {
enabled: true,
requestNoContextTakeover: responseParams.includes('client_no_context_takeover'),
responseNoContextTakeover: responseParams.includes('server_no_context_takeover'),
threshold: config.threshold ?? 1024,
};
return {
header: ['permessage-deflate', ...responseParams].join('; '),
options: accepted,
};
}
export function offerPerMessageDeflate(options: unknown): string | null {
const config = normalizePerMessageDeflateOptions(options);
if (!config) {
return null;
}
const params = ['permessage-deflate'];
if (config.clientNoContextTakeover !== false) {
params.push('client_no_context_takeover');
}
if (config.serverNoContextTakeover !== false) {
params.push('server_no_context_takeover');
}
return params.join('; ');
}
export function parseAcceptedPerMessageDeflate(
responseHeader: string | undefined,
requestedOptions: unknown,
): AcceptedPerMessageDeflate | null {
const config = normalizePerMessageDeflateOptions(requestedOptions);
if (!config) {
return null;
}
const accepted = parseHeader(responseHeader).find((item) => item.name === 'permessage-deflate');
if (!accepted) {
return null;
}
for (const key of Object.keys(accepted.params)) {
if (key !== 'client_no_context_takeover' && key !== 'server_no_context_takeover') {
throw new Error(`Unsupported permessage-deflate parameter: ${key}`);
}
}
return {
enabled: true,
requestNoContextTakeover: !!accepted.params.server_no_context_takeover,
responseNoContextTakeover: !!accepted.params.client_no_context_takeover,
threshold: config.threshold ?? 1024,
};
}
export function compressRaw(data: Buffer): Buffer {
const compressed = zlib.deflateRawSync(data, { flush: zlib.constants.Z_SYNC_FLUSH });
if (compressed.length >= 4 && compressed.subarray(compressed.length - 4).equals(TRAILER)) {
return compressed.subarray(0, compressed.length - 4);
}
return compressed;
}
export function decompressRaw(data: Buffer, maxPayload?: number): Buffer {
let inflated: Buffer;
try {
inflated = zlib.inflateRawSync(Buffer.concat([data, TRAILER]), {
finishFlush: zlib.constants.Z_SYNC_FLUSH,
// A post-inflate length check is too late for a compression bomb: zlib
// has already allocated the expanded buffer by then. Node enforces this
// limit while producing output. maxOutputLength cannot be zero, so keep
// the allocation ceiling at one byte for the maxPayload=0 edge case and
// let the exact check below reject any non-empty message.
...(maxPayload === undefined ? {} : { maxOutputLength: Math.max(1, maxPayload) }),
});
} catch (cause) {
const zlibError = cause as Error & { code?: string };
if (maxPayload !== undefined && zlibError.code === 'ERR_BUFFER_TOO_LARGE') {
const err = new Error(`Message too large after inflate (maxPayload=${maxPayload})`, {
cause,
}) as Error & { code?: number };
err.code = 1009;
throw err;
}
throw cause;
}
if (maxPayload !== undefined && inflated.length > maxPayload) {
const err = new Error(
`Message too large after inflate (maxPayload=${maxPayload})`,
) as Error & {
code?: number;
};
err.code = 1009;
throw err;
}
return inflated;
}
export function normalizeProtocolList(
protocols: string | string[] | Set<string> | undefined | null,
): string[] {
if (!protocols) {
return [];
}
if (typeof protocols === 'string') {
return protocols
.split(',')
.map((p) => p.trim())
.filter(Boolean);
}
if (Array.isArray(protocols)) {
return protocols
.map(String)
.map((p) => p.trim())
.filter(Boolean);
}
if (protocols instanceof Set) {
return Array.from(protocols)
.map(String)
.map((p) => p.trim())
.filter(Boolean);
}
return [];
}
export function chooseSubprotocol(
requestHeader: string | undefined,
protocols:
| string
| string[]
| Set<string>
| ((requested: string[]) => string | null | undefined)
| undefined,
): string | null {
const requested = normalizeProtocolList(requestHeader);
if (typeof protocols === 'function') {
const selected = protocols(requested);
return selected && requested.includes(String(selected)) ? String(selected) : null;
}
const supported = normalizeProtocolList(protocols);
if (requested.length === 0 || supported.length === 0) {
return null;
}
for (const protocol of supported) {
if (requested.includes(protocol)) {
return protocol;
}
}
return null;
}
+49
View File
@@ -0,0 +1,49 @@
import type { EventEmitter } from 'node:events';
import { WebSocket as InternalWebSocket } from './websocket';
import { WebSocketServer } from './server';
import { WebSocketClient } from './client';
import native from './native';
import type { SendOptions, SendCallback } from './websocket';
WebSocketClient.Server = WebSocketServer;
// `WebSocket` mirrors the `ws` package's default export shape. We use
// declaration merging (const + interface) so the name resolves to the client
// constructor in value space AND a structural interface in type space. The
// interface is intentionally narrow enough to cover both `WebSocketClient`
// (constructed by the user) and the internal `WebSocket` (emitted by the
// server's `connection` event). It also shadows Node 22+'s global DOM-style
// `WebSocket` type that ships with @types/node.
export const WebSocket: typeof WebSocketClient = WebSocketClient;
export interface WebSocket extends EventEmitter {
readonly readyState: number;
readonly protocol: string;
readonly extensions: string;
send(
data: string | Buffer | Uint8Array | ArrayBuffer,
options?: SendOptions | SendCallback,
cb?: SendCallback,
): void;
ping(data?: Buffer | string | SendCallback, mask?: unknown, cb?: SendCallback): void;
pong(data?: Buffer | string | SendCallback, mask?: unknown, cb?: SendCallback): void;
close(code?: number, reason?: string): void;
terminate(): void;
on(event: 'message', listener: (data: Buffer, isBinary: boolean) => void): this;
on(event: 'open', listener: () => void): this;
on(event: 'close', listener: (code: number, reason: string) => void): this;
on(event: 'error', listener: (err: Error) => void): this;
on(event: 'ping' | 'pong', listener: (data: Buffer) => void): this;
on(event: string | symbol, listener: (...args: unknown[]) => void): this;
}
const Server = WebSocketServer;
const _internal = { WebSocket: InternalWebSocket, native };
export { WebSocketClient, WebSocketServer, Server, InternalWebSocket, _internal };
export { DEFAULT_MAX_PAYLOAD_BYTES } from './websocket';
export type { SendOptions, SendCallback, WebSocketRawData } from './websocket';
export type { WebSocketClientOptions } from './client';
export type { WebSocketServerOptions, SubprotocolSelector } from './server';
export default WebSocketClient;
+153
View File
@@ -0,0 +1,153 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface ParserOptions {
isServer: boolean;
maxPayload: number;
allowedRsv: number;
}
export interface ParsedFrame {
fin: boolean;
opcode: number;
payload: Buffer;
rsv?: number;
}
export interface ParseResult {
frames: ParsedFrame[];
error?: boolean;
code?: number;
message?: string;
}
export interface ParserInstance {
push(chunk: Buffer): ParseResult;
}
export interface NativeAddon {
Parser: new (options: ParserOptions) => ParserInstance;
buildFrame(
opcode: number,
fin: boolean,
payload: Buffer,
maskKey: Buffer | null,
rsv: number,
): Buffer;
computeAcceptKey(key: string): string;
}
function platformBinaryName(): string {
return `websocket-${process.platform}-${process.arch}.node`;
}
// The same binary ships under several layouts, depending on whether we're
// running from source (tsx), from the bundled monorepo dist/, or from a
// flattened release zip. Probe each candidate in priority order.
function searchDirs(): string[] {
return [
// Released zip (flattened): <root>/native, called from <root>/index.mjs
path.resolve(__dirname, 'native'),
// Bundled into <root>/dist/index.mjs → natives at <root>/dist/native
path.resolve(__dirname, '..', 'native'),
// Dev (tsx) — packages/websocket/src → packages/runtime/native
path.resolve(__dirname, '..', '..', 'runtime', 'native'),
// Fallback when invoked from repo root
path.resolve(process.cwd(), 'native'),
path.resolve(process.cwd(), 'dist', 'native'),
path.resolve(process.cwd(), 'packages', 'runtime', 'native'),
];
}
function resolveAddonPath(): string {
const fileName = platformBinaryName();
for (const dir of searchDirs()) {
const full = path.join(dir, fileName);
if (existsSync(full)) {
return full;
}
}
throw new Error(
`[snowluma/websocket] native addon not found: ${fileName}. Searched: ${searchDirs().join(', ')}`,
);
}
function loadAddon(): NativeAddon {
const addonPath = resolveAddonPath();
const mod = { exports: {} as Record<string, unknown> };
// process.dlopen expects a NodeModule-shaped object; mod.exports is mutated in-place.
(process as unknown as { dlopen: (m: { exports: unknown }, file: string) => void }).dlopen(
mod,
addonPath,
);
return mod.exports as unknown as NativeAddon;
}
// Local-only escape hatch: when SNOWLUMA_DEV_NO_NATIVE=1 is set, we return a
// stub addon that lets the process boot (so the WebUI / HTTP API can run on
// platforms without a prebuilt binary, e.g. darwin-arm64 dev machines). Any
// actual WebSocket parsing/serialization call will throw at use-time. This
// branch never triggers for normal users because the env var is opt-in.
function createStubAddon(reason: string): NativeAddon {
const fail = (): never => {
throw new Error(
`[snowluma/websocket] native addon is stubbed (${reason}). ` +
`Set SNOWLUMA_DEV_NO_NATIVE=0 (or unset) and provide the prebuilt binary to enable WebSocket I/O.`,
);
};
class StubParser {
constructor(_options: ParserOptions) {}
push(_chunk: Buffer): ParseResult {
return fail();
}
}
console.warn(
`[snowluma/websocket] WARNING: using stub addon (${reason}). WebSocket features are disabled.`,
);
return {
Parser: StubParser as unknown as NativeAddon['Parser'],
buildFrame: fail as unknown as NativeAddon['buildFrame'],
computeAcceptKey: fail as unknown as NativeAddon['computeAcceptKey'],
};
}
function tryLoad(): NativeAddon {
const devSkip = process.env.SNOWLUMA_DEV_NO_NATIVE === '1';
try {
return loadAddon();
} catch (err) {
if (devSkip) {
return createStubAddon((err as Error).message);
}
throw err;
}
}
let addon: NativeAddon | undefined;
function getAddon(): NativeAddon {
return (addon ??= tryLoad());
}
const native: NativeAddon = {
get Parser() {
return getAddon().Parser;
},
buildFrame(...args) {
return getAddon().buildFrame(...args);
},
computeAcceptKey(key) {
return getAddon().computeAcceptKey(key);
},
};
export default native;
+407
View File
@@ -0,0 +1,407 @@
import { EventEmitter } from 'node:events';
import http from 'node:http';
import https from 'node:https';
import type { IncomingMessage, Server as HttpServer, ServerResponse } from 'node:http';
import type { Server as HttpsServer } from 'node:https';
import type { Duplex } from 'node:stream';
import native from './native';
import {
type PerMessageDeflateConfig,
acceptPerMessageDeflate,
chooseSubprotocol,
} from './extensions';
import { WebSocket, OPEN } from './websocket';
function computeAccept(key: string): string {
return native.computeAcceptKey(key);
}
function abortUpgrade(socket: Duplex, status: number, message?: string): void {
try {
const body = message ? message : http.STATUS_CODES[status] || '';
const head =
`HTTP/1.1 ${status} ${http.STATUS_CODES[status] || 'Error'}\r\n` +
'Connection: close\r\n' +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
'Content-Type: text/plain\r\n\r\n';
socket.write(head + body);
} catch {
/* noop */
}
try {
socket.destroy();
} catch {
/* noop */
}
}
const TLS_OPTION_KEYS = [
'ALPNProtocols',
'SNICallback',
'ca',
'cert',
'ciphers',
'clientCertEngine',
'crl',
'dhparam',
'ecdhCurve',
'honorCipherOrder',
'key',
'maxVersion',
'minVersion',
'passphrase',
'pfx',
'privateKeyEngine',
'privateKeyIdentifier',
'requestCert',
'rejectUnauthorized',
'secureOptions',
'secureProtocol',
'sessionIdContext',
'sigalgs',
'ticketKeys',
] as const;
type TlsOptions = Record<string, unknown>;
function getTlsOptions(options: WebSocketServerOptions & TlsOptions): TlsOptions {
const tlsOptions: TlsOptions = {};
for (const key of TLS_OPTION_KEYS) {
if ((options as TlsOptions)[key] !== undefined) {
tlsOptions[key] = (options as TlsOptions)[key];
}
}
if (options.tls && typeof options.tls === 'object') {
Object.assign(tlsOptions, options.tls);
}
return tlsOptions;
}
export type SubprotocolSelector = (requested: string[]) => string | null | undefined;
export interface WebSocketServerOptions {
port?: number;
host?: string;
server?: HttpServer | HttpsServer;
noServer?: boolean;
path?: string;
maxPayload?: number;
verifyClient?:
| ((info: { origin?: string; secure: boolean; req: IncomingMessage }) => boolean)
| ((
info: { origin?: string; secure: boolean; req: IncomingMessage },
cb: (
allow: boolean,
code?: number,
message?: string,
headers?: Record<string, string>,
) => void,
) => void);
protocols?: string | string[] | Set<string> | SubprotocolSelector;
perMessageDeflate?: boolean | PerMessageDeflateConfig;
backlog?: number;
tls?: TlsOptions;
}
type UpgradeHandler = (req: IncomingMessage, socket: Duplex, head: Buffer) => void;
export class WebSocketServer extends EventEmitter {
public readonly options: WebSocketServerOptions & { tls?: TlsOptions };
public readonly clients = new Set<WebSocket>();
private _server: HttpServer | HttpsServer | null = null;
private _externalServer: HttpServer | HttpsServer | null = null;
private _upgradeHandler: UpgradeHandler | null = null;
constructor(options?: WebSocketServerOptions) {
super();
options = options || {};
this.options = {
port: options.port,
host: options.host,
server: options.server,
noServer: !!options.noServer,
path: options.path,
maxPayload: options.maxPayload,
verifyClient: options.verifyClient,
protocols: options.protocols,
perMessageDeflate: options.perMessageDeflate,
backlog: options.backlog,
tls: options.tls,
};
const tlsOptions = getTlsOptions(options as WebSocketServerOptions & TlsOptions);
this.options.tls = Object.keys(tlsOptions).length > 0 ? tlsOptions : undefined;
if (this.options.noServer) {
return;
}
if (this.options.server) {
this._externalServer = this.options.server;
this._attachToServer(this._externalServer);
} else if (this.options.port !== undefined) {
const requestHandler = (_req: IncomingMessage, res: ServerResponse) => {
const body = http.STATUS_CODES[426] ?? '';
res.writeHead(426, {
'Content-Length': Buffer.byteLength(body),
'Content-Type': 'text/plain',
});
res.end(body);
};
this._server = this.options.tls
? https.createServer(this.options.tls, requestHandler)
: http.createServer(requestHandler);
this._attachToServer(this._server);
this._server.listen(this.options.port, this.options.host, this.options.backlog, () =>
this.emit('listening'),
);
this._server.on('error', (err: Error) => this.emit('error', err));
} else {
throw new Error(
'WebSocketServer requires { port } or { server } or { noServer: true }',
);
}
}
address(): ReturnType<HttpServer['address']> | null {
if (this._server) {
return this._server.address();
}
if (this._externalServer && this._externalServer.address) {
return this._externalServer.address();
}
return null;
}
private _attachToServer(server: HttpServer | HttpsServer): void {
const handler: UpgradeHandler = (req, socket, head) => {
if (this.options.path) {
const urlPath = (req.url ?? '/').split('?')[0];
if (urlPath !== this.options.path) {
if (server.listenerCount('upgrade') === 1) {
abortUpgrade(socket, 400, 'Bad path for WebSocket');
}
return;
}
}
this.handleUpgrade(req, socket, head, (ws) => {
this.emit('connection', ws, req);
});
};
this._upgradeHandler = handler;
server.on('upgrade', handler);
}
handleUpgrade(
request: IncomingMessage,
socket: Duplex,
head: Buffer,
cb: (ws: WebSocket, request: IncomingMessage) => void,
): void {
const upgrade = (request.headers['upgrade'] ?? '').toLowerCase();
const connection = (
Array.isArray(request.headers['connection'])
? request.headers['connection'].join(',')
: (request.headers['connection'] ?? '')
).toLowerCase();
const version = request.headers['sec-websocket-version'];
const key = request.headers['sec-websocket-key'];
if (request.method !== 'GET') {
return abortUpgrade(socket, 405, 'Method not allowed');
}
if (upgrade !== 'websocket') {
return abortUpgrade(socket, 400, 'Upgrade header must be websocket');
}
if (!/\bupgrade\b/i.test(connection)) {
return abortUpgrade(socket, 400, 'Connection header must contain "upgrade"');
}
if (version !== '13') {
try {
socket.write(
'HTTP/1.1 426 Upgrade Required\r\n' +
'Sec-WebSocket-Version: 13\r\n' +
'Connection: close\r\n\r\n',
);
} catch {
/* noop */
}
try {
socket.destroy();
} catch {
/* noop */
}
return;
}
if (typeof key !== 'string' || !/^[+/0-9A-Za-z]{22}==$/.test(key)) {
return abortUpgrade(socket, 400, 'Invalid Sec-WebSocket-Key');
}
const doAccept = () => {
const accept = computeAccept(key);
const extension = acceptPerMessageDeflate(
request.headers['sec-websocket-extensions'] as string | undefined,
this.options.perMessageDeflate,
);
const protocol = chooseSubprotocol(
request.headers['sec-websocket-protocol'] as string | undefined,
this.options.protocols,
);
const responseHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${accept}`,
];
if (extension) {
responseHeaders.push(`Sec-WebSocket-Extensions: ${extension.header}`);
}
if (protocol) {
responseHeaders.push(`Sec-WebSocket-Protocol: ${protocol}`);
}
try {
socket.write(responseHeaders.join('\r\n') + '\r\n\r\n');
} catch {
try {
socket.destroy();
} catch {
/* noop */
}
return;
}
const ns = socket as Duplex & {
setTimeout?: (ms: number) => void;
setNoDelay?: (b: boolean) => void;
};
ns.setTimeout?.(0);
ns.setNoDelay?.(true);
const ws = new WebSocket(socket as unknown as import('node:net').Socket, {
isServer: true,
maxPayload: this.options.maxPayload,
extensions: extension ? { perMessageDeflate: extension.options } : undefined,
protocol: protocol ?? '',
readyState: OPEN,
});
this.clients.add(ws);
ws.on('close', () => this.clients.delete(ws));
if (head && head.length > 0) {
ws._onData(head);
}
cb(ws, request);
};
const verify = this.options.verifyClient;
if (typeof verify === 'function') {
type VerifyInfo = { origin?: string; secure: boolean; req: IncomingMessage };
const info: VerifyInfo = {
origin: request.headers['origin'] as string | undefined,
secure: !!(socket as unknown as { encrypted?: boolean }).encrypted,
req: request,
};
if (verify.length >= 2) {
(
verify as (
i: VerifyInfo,
cb: (allow: boolean, code?: number, message?: string) => void,
) => void
)(info, (allow, code, message) => {
if (!allow) {
return abortUpgrade(socket, code || 401, message);
}
doAccept();
});
} else {
const allow = (verify as (i: VerifyInfo) => boolean)(info);
if (!allow) {
return abortUpgrade(socket, 401, 'Unauthorized');
}
doAccept();
}
} else {
doAccept();
}
}
close(cb?: (err?: Error) => void): void {
if (this._externalServer && this._upgradeHandler) {
this._externalServer.removeListener('upgrade', this._upgradeHandler);
this._upgradeHandler = null;
}
for (const ws of this.clients) {
try {
ws.close(1001, 'Server shutting down');
} catch {
/* noop */
}
}
if (this._server) {
this._server.close((err?: Error) => cb && cb(err));
} else if (cb) {
setImmediate(cb);
}
}
/** Destroy clients and, when this server owns the HTTP listener, that
* listener too. An external HTTP server is left to its owner. */
destroy(): void {
for (const ws of this.clients) {
try {
ws.terminate();
} catch {
/* noop */
}
}
if (this._externalServer && this._upgradeHandler) {
this._externalServer.removeListener('upgrade', this._upgradeHandler);
this._upgradeHandler = null;
}
if (this._server) {
try {
this._server.closeAllConnections();
} catch {
/* noop */
}
try {
this._server.close(() => undefined);
} catch {
/* noop */
}
this._server = null;
}
}
}
+896
View File
@@ -0,0 +1,896 @@
import { EventEmitter } from 'node:events';
import crypto from 'node:crypto';
import type { Socket } from 'node:net';
import native, { type ParsedFrame, type ParserInstance, type ParseResult } from './native';
import { type AcceptedPerMessageDeflate, compressRaw, decompressRaw } from './extensions';
// RFC 6455 opcodes.
export const OP_CONT = 0x0;
export const OP_TEXT = 0x1;
export const OP_BIN = 0x2;
export const OP_CLOSE = 0x8;
export const OP_PING = 0x9;
export const OP_PONG = 0xa;
export const RSV1 = 0x40;
// Ready states (mirror the standard WebSocket API numbers).
export const CONNECTING = 0;
export const OPEN = 1;
export const CLOSING = 2;
export const CLOSED = 3;
/**
* Default cap on one inbound WebSocket message (all fragments included).
* Exported so callers can name the default in configuration and diagnostics
* instead of duplicating the number.
*/
export const DEFAULT_MAX_PAYLOAD_BYTES = 100 * 1024 * 1024;
/** RFC 6455 §5.5 bounds a close frame's payload to 125 bytes; the 2-byte code
* leaves this much room for a reason. Long diagnostics are truncated (never
* mid-code-point) because an over-long reason would make the close invalid. */
const MAX_CLOSE_REASON_BYTES = 123;
export function isValidCloseCode(code: number): boolean {
if (
code === 1000 ||
code === 1001 ||
code === 1002 ||
code === 1003 ||
code === 1007 ||
code === 1008 ||
code === 1009 ||
code === 1010 ||
code === 1011
) {
return true;
}
if (code >= 3000 && code <= 4999) {
return true;
}
return false;
}
export function encodeClosePayload(code: number | undefined | null, reason?: string): Buffer {
const reasonBuf = truncateCloseReason(reason);
if (code === undefined || code === null) {
if (reasonBuf.length > 0) {
throw new Error('Cannot send close reason without a code');
}
return Buffer.alloc(0);
}
const buf = Buffer.allocUnsafe(2 + reasonBuf.length);
buf.writeUInt16BE(code & 0xffff, 0);
reasonBuf.copy(buf, 2);
return buf;
}
/** UTF-8 safe truncation of a close reason to the RFC 6455 control-frame room. */
function truncateCloseReason(reason: string | undefined): Buffer {
if (!reason) {
return Buffer.alloc(0);
}
const encoded = Buffer.from(String(reason), 'utf8');
if (encoded.length <= MAX_CLOSE_REASON_BYTES) {
return encoded;
}
let end = MAX_CLOSE_REASON_BYTES;
// Back off to a code-point boundary: `encoded[end]` is the first dropped
// byte, and continuation bytes must never lead a truncated string.
while (end > 0 && (encoded[end]! & 0xc0) === 0x80) {
end -= 1;
}
return encoded.subarray(0, end);
}
/** Human label for the message type carried by a frame's opcode. */
function opcodeLabel(opcode: number): string {
if (opcode === OP_TEXT) {
return 'text';
}
if (opcode === OP_BIN) {
return 'binary';
}
return `opcode ${opcode}`;
}
interface FrameHeaderInfo {
opcode: number;
payloadBytes: number;
}
/** Budget for re-reading inbound bytes when an oversized frame is reported. */
const DIAG_BUFFER_LIMIT = 64 * 1024;
/**
* Walk a frame stream from `bytes` and return the first data frame whose declared
* payload exceeds `maxPayload`.
*
* The native parser rejects an oversized frame without saying where it is or how
* large it is, so the reporting path re-reads the retained bytes from the first
* unparsed frame. Walking from a frame boundary removes the "the first bytes of
* this chunk are the offending header" assumption: earlier data frames, interleaved
* control frames and a header split across chunks are all stepped over correctly.
* Returns `null` when the walk cannot reach a verdict, in which case the caller
* reports the cap without a size instead of guessing.
*/
function scanOversizedFrame(bytes: Buffer, maxPayload: number): FrameHeaderInfo | null {
let offset = 0;
while (offset + 2 <= bytes.length) {
const first = bytes[offset]!;
const second = bytes[offset + 1]!;
const opcode = first & 0x0f;
const masked = (second & 0x80) !== 0;
const shortLength = second & 0x7f;
let payloadBytes: number;
let headerBytes: number;
if (shortLength === 126) {
if (offset + 4 > bytes.length) {
return null;
}
payloadBytes = bytes.readUInt16BE(offset + 2);
headerBytes = 4;
} else if (shortLength === 127) {
if (offset + 10 > bytes.length) {
return null;
}
const high = bytes.readUInt32BE(offset + 2);
const low = bytes.readUInt32BE(offset + 6);
if (high > 0x1fffff) {
return null;
}
payloadBytes = high * 2 ** 32 + low;
headerBytes = 10;
} else {
payloadBytes = shortLength;
headerBytes = 2;
}
if (masked) {
headerBytes += 4;
}
if (payloadBytes > maxPayload) {
// Only a data frame may legitimately exceed the cap; any other opcode
// means the walk is out of sync, so report nothing rather than a
// misleading number.
return opcode === OP_TEXT || opcode === OP_BIN ? { opcode, payloadBytes } : null;
}
offset += headerBytes + payloadBytes;
}
return null;
}
// Streaming UTF-8 validator used while a text message is being received in
// fragments. Returns false if invalid; `done()` checks code-point boundary.
class Utf8Validator {
private state = 0;
private codepoint = 0;
private minNext = 0;
push(buf: Buffer): boolean {
for (let i = 0; i < buf.length; i++) {
const b = buf[i]!;
if (this.state === 0) {
if ((b & 0x80) === 0) {
continue;
}
if ((b & 0xe0) === 0xc0) {
if (b < 0xc2) {
return false;
}
this.codepoint = b & 0x1f;
this.state = 1;
this.minNext = 0x80;
} else if ((b & 0xf0) === 0xe0) {
this.codepoint = b & 0x0f;
this.state = 2;
this.minNext = b === 0xe0 ? 0xa0 : 0x80;
} else if ((b & 0xf8) === 0xf0) {
if (b > 0xf4) {
return false;
}
this.codepoint = b & 0x07;
this.state = 3;
this.minNext = b === 0xf0 ? 0x90 : 0x80;
} else {
return false;
}
} else {
if (b < this.minNext || b > 0xbf) {
return false;
}
// Reject UTF-16 surrogates (U+D800–U+DFFF), encoded as 0xED 0xA0–0xBF ….
// Here b is already constrained to 0x80–0xBF by the guard above, and in
// that range `b & 0x20` is true iff `b >= 0xA0` — so a single check
// covers it (the former second `if` was unreachable dead code).
if (this.state === 2 && this.codepoint === 0xd && b >= 0xa0) {
return false;
}
this.codepoint = (this.codepoint << 6) | (b & 0x3f);
this.state--;
this.minNext = 0x80;
}
}
return true;
}
done(): boolean {
return this.state === 0;
}
}
export function randomMaskKey(): Buffer {
const b = Buffer.allocUnsafe(4);
crypto.randomFillSync(b);
return b;
}
export interface SendOptions {
binary?: boolean;
compress?: boolean;
}
export type SendCallback = (err?: Error | null) => void;
export type WebSocketRawData = Buffer;
export interface WebSocketEvents {
message: (data: Buffer, isBinary: boolean) => void;
ping: (data: Buffer) => void;
pong: (data: Buffer) => void;
close: (code: number, reason: string) => void;
error: (err: Error) => void;
}
export interface WebSocketOptions {
isServer?: boolean;
maxPayload?: number;
readyState?: number;
extensions?: { perMessageDeflate?: AcceptedPerMessageDeflate };
protocol?: string;
}
// Shared WebSocket connection handling logic (post-handshake).
export class WebSocket extends EventEmitter {
protected readonly _socket: Socket;
protected readonly _isServer: boolean;
protected readonly _maxPayload: number;
protected _readyState: number;
protected readonly _perMessageDeflate?: AcceptedPerMessageDeflate;
public protocol: string;
public extensions: string;
protected readonly _parser: ParserInstance;
// Fragmented message assembly state.
protected _msgOpcode = 0;
protected _msgChunks: Buffer[] = [];
protected _msgSize = 0;
protected _msgValidator: Utf8Validator | null = null;
protected _msgCompressed = false;
// Diagnostic re-read of inbound bytes, kept only so an oversized-frame error
// can name the offending frame. It follows the parser's frame boundaries,
// retaining any partial next frame, and downgrades the error text once the
// small budget would be exceeded. It never feeds parsing.
private _diagChunks: Buffer[] = [];
private _diagBytes = 0;
private _diagOverflow = false;
// Close state.
protected _closeCodeSent: number | null = null;
protected _closeCodeReceived: number | null = null;
protected _closeReasonReceived = '';
protected _closeFrameSent = false;
protected _closeTimer: NodeJS.Timeout | null = null;
// Ready state constants exposed as instance fields (for ws compatibility).
static readonly CONNECTING = CONNECTING;
static readonly OPEN = OPEN;
static readonly CLOSING = CLOSING;
static readonly CLOSED = CLOSED;
constructor(socket: Socket, options?: WebSocketOptions) {
super();
options = options || {};
this._socket = socket;
this._isServer = !!options.isServer;
this._maxPayload = options.maxPayload ?? DEFAULT_MAX_PAYLOAD_BYTES;
this._readyState = options.readyState ?? OPEN;
this._perMessageDeflate = options.extensions?.perMessageDeflate;
this.protocol = options.protocol || '';
this.extensions = this._perMessageDeflate ? 'permessage-deflate' : '';
this._parser = new native.Parser({
isServer: this._isServer,
maxPayload: this._maxPayload,
allowedRsv: this._perMessageDeflate ? RSV1 : 0,
});
this._bindSocket();
}
get readyState(): number {
return this._readyState;
}
get isServer(): boolean {
return this._isServer;
}
protected _bindSocket(): void {
const sock = this._socket;
sock.on('data', (chunk: Buffer) => this._onData(chunk));
sock.on('end', () => this._onEnd());
sock.on('close', () => this._onSocketClose());
sock.on('error', (err: Error) => this._onError(err));
}
/** @internal */
_onData(chunk: Buffer): void {
if (this._readyState === CLOSED) {
return;
}
this._recordDiagnosticBytes(chunk);
const res = this._parser.push(chunk);
if (res.error) {
this._failConnection(res.code || 1002, this._describeParseError(res));
return;
}
for (const f of res.frames) {
this._consumeDiagnosticFrame(f);
this._handleFrame(f);
if (this._readyState === CLOSED) {
return;
}
}
}
/**
* Add the numbers a payload-limit failure needs to be actionable: how big the
* offending frame is, its message type, and the effective cap. The native
* parser only reports that the cap was exceeded, so the size is recovered by
* re-walking the buffered bytes; when that walk cannot reach a verdict the
* text names the cap alone instead of inventing a size.
*/
protected _describeParseError(res: ParseResult): string {
const message = res.message || 'Protocol error';
if (res.code !== 1009) {
return message;
}
const info = this._scanDiagnosticBytes();
this._resetDiagnosticBytes();
const limit = `${this._maxPayload} byte limit (maxPayloadBytes)`;
if (!info) {
return `${message}: an inbound frame exceeds the ${limit}`;
}
return `${message}: ${info.payloadBytes} B ${opcodeLabel(info.opcode)} frame exceeds the ${limit}`;
}
/** Keep bounded diagnostic bytes starting at the first unparsed frame. */
private _recordDiagnosticBytes(chunk: Buffer): void {
this._diagBytes += chunk.length;
if (this._diagOverflow) {
return;
}
if (this._diagBytes > DIAG_BUFFER_LIMIT) {
this._diagOverflow = true;
this._diagChunks = [];
return;
}
this._diagChunks.push(chunk);
}
private _consumeDiagnosticFrame(frame: ParsedFrame): void {
const payloadBytes = frame.payload.length;
// The parser enforces minimal length encoding and the masking direction,
// so every accepted frame has this exact wire length, including controls.
const lengthBytes = payloadBytes <= 125 ? 0 : payloadBytes <= 65535 ? 2 : 8;
let remaining = 2 + lengthBytes + (this._isServer ? 4 : 0) + payloadBytes;
this._diagBytes -= remaining;
if (this._diagBytes === 0) {
this._resetDiagnosticBytes();
return;
}
// Once bytes are discarded, wait until the parser drains all pending
// bytes before recording again; a new read may start inside a frame.
if (this._diagOverflow) {
return;
}
let consumedChunks = 0;
while (remaining > 0) {
const chunk = this._diagChunks[consumedChunks]!;
if (remaining < chunk.length) {
this._diagChunks[consumedChunks] = chunk.subarray(remaining);
break;
}
remaining -= chunk.length;
consumedChunks += 1;
}
this._diagChunks.splice(0, consumedChunks);
}
private _resetDiagnosticBytes(): void {
this._diagChunks = [];
this._diagBytes = 0;
this._diagOverflow = false;
}
private _scanDiagnosticBytes(): FrameHeaderInfo | null {
if (this._diagOverflow || this._diagChunks.length === 0) {
return null;
}
const bytes =
this._diagChunks.length === 1
? this._diagChunks[0]!
: Buffer.concat(this._diagChunks, this._diagBytes);
return scanOversizedFrame(bytes, this._maxPayload);
}
protected _onEnd(): void {
if (this._readyState !== CLOSED) {
if (this._closeCodeReceived === null) {
this._closeCodeReceived = 1006;
this._closeReasonReceived = '';
}
this._destroySocket();
}
}
protected _onSocketClose(): void {
if (this._readyState === CLOSED) {
return;
}
if (this._closeCodeReceived === null) {
this._closeCodeReceived = 1006;
}
this._readyState = CLOSED;
if (this._closeTimer) {
clearTimeout(this._closeTimer);
this._closeTimer = null;
}
this.emit('close', this._closeCodeReceived, this._closeReasonReceived);
}
protected _onError(err: Error): void {
this.emit('error', err);
}
protected _handleFrame(f: ParsedFrame): void {
const { fin, opcode, payload } = f;
const rsv = f.rsv ?? 0;
if (opcode === OP_PING) {
this.emit('ping', payload);
if (this._readyState === OPEN) {
this._sendControl(OP_PONG, payload);
}
return;
}
if (opcode === OP_PONG) {
this.emit('pong', payload);
return;
}
if (opcode === OP_CLOSE) {
this._handleCloseFrame(payload);
return;
}
if (opcode === OP_TEXT || opcode === OP_BIN) {
if (this._msgOpcode !== 0) {
this._failConnection(
1002,
'New data frame started before previous fragmented message finished',
);
return;
}
if (rsv & RSV1 && !this._perMessageDeflate) {
this._failConnection(
1002,
'Compressed frame without negotiated permessage-deflate',
);
return;
}
this._msgOpcode = opcode;
this._msgChunks = [];
this._msgSize = 0;
this._msgCompressed = (rsv & RSV1) !== 0;
this._msgValidator = opcode === OP_TEXT ? new Utf8Validator() : null;
} else if (opcode === OP_CONT) {
if (this._msgOpcode === 0) {
this._failConnection(1002, 'Continuation frame without active message');
return;
}
if (rsv !== 0) {
this._failConnection(1002, 'RSV bits set on continuation frame');
return;
}
}
this._msgSize += payload.length;
if (this._msgSize > this._maxPayload) {
this._failConnection(
1009,
`Message too large: ${this._msgSize} B ${opcodeLabel(this._msgOpcode)} message exceeds the ${this._maxPayload} byte limit (maxPayloadBytes)`,
);
return;
}
if (payload.length > 0) {
this._msgChunks.push(payload);
}
if (fin) {
let data =
this._msgChunks.length === 1
? this._msgChunks[0]!
: Buffer.concat(this._msgChunks, this._msgSize);
if (this._msgCompressed) {
try {
data = decompressRaw(data, this._maxPayload);
} catch (err) {
const e = err as Error & { code?: number };
this._failConnection(e.code || 1007, e.message || 'Invalid compressed payload');
return;
}
}
if (this._msgValidator) {
if (!this._msgValidator.push(data) || !this._msgValidator.done()) {
this._failConnection(1007, 'Invalid UTF-8');
return;
}
}
const isBinary = this._msgOpcode === OP_BIN;
this._msgOpcode = 0;
this._msgChunks = [];
this._msgSize = 0;
this._msgValidator = null;
this._msgCompressed = false;
this.emit('message', data, isBinary);
}
}
protected _handleCloseFrame(payload: Buffer): void {
let code = 1005;
let reason = '';
if (payload.length === 1) {
this._failConnection(1002, 'Close payload length 1');
return;
}
if (payload.length >= 2) {
code = payload.readUInt16BE(0);
if (!isValidCloseCode(code)) {
this._failConnection(1002, 'Invalid close code');
return;
}
if (payload.length > 2) {
try {
reason = new TextDecoder('utf-8', { fatal: true }).decode(payload.subarray(2));
} catch {
this._failConnection(1007, 'Invalid UTF-8 in close reason');
return;
}
}
}
this._closeCodeReceived = code;
this._closeReasonReceived = reason;
if (this._readyState === OPEN) {
this._readyState = CLOSING;
const echo = encodeClosePayload(code === 1005 ? undefined : code, '');
this._sendControl(OP_CLOSE, echo);
this._closeFrameSent = true;
this._endSocketSoon();
} else if (this._readyState === CLOSING) {
this._endSocketSoon();
}
}
protected _endSocketSoon(): void {
try {
this._socket.end();
} catch {
/* noop */
}
if (this._closeTimer) {
clearTimeout(this._closeTimer);
}
this._closeTimer = setTimeout(() => this._destroySocket(), 30000);
this._closeTimer.unref?.();
}
protected _destroySocket(): void {
try {
this._socket.destroy();
} catch {
/* noop */
}
}
protected _failConnection(code: number, reason: string): void {
if (this._readyState === CLOSED) {
return;
}
const payload = encodeClosePayload(code, reason);
let frame: Buffer | null = null;
try {
frame = native.buildFrame(
OP_CLOSE,
true,
payload,
this._isServer ? null : randomMaskKey(),
0,
);
} catch {
/* noop */
}
this._closeCodeSent = code;
this._readyState = CLOSING;
const err = new Error(`WebSocket protocol error: ${reason}`) as Error & { code?: number };
err.code = code;
this.emit('error', err);
try {
if (frame) {
this._socket.end(frame);
} else {
this._socket.end();
}
} catch {
/* noop */
}
if (this._closeTimer) {
clearTimeout(this._closeTimer);
}
this._closeTimer = setTimeout(() => this._destroySocket(), 2000);
this._closeTimer.unref?.();
}
protected _sendControl(opcode: number, payload?: Buffer): void {
const data = payload ?? Buffer.alloc(0);
const frame = native.buildFrame(
opcode,
true,
data,
this._isServer ? null : randomMaskKey(),
0,
);
this._socket.write(frame);
}
// Public API -------------------------------------------------------------
send(
data: string | Buffer | Uint8Array | ArrayBuffer,
options?: SendOptions | SendCallback,
cb?: SendCallback,
): void {
if (typeof options === 'function') {
cb = options;
options = undefined;
}
const opts: SendOptions = options || {};
if (this._readyState !== OPEN) {
const err = new Error('WebSocket is not open');
if (cb) {
cb(err);
return;
}
throw err;
}
let payload: Buffer;
let opcode: number;
if (typeof data === 'string') {
payload = Buffer.from(data, 'utf8');
opcode = opts.binary ? OP_BIN : OP_TEXT;
} else if (Buffer.isBuffer(data)) {
payload = data;
opcode = opts.binary === false ? OP_TEXT : OP_BIN;
} else if (data instanceof Uint8Array) {
payload = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
opcode = opts.binary === false ? OP_TEXT : OP_BIN;
} else if (data instanceof ArrayBuffer) {
payload = Buffer.from(data);
opcode = opts.binary === false ? OP_TEXT : OP_BIN;
} else {
const err = new TypeError('Unsupported data type for send()');
if (cb) {
cb(err);
return;
}
throw err;
}
let rsv = 0;
if (
this._perMessageDeflate &&
opts.compress !== false &&
payload.length >= (this._perMessageDeflate.threshold ?? 1024)
) {
payload = compressRaw(payload);
rsv = RSV1;
}
const maskKey = this._isServer ? null : randomMaskKey();
const frame = native.buildFrame(opcode, true, payload, maskKey, rsv);
this._socket.write(frame, cb);
}
ping(data?: Buffer | string | SendCallback, _mask?: unknown, cb?: SendCallback): void {
if (typeof data === 'function') {
cb = data;
data = undefined;
}
const payload = data
? Buffer.isBuffer(data)
? data
: Buffer.from(String(data), 'utf8')
: Buffer.alloc(0);
if (payload.length > 125) {
throw new Error('Ping payload must be <=125 bytes');
}
if (this._readyState !== OPEN) {
const err = new Error('WebSocket is not open');
if (cb) {
cb(err);
return;
}
throw err;
}
const maskKey = this._isServer ? null : randomMaskKey();
const frame = native.buildFrame(OP_PING, true, payload, maskKey, 0);
this._socket.write(frame, cb);
}
pong(data?: Buffer | string | SendCallback, _mask?: unknown, cb?: SendCallback): void {
if (typeof data === 'function') {
cb = data;
data = undefined;
}
const payload = data
? Buffer.isBuffer(data)
? data
: Buffer.from(String(data), 'utf8')
: Buffer.alloc(0);
if (payload.length > 125) {
throw new Error('Pong payload must be <=125 bytes');
}
if (this._readyState !== OPEN) {
const err = new Error('WebSocket is not open');
if (cb) {
cb(err);
return;
}
throw err;
}
const maskKey = this._isServer ? null : randomMaskKey();
const frame = native.buildFrame(OP_PONG, true, payload, maskKey, 0);
this._socket.write(frame, cb);
}
close(code?: number, reason?: string): void {
if (this._readyState === CLOSED || this._readyState === CLOSING) {
return;
}
if (code !== undefined && !isValidCloseCode(code)) {
throw new RangeError(`Invalid close code: ${code}`);
}
const reasonBuf = reason ? Buffer.from(String(reason), 'utf8') : Buffer.alloc(0);
if (reasonBuf.length > 123) {
throw new RangeError('Close reason must be <=123 bytes');
}
const payload = encodeClosePayload(code, reason);
const maskKey = this._isServer ? null : randomMaskKey();
const frame = native.buildFrame(OP_CLOSE, true, payload, maskKey, 0);
this._readyState = CLOSING;
this._closeCodeSent = code ?? null;
this._socket.write(frame);
this._closeFrameSent = true;
this._endSocketSoon();
}
terminate(): void {
this._readyState = CLOSING;
this._destroySocket();
}
}