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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user