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,28 @@
|
||||
{
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"name": "@snowluma/websocket",
|
||||
"version": "2.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"description": "SnowLuma in-tree WebSocket implementation. Loads packages/runtime/native/websocket-<platform>-<arch>.node via process.dlopen().",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.22.1 || >=26.12.1"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,413 @@
|
||||
import zlib from 'node:zlib';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
acceptPerMessageDeflate,
|
||||
chooseSubprotocol,
|
||||
compressRaw,
|
||||
decompressRaw,
|
||||
normalizeProtocolList,
|
||||
offerPerMessageDeflate,
|
||||
parseAcceptedPerMessageDeflate,
|
||||
parseHeader,
|
||||
} from '../src/extensions';
|
||||
|
||||
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function deflateRawFlushed(data: Buffer): Buffer {
|
||||
return zlib.deflateRawSync(data, { flush: zlib.constants.Z_SYNC_FLUSH });
|
||||
}
|
||||
|
||||
function inflateRawError(data: Buffer): Error & { code?: string } {
|
||||
try {
|
||||
zlib.inflateRawSync(Buffer.concat([data, TRAILER]), {
|
||||
finishFlush: zlib.constants.Z_SYNC_FLUSH,
|
||||
});
|
||||
} catch (err) {
|
||||
return err as Error & { code?: string };
|
||||
}
|
||||
|
||||
throw new Error('expected inflateRawSync to fail');
|
||||
}
|
||||
|
||||
describe('parseHeader', () => {
|
||||
it('returns no offers for missing or blank headers', () => {
|
||||
expect(parseHeader(undefined)).toEqual([]);
|
||||
expect(parseHeader(null)).toEqual([]);
|
||||
expect(parseHeader('')).toEqual([]);
|
||||
expect(parseHeader(' ')).toEqual([]);
|
||||
expect(parseHeader(', ,')).toEqual([]);
|
||||
});
|
||||
|
||||
it('lowercases names and flag parameters, and keeps unquoted values', () => {
|
||||
expect(
|
||||
parseHeader(
|
||||
'PerMessage-Deflate; Client_No_Context_Takeover; Server_Max_Window_Bits=10',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
name: 'permessage-deflate',
|
||||
params: {
|
||||
client_no_context_takeover: true,
|
||||
server_max_window_bits: '10',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips matching double or single quotes around parameter values', () => {
|
||||
expect(parseHeader(`mux; token="a b"; flag='x'`)).toEqual([
|
||||
{ name: 'mux', params: { token: 'a b', flag: 'x' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves unmatched quotes and empty quoted values as parsed', () => {
|
||||
expect(parseHeader(`ext; a="bare; b='bare; c=""; d=''`)).toEqual([
|
||||
{
|
||||
name: 'ext',
|
||||
params: {
|
||||
a: '"bare',
|
||||
b: "'bare",
|
||||
c: '',
|
||||
d: '',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('splits multiple offers and ignores empty tokens', () => {
|
||||
expect(parseHeader(' mux , permessage-deflate; client_no_context_takeover, ')).toEqual([
|
||||
{ name: 'mux', params: {} },
|
||||
{ name: 'permessage-deflate', params: { client_no_context_takeover: true } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptPerMessageDeflate', () => {
|
||||
it('returns null when deflate is disabled or the client did not offer it', () => {
|
||||
expect(acceptPerMessageDeflate('permessage-deflate', undefined)).toBeNull();
|
||||
expect(acceptPerMessageDeflate('permessage-deflate', false)).toBeNull();
|
||||
expect(acceptPerMessageDeflate('permessage-deflate', null)).toBeNull();
|
||||
expect(acceptPerMessageDeflate(undefined, true)).toBeNull();
|
||||
expect(acceptPerMessageDeflate('mux', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a boolean enable with both no-context-takeover parameters', () => {
|
||||
expect(acceptPerMessageDeflate('permessage-deflate', true)).toEqual({
|
||||
header: 'permessage-deflate; server_no_context_takeover; client_no_context_takeover',
|
||||
options: {
|
||||
enabled: true,
|
||||
requestNoContextTakeover: true,
|
||||
responseNoContextTakeover: true,
|
||||
threshold: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a non-object enable value like true', () => {
|
||||
expect(acceptPerMessageDeflate('PerMessage-Deflate', 1)).toEqual({
|
||||
header: 'permessage-deflate; server_no_context_takeover; client_no_context_takeover',
|
||||
options: {
|
||||
enabled: true,
|
||||
requestNoContextTakeover: true,
|
||||
responseNoContextTakeover: true,
|
||||
threshold: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('omits takeover parameters that are disabled and not requested', () => {
|
||||
expect(
|
||||
acceptPerMessageDeflate('permessage-deflate', {
|
||||
clientNoContextTakeover: false,
|
||||
serverNoContextTakeover: false,
|
||||
threshold: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
header: 'permessage-deflate',
|
||||
options: {
|
||||
enabled: true,
|
||||
requestNoContextTakeover: false,
|
||||
responseNoContextTakeover: false,
|
||||
threshold: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('still echoes a takeover parameter the client offered when the config disables it', () => {
|
||||
expect(
|
||||
acceptPerMessageDeflate(
|
||||
'mux, permessage-deflate; server_no_context_takeover; client_no_context_takeover',
|
||||
{ clientNoContextTakeover: false, serverNoContextTakeover: false, threshold: 8 },
|
||||
),
|
||||
).toEqual({
|
||||
header: 'permessage-deflate; server_no_context_takeover; client_no_context_takeover',
|
||||
options: {
|
||||
enabled: true,
|
||||
requestNoContextTakeover: true,
|
||||
responseNoContextTakeover: true,
|
||||
threshold: 8,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults object options to both takeover flags', () => {
|
||||
expect(acceptPerMessageDeflate('permessage-deflate', {})).toEqual({
|
||||
header: 'permessage-deflate; server_no_context_takeover; client_no_context_takeover',
|
||||
options: {
|
||||
enabled: true,
|
||||
requestNoContextTakeover: true,
|
||||
responseNoContextTakeover: true,
|
||||
threshold: 1024,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('offerPerMessageDeflate', () => {
|
||||
it('returns null when deflate is disabled', () => {
|
||||
expect(offerPerMessageDeflate(undefined)).toBeNull();
|
||||
expect(offerPerMessageDeflate(false)).toBeNull();
|
||||
expect(offerPerMessageDeflate(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('offers both takeover flags for true or a non-object enable value', () => {
|
||||
expect(offerPerMessageDeflate(true)).toBe(
|
||||
'permessage-deflate; client_no_context_takeover; server_no_context_takeover',
|
||||
);
|
||||
expect(offerPerMessageDeflate('yes')).toBe(
|
||||
'permessage-deflate; client_no_context_takeover; server_no_context_takeover',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers only the takeover flags that are not disabled', () => {
|
||||
expect(offerPerMessageDeflate({ clientNoContextTakeover: false })).toBe(
|
||||
'permessage-deflate; server_no_context_takeover',
|
||||
);
|
||||
expect(offerPerMessageDeflate({ serverNoContextTakeover: false })).toBe(
|
||||
'permessage-deflate; client_no_context_takeover',
|
||||
);
|
||||
expect(
|
||||
offerPerMessageDeflate({
|
||||
clientNoContextTakeover: false,
|
||||
serverNoContextTakeover: false,
|
||||
}),
|
||||
).toBe('permessage-deflate');
|
||||
});
|
||||
|
||||
it('keeps default takeover flags when the object only sets threshold', () => {
|
||||
expect(offerPerMessageDeflate({ threshold: 8 })).toBe(
|
||||
'permessage-deflate; client_no_context_takeover; server_no_context_takeover',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAcceptedPerMessageDeflate', () => {
|
||||
it('returns null when deflate was not requested or not accepted', () => {
|
||||
expect(parseAcceptedPerMessageDeflate('permessage-deflate', undefined)).toBeNull();
|
||||
expect(parseAcceptedPerMessageDeflate('permessage-deflate', false)).toBeNull();
|
||||
expect(parseAcceptedPerMessageDeflate('permessage-deflate', null)).toBeNull();
|
||||
expect(parseAcceptedPerMessageDeflate(undefined, true)).toBeNull();
|
||||
expect(parseAcceptedPerMessageDeflate('mux', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps server/client takeover parameters onto the opposite request/response flags', () => {
|
||||
expect(
|
||||
parseAcceptedPerMessageDeflate(
|
||||
'permessage-deflate; server_no_context_takeover; client_no_context_takeover',
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
requestNoContextTakeover: true,
|
||||
responseNoContextTakeover: true,
|
||||
threshold: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats missing takeover parameters as false and uses the requested threshold', () => {
|
||||
expect(
|
||||
parseAcceptedPerMessageDeflate('mux, permessage-deflate', { threshold: 4096 }),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
requestNoContextTakeover: false,
|
||||
responseNoContextTakeover: false,
|
||||
threshold: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty parameter value as absent and a non-empty string as present', () => {
|
||||
expect(
|
||||
parseAcceptedPerMessageDeflate(
|
||||
'permessage-deflate; client_no_context_takeover=; server_no_context_takeover=0',
|
||||
{},
|
||||
),
|
||||
).toEqual({
|
||||
enabled: true,
|
||||
requestNoContextTakeover: true,
|
||||
responseNoContextTakeover: false,
|
||||
threshold: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported permessage-deflate parameters', () => {
|
||||
expect(() =>
|
||||
parseAcceptedPerMessageDeflate('permessage-deflate; server_max_window_bits=10', true),
|
||||
).toThrowError(
|
||||
new Error('Unsupported permessage-deflate parameter: server_max_window_bits'),
|
||||
);
|
||||
expect(() =>
|
||||
parseAcceptedPerMessageDeflate(
|
||||
'permessage-deflate; client_no_context_takeover; client_max_window_bits=12',
|
||||
true,
|
||||
),
|
||||
).toThrowError(
|
||||
new Error('Unsupported permessage-deflate parameter: client_max_window_bits'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compressRaw / decompressRaw', () => {
|
||||
it('strips the zlib sync-flush trailer when deflateRawSync emits one', () => {
|
||||
const source = Buffer.from('hello hello hello');
|
||||
const flushed = deflateRawFlushed(source);
|
||||
const compressed = compressRaw(source);
|
||||
|
||||
if (flushed.length >= 4 && flushed.subarray(flushed.length - 4).equals(TRAILER)) {
|
||||
expect(compressed).toEqual(flushed.subarray(0, flushed.length - 4));
|
||||
} else {
|
||||
expect(compressed).toEqual(flushed);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns raw bytes when the deflate output has no sync-flush trailer', () => {
|
||||
const raw = Buffer.from([0x03, 0x00]);
|
||||
vi.spyOn(zlib, 'deflateRawSync').mockReturnValueOnce(raw);
|
||||
expect(compressRaw(Buffer.from('x'))).toEqual(Buffer.from([0x03, 0x00]));
|
||||
});
|
||||
|
||||
it('returns raw bytes when the deflate output is shorter than the trailer', () => {
|
||||
vi.spyOn(zlib, 'deflateRawSync').mockReturnValueOnce(Buffer.from([0xaa]));
|
||||
expect(compressRaw(Buffer.alloc(0))).toEqual(Buffer.from([0xaa]));
|
||||
});
|
||||
|
||||
it('inflates independently stripped deflate bytes without a maxPayload cap', () => {
|
||||
const source = Buffer.from('roundtrip payload');
|
||||
expect(decompressRaw(compressRaw(source))).toEqual(source);
|
||||
});
|
||||
|
||||
it('accepts an empty inflate when maxPayload is 0', () => {
|
||||
expect(decompressRaw(compressRaw(Buffer.alloc(0)), 0)).toEqual(Buffer.alloc(0));
|
||||
});
|
||||
|
||||
it('rejects a one-byte inflate when maxPayload is 0 after zlib succeeds', () => {
|
||||
const source = Buffer.from([0x61]);
|
||||
const stripped = compressRaw(source);
|
||||
expect(
|
||||
zlib.inflateRawSync(Buffer.concat([stripped, TRAILER]), {
|
||||
finishFlush: zlib.constants.Z_SYNC_FLUSH,
|
||||
maxOutputLength: 1,
|
||||
}),
|
||||
).toEqual(source);
|
||||
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
decompressRaw(stripped, 0);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
code: 1009,
|
||||
message: 'Message too large after inflate (maxPayload=0)',
|
||||
});
|
||||
expect((thrown as Error).cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rethrows a zlib failure that is not ERR_BUFFER_TOO_LARGE', () => {
|
||||
const garbage = Buffer.from([0x03, 0x01, 0x02, 0xff]);
|
||||
const cause = inflateRawError(garbage);
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
decompressRaw(garbage, 64);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect(thrown).toMatchObject({
|
||||
message: cause.message,
|
||||
code: cause.code,
|
||||
});
|
||||
expect(thrown).not.toMatchObject({ code: 1009 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeProtocolList', () => {
|
||||
it('returns an empty list for missing or empty inputs', () => {
|
||||
expect(normalizeProtocolList(undefined)).toEqual([]);
|
||||
expect(normalizeProtocolList(null)).toEqual([]);
|
||||
expect(normalizeProtocolList('')).toEqual([]);
|
||||
expect(normalizeProtocolList([])).toEqual([]);
|
||||
expect(normalizeProtocolList(new Set())).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits a comma-separated string and drops blank tokens', () => {
|
||||
expect(normalizeProtocolList(' chat , , superchat ')).toEqual(['chat', 'superchat']);
|
||||
});
|
||||
|
||||
it('stringifies array entries, trims them, and drops blanks', () => {
|
||||
expect(normalizeProtocolList(['chat', ' superchat ', '', ' '])).toEqual([
|
||||
'chat',
|
||||
'superchat',
|
||||
]);
|
||||
});
|
||||
|
||||
it('stringifies Set entries, trims them, and drops blanks', () => {
|
||||
expect(normalizeProtocolList(new Set(['chat', ' superchat ', '']))).toEqual([
|
||||
'chat',
|
||||
'superchat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('chooseSubprotocol', () => {
|
||||
it('returns the first supported protocol that the client requested', () => {
|
||||
expect(chooseSubprotocol('chat, superchat', ['superchat', 'chat'])).toBe('superchat');
|
||||
expect(chooseSubprotocol('chat, superchat', 'chat, extra')).toBe('chat');
|
||||
expect(chooseSubprotocol('chat, superchat', new Set(['other', 'superchat']))).toBe(
|
||||
'superchat',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when either side has no protocols or there is no overlap', () => {
|
||||
expect(chooseSubprotocol(undefined, ['chat'])).toBeNull();
|
||||
expect(chooseSubprotocol(' , ', ['chat'])).toBeNull();
|
||||
expect(chooseSubprotocol('chat', undefined)).toBeNull();
|
||||
expect(chooseSubprotocol('chat', [])).toBeNull();
|
||||
expect(chooseSubprotocol('chat', ['superchat'])).toBeNull();
|
||||
});
|
||||
|
||||
it('uses a selector function only when the result is in the requested list', () => {
|
||||
let seen: string[] | undefined;
|
||||
expect(
|
||||
chooseSubprotocol(' chat , superchat ', (requested) => {
|
||||
seen = requested;
|
||||
return 'superchat';
|
||||
}),
|
||||
).toBe('superchat');
|
||||
expect(seen).toEqual(['chat', 'superchat']);
|
||||
|
||||
expect(chooseSubprotocol('chat', () => 'other')).toBeNull();
|
||||
expect(chooseSubprotocol('chat', () => null)).toBeNull();
|
||||
expect(chooseSubprotocol('chat', () => undefined)).toBeNull();
|
||||
expect(chooseSubprotocol('chat', () => '')).toBeNull();
|
||||
expect(chooseSubprotocol('', () => 'chat')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { compressRaw, decompressRaw } from '../src/extensions';
|
||||
|
||||
describe('permessage-deflate decompression limits', () => {
|
||||
it('accepts output exactly at maxPayload', () => {
|
||||
const source = Buffer.alloc(4096, 0x61);
|
||||
const compressed = compressRaw(source);
|
||||
|
||||
expect(decompressRaw(compressed, source.byteLength)).toEqual(source);
|
||||
});
|
||||
|
||||
it('stops a compression bomb during inflate and maps it to WebSocket 1009', () => {
|
||||
const maxPayload = 4096;
|
||||
const compressed = compressRaw(Buffer.alloc(1024 * 1024, 0x61));
|
||||
expect(compressed.byteLength).toBeLessThan(maxPayload);
|
||||
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
decompressRaw(compressed, maxPayload);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
code: 1009,
|
||||
message: `Message too large after inflate (maxPayload=${maxPayload})`,
|
||||
cause: { code: 'ERR_BUFFER_TOO_LARGE' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,570 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import native, { type NativeAddon, type ParserOptions } from '../src/native';
|
||||
|
||||
const { existsSyncMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn<(target: string) => boolean>(),
|
||||
}));
|
||||
|
||||
const fsHooks = vi.hoisted(() => ({
|
||||
realExistsSync: (_target: string): boolean => false,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
fsHooks.realExistsSync = (target) => actual.existsSync(target);
|
||||
existsSyncMock.mockImplementation((target) => actual.existsSync(target));
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (target: Parameters<typeof actual.existsSync>[0]) =>
|
||||
existsSyncMock(String(target)),
|
||||
};
|
||||
});
|
||||
|
||||
const ORIGINAL_DEV_NO_NATIVE = process.env.SNOWLUMA_DEV_NO_NATIVE;
|
||||
const ORIGINAL_CWD = process.cwd();
|
||||
const NATIVE_SRC_DIR = path.dirname(fileURLToPath(new URL('../src/native.ts', import.meta.url)));
|
||||
const BINARY_NAME = `websocket-${process.platform}-${process.arch}.node`;
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
const RFC_KEY = 'dGhlIHNhbXBsZSBub25jZQ==';
|
||||
const RFC_ACCEPT = 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=';
|
||||
const RFC_UNMASKED_HELLO = Buffer.from([0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]);
|
||||
const RFC_MASKED_HELLO = Buffer.from([
|
||||
0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58,
|
||||
]);
|
||||
const RFC_MASK = Buffer.from([0x37, 0xfa, 0x21, 0x3d]);
|
||||
const HELLO = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
|
||||
|
||||
const STUB_USE_SUFFIX =
|
||||
'Set SNOWLUMA_DEV_NO_NATIVE=0 (or unset) and provide the prebuilt binary to enable WebSocket I/O.';
|
||||
|
||||
let nativeIo = false;
|
||||
|
||||
try {
|
||||
nativeIo = native.computeAcceptKey(RFC_KEY) === RFC_ACCEPT;
|
||||
} catch (err) {
|
||||
nativeIo = !String((err as Error).message).includes('native addon is stubbed');
|
||||
}
|
||||
|
||||
function expectedSearchDirs(cwd = process.cwd()): string[] {
|
||||
return [
|
||||
path.resolve(NATIVE_SRC_DIR, 'native'),
|
||||
path.resolve(NATIVE_SRC_DIR, '..', 'native'),
|
||||
path.resolve(NATIVE_SRC_DIR, '..', '..', 'runtime', 'native'),
|
||||
path.resolve(cwd, 'native'),
|
||||
path.resolve(cwd, 'dist', 'native'),
|
||||
path.resolve(cwd, 'packages', 'runtime', 'native'),
|
||||
];
|
||||
}
|
||||
|
||||
function candidatePaths(cwd = process.cwd()): string[] {
|
||||
return expectedSearchDirs(cwd).map((dir) => path.join(dir, BINARY_NAME));
|
||||
}
|
||||
|
||||
function notFoundMessage(cwd = process.cwd()): string {
|
||||
return `[snowluma/websocket] native addon not found: ${BINARY_NAME}. Searched: ${expectedSearchDirs(cwd).join(', ')}`;
|
||||
}
|
||||
|
||||
function stubWarnMessage(reason: string): string {
|
||||
return `[snowluma/websocket] WARNING: using stub addon (${reason}). WebSocket features are disabled.`;
|
||||
}
|
||||
|
||||
function stubUseMessage(reason: string): string {
|
||||
return `[snowluma/websocket] native addon is stubbed (${reason}). ${STUB_USE_SUFFIX}`;
|
||||
}
|
||||
|
||||
function restoreExistsSync(): void {
|
||||
existsSyncMock.mockReset();
|
||||
existsSyncMock.mockImplementation((target) => fsHooks.realExistsSync(target));
|
||||
}
|
||||
|
||||
function restoreDevNoNative(): void {
|
||||
if (ORIGINAL_DEV_NO_NATIVE === undefined) {
|
||||
delete process.env.SNOWLUMA_DEV_NO_NATIVE;
|
||||
} else {
|
||||
process.env.SNOWLUMA_DEV_NO_NATIVE = ORIGINAL_DEV_NO_NATIVE;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNative(): Promise<NativeAddon> {
|
||||
const addon = (await import('../src/native')).default;
|
||||
void addon.Parser;
|
||||
return addon;
|
||||
}
|
||||
|
||||
function installFakeAddon(exports: NativeAddon): string[] {
|
||||
const opened: string[] = [];
|
||||
vi.spyOn(process, 'dlopen').mockImplementation((module, filename) => {
|
||||
opened.push(filename);
|
||||
module.exports = exports;
|
||||
});
|
||||
return opened;
|
||||
}
|
||||
|
||||
function fakeAddon(acceptKey: string): NativeAddon {
|
||||
return {
|
||||
Parser: class {
|
||||
push() {
|
||||
return { frames: [] };
|
||||
}
|
||||
} as unknown as NativeAddon['Parser'],
|
||||
buildFrame: () => Buffer.from([0x81, 0x00]),
|
||||
computeAcceptKey: () => acceptKey,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTmpDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'sl-ws-native-'));
|
||||
tmpDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanupLoaderTest(): void {
|
||||
process.chdir(ORIGINAL_CWD);
|
||||
restoreDevNoNative();
|
||||
restoreExistsSync();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
for (const dir of tmpDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!nativeIo)('computeAcceptKey', () => {
|
||||
it('returns the RFC 6455 §1.3 Sec-WebSocket-Accept value', () => {
|
||||
expect(native.computeAcceptKey(RFC_KEY)).toBe(RFC_ACCEPT);
|
||||
});
|
||||
|
||||
it('rejects a non-string key', () => {
|
||||
expect(() => native.computeAcceptKey(1 as unknown as string)).toThrow('expected string');
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!nativeIo)('buildFrame', () => {
|
||||
it('emits the RFC 6455 §5.7 unmasked Hello text frame', () => {
|
||||
expect(native.buildFrame(0x1, true, HELLO, null, 0)).toEqual(RFC_UNMASKED_HELLO);
|
||||
});
|
||||
|
||||
it('emits the RFC 6455 §5.7 masked Hello text frame', () => {
|
||||
expect(native.buildFrame(0x1, true, HELLO, RFC_MASK, 0)).toEqual(RFC_MASKED_HELLO);
|
||||
});
|
||||
|
||||
it('encodes a 16-bit length for a 126-byte payload', () => {
|
||||
const payload = Buffer.alloc(126, 0x55);
|
||||
expect(native.buildFrame(0x2, true, payload, null, 0)).toEqual(
|
||||
Buffer.concat([Buffer.from([0x82, 0x7e, 0x00, 0x7e]), payload]),
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes a 64-bit length for a 65536-byte payload', () => {
|
||||
const payload = Buffer.alloc(65536, 0x62);
|
||||
expect(native.buildFrame(0x2, true, payload, null, 0)).toEqual(
|
||||
Buffer.concat([
|
||||
Buffer.from([0x82, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]),
|
||||
payload,
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('masks a 16-bit-length payload with the 4-byte key', () => {
|
||||
const payload = Buffer.alloc(126, 0x55);
|
||||
const mask = Buffer.from([0x01, 0x02, 0x03, 0x04]);
|
||||
const body = Buffer.from(payload);
|
||||
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
body[i]! ^= mask[i & 3]!;
|
||||
}
|
||||
|
||||
expect(native.buildFrame(0x2, true, payload, mask, 0)).toEqual(
|
||||
Buffer.concat([Buffer.from([0x82, 0xfe, 0x00, 0x7e, 0x01, 0x02, 0x03, 0x04]), body]),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes FIN, RSV, and opcode into the first header byte', () => {
|
||||
expect(native.buildFrame(0x1, false, Buffer.from([0x48, 0x65, 0x6c]), null, 0)).toEqual(
|
||||
Buffer.from([0x01, 0x03, 0x48, 0x65, 0x6c]),
|
||||
);
|
||||
expect(native.buildFrame(0x1, true, Buffer.alloc(0), null, 0x40)).toEqual(
|
||||
Buffer.from([0xc1, 0x00]),
|
||||
);
|
||||
expect(native.buildFrame(0x11, true, Buffer.alloc(0), null, 0x41)).toEqual(
|
||||
Buffer.from([0xc1, 0x00]),
|
||||
);
|
||||
expect(native.buildFrame(0x8, true, Buffer.from([0x03, 0xe8]), null, 0)).toEqual(
|
||||
Buffer.from([0x88, 0x02, 0x03, 0xe8]),
|
||||
);
|
||||
expect(native.buildFrame(0x9, true, Buffer.alloc(0), null, 0)).toEqual(
|
||||
Buffer.from([0x89, 0x00]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a mask key that is not 4 bytes', () => {
|
||||
expect(() =>
|
||||
native.buildFrame(0x1, true, HELLO, Buffer.from([0x00, 0x01, 0x02]), 0),
|
||||
).toThrow('mask key must be 4 bytes');
|
||||
});
|
||||
|
||||
it('rejects a call that is missing the required arguments', () => {
|
||||
expect(() => (native.buildFrame as unknown as (opcode: number) => Buffer)(0x1)).toThrow(
|
||||
'bad args',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!nativeIo)('Parser', () => {
|
||||
it('parses the RFC 6455 unmasked Hello frame as a client', () => {
|
||||
const parser = new native.Parser({ isServer: false, maxPayload: 1024, allowedRsv: 0 });
|
||||
expect(parser.push(RFC_UNMASKED_HELLO)).toEqual({
|
||||
frames: [{ fin: true, rsv: 0, opcode: 0x1, payload: HELLO }],
|
||||
});
|
||||
});
|
||||
|
||||
it('unmasks the RFC 6455 masked Hello frame as a server', () => {
|
||||
const parser = new native.Parser({ isServer: true, maxPayload: 1024, allowedRsv: 0 });
|
||||
expect(parser.push(RFC_MASKED_HELLO)).toEqual({
|
||||
frames: [{ fin: true, rsv: 0, opcode: 0x1, payload: HELLO }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns no frames until a split header and payload are complete', () => {
|
||||
const parser = new native.Parser({ isServer: false, maxPayload: 1024, allowedRsv: 0 });
|
||||
expect(parser.push(Buffer.from([0x81]))).toEqual({ frames: [] });
|
||||
expect(parser.push(Buffer.from([0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]))).toEqual({
|
||||
frames: [{ fin: true, rsv: 0, opcode: 0x1, payload: HELLO }],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses two frames from one chunk', () => {
|
||||
const parser = new native.Parser({ isServer: false, maxPayload: 1024, allowedRsv: 0 });
|
||||
expect(parser.push(Buffer.concat([RFC_UNMASKED_HELLO, Buffer.from([0x89, 0x00])]))).toEqual(
|
||||
{
|
||||
frames: [
|
||||
{ fin: true, rsv: 0, opcode: 0x1, payload: HELLO },
|
||||
{ fin: true, rsv: 0, opcode: 0x9, payload: Buffer.alloc(0) },
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('parses 16-bit and 64-bit unmasked payloads', () => {
|
||||
const mid = Buffer.alloc(126, 0x41);
|
||||
const large = Buffer.alloc(65536, 0x42);
|
||||
const midParser = new native.Parser({ isServer: false, maxPayload: 200, allowedRsv: 0 });
|
||||
expect(midParser.push(Buffer.concat([Buffer.from([0x82, 0x7e, 0x00, 0x7e]), mid]))).toEqual(
|
||||
{
|
||||
frames: [{ fin: true, rsv: 0, opcode: 0x2, payload: mid }],
|
||||
},
|
||||
);
|
||||
const largeParser = new native.Parser({
|
||||
isServer: false,
|
||||
maxPayload: 70_000,
|
||||
allowedRsv: 0,
|
||||
});
|
||||
expect(
|
||||
largeParser.push(
|
||||
Buffer.concat([
|
||||
Buffer.from([0x82, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00]),
|
||||
large,
|
||||
]),
|
||||
),
|
||||
).toEqual({
|
||||
frames: [{ fin: true, rsv: 0, opcode: 0x2, payload: large }],
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes allowed RSV1 and defaults to a masking server', () => {
|
||||
const deflate = new native.Parser({ isServer: false, maxPayload: 1024, allowedRsv: 0x40 });
|
||||
expect(deflate.push(Buffer.from([0xc1, 0x00]))).toEqual({
|
||||
frames: [{ fin: true, rsv: 0x40, opcode: 0x1, payload: Buffer.alloc(0) }],
|
||||
});
|
||||
|
||||
const defaults = new native.Parser({} as ParserOptions);
|
||||
expect(defaults.push(RFC_UNMASKED_HELLO)).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Expected masked frame from client',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports protocol errors with the native close code and message', () => {
|
||||
const client: ParserOptions = { isServer: false, maxPayload: 1, allowedRsv: 0 };
|
||||
expect(new native.Parser(client).push(Buffer.from([0xc1, 0x00]))).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Unexpected RSV bits',
|
||||
});
|
||||
expect(new native.Parser(client).push(Buffer.from([0x83, 0x00]))).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Unknown opcode',
|
||||
});
|
||||
expect(new native.Parser(client).push(Buffer.from([0x09, 0x00]))).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Fragmented control frame',
|
||||
});
|
||||
expect(new native.Parser(client).push(Buffer.from([0x89, 0x7e, 0x00, 0x7e]))).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Control frame too large',
|
||||
});
|
||||
expect(new native.Parser(client).push(Buffer.from([0x81, 0x80]))).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Server must not mask frames',
|
||||
});
|
||||
expect(new native.Parser(client).push(Buffer.from([0x82, 0x7e, 0x00, 0x05]))).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Non-minimal 16-bit length',
|
||||
});
|
||||
expect(
|
||||
new native.Parser(client).push(
|
||||
Buffer.from([0x82, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05]),
|
||||
),
|
||||
).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Non-minimal 64-bit length',
|
||||
});
|
||||
expect(
|
||||
new native.Parser(client).push(
|
||||
Buffer.from([0x82, 0x7f, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
),
|
||||
).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Payload length top bit set',
|
||||
});
|
||||
expect(new native.Parser(client).push(Buffer.from([0x82, 0x02]))).toEqual({
|
||||
error: true,
|
||||
code: 1009,
|
||||
message: 'Payload exceeds configured max',
|
||||
});
|
||||
|
||||
expect(
|
||||
new native.Parser({ isServer: true, maxPayload: 1024, allowedRsv: 0 }).push(
|
||||
Buffer.from([0x81, 0x00]),
|
||||
),
|
||||
).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Expected masked frame from client',
|
||||
});
|
||||
|
||||
expect(
|
||||
new native.Parser({ isServer: false, maxPayload: 1024, allowedRsv: 0x40 }).push(
|
||||
Buffer.from([0xc9, 0x00]),
|
||||
),
|
||||
).toEqual({
|
||||
error: true,
|
||||
code: 1002,
|
||||
message: 'Control frame RSV bits must be zero',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a negative maxPayload as zero and masks allowedRsv to RSV bits', () => {
|
||||
const zeroCap = new native.Parser({ isServer: false, maxPayload: -1, allowedRsv: 0 });
|
||||
expect(zeroCap.push(Buffer.from([0x81, 0x00]))).toEqual({
|
||||
frames: [{ fin: true, rsv: 0, opcode: 0x1, payload: Buffer.alloc(0) }],
|
||||
});
|
||||
expect(zeroCap.push(Buffer.from([0x81, 0x01, 0x61]))).toEqual({
|
||||
error: true,
|
||||
code: 1009,
|
||||
message: 'Payload exceeds configured max',
|
||||
});
|
||||
|
||||
const rsvMasked = new native.Parser({
|
||||
isServer: false,
|
||||
maxPayload: 1024,
|
||||
allowedRsv: 0x41,
|
||||
});
|
||||
expect(rsvMasked.push(Buffer.from([0xc1, 0x00]))).toEqual({
|
||||
frames: [{ fin: true, rsv: 0x40, opcode: 0x1, payload: Buffer.alloc(0) }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-Buffer push argument', () => {
|
||||
const parser = new native.Parser({ isServer: false, maxPayload: 1024, allowedRsv: 0 });
|
||||
expect(() => parser.push('hello' as unknown as Buffer)).toThrow('expected Buffer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addon path resolution', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.SNOWLUMA_DEV_NO_NATIVE;
|
||||
restoreExistsSync();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(cleanupLoaderTest);
|
||||
|
||||
it('does not load a native library until first use, then caches it', async () => {
|
||||
const opened = installFakeAddon(fakeAddon('lazy'));
|
||||
existsSyncMock.mockReturnValue(true);
|
||||
const addon = (await import('../src/native')).default;
|
||||
expect(opened).toEqual([]);
|
||||
expect(existsSyncMock).not.toHaveBeenCalled();
|
||||
expect(addon.computeAcceptKey(RFC_KEY)).toBe('lazy');
|
||||
expect(addon.computeAcceptKey(RFC_KEY)).toBe('lazy');
|
||||
expect(opened).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('dlopens the first existing websocket-<platform>-<arch>.node candidate', async () => {
|
||||
const opened = installFakeAddon(fakeAddon('from-first-hit'));
|
||||
const winner = candidatePaths()[2]!;
|
||||
const probed: string[] = [];
|
||||
existsSyncMock.mockImplementation((target) => {
|
||||
probed.push(target);
|
||||
return target === winner;
|
||||
});
|
||||
|
||||
const addon = await loadNative();
|
||||
expect(probed).toEqual(candidatePaths().slice(0, 3));
|
||||
expect(opened).toEqual([winner]);
|
||||
expect(addon.computeAcceptKey(RFC_KEY)).toBe('from-first-hit');
|
||||
});
|
||||
|
||||
it('prefers the flattened <module>/native layout over later candidates', async () => {
|
||||
const opened = installFakeAddon(fakeAddon('flattened'));
|
||||
const winner = candidatePaths()[0]!;
|
||||
existsSyncMock.mockImplementation((target) => target === winner);
|
||||
|
||||
await loadNative();
|
||||
expect(opened).toEqual([winner]);
|
||||
expect(existsSyncMock.mock.calls.map(([target]) => target)).toEqual([winner]);
|
||||
});
|
||||
|
||||
it('falls back to process.cwd()/native when module-relative binaries are absent', async () => {
|
||||
process.chdir(makeTmpDir());
|
||||
// Node may report /private/var/folders/... after chdir while mkdtemp
|
||||
// returned /var/folders/...; searchDirs() uses process.cwd().
|
||||
const cwd = process.cwd();
|
||||
const opened = installFakeAddon(fakeAddon('cwd-native'));
|
||||
const winner = path.join(cwd, 'native', BINARY_NAME);
|
||||
existsSyncMock.mockImplementation((target) => target === winner);
|
||||
|
||||
const addon = await loadNative();
|
||||
expect(opened).toEqual([winner]);
|
||||
expect(addon.computeAcceptKey(RFC_KEY)).toBe('cwd-native');
|
||||
});
|
||||
|
||||
it('throws the searched-path error when no candidate exists', async () => {
|
||||
process.chdir(makeTmpDir());
|
||||
const cwd = process.cwd();
|
||||
const probed: string[] = [];
|
||||
existsSyncMock.mockImplementation((target) => {
|
||||
probed.push(target);
|
||||
return false;
|
||||
});
|
||||
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
await loadNative();
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
expect(probed).toEqual(candidatePaths(cwd));
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).message).toBe(notFoundMessage(cwd));
|
||||
});
|
||||
|
||||
it('does not stub a successful load when SNOWLUMA_DEV_NO_NATIVE=1', async () => {
|
||||
process.env.SNOWLUMA_DEV_NO_NATIVE = '1';
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
existsSyncMock.mockImplementation((target) => target === candidatePaths()[0]);
|
||||
installFakeAddon(fakeAddon('still-live'));
|
||||
|
||||
const addon = await loadNative();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(addon.computeAcceptKey(RFC_KEY)).toBe('still-live');
|
||||
expect(addon.buildFrame(0x1, true, Buffer.alloc(0), null, 0)).toEqual(
|
||||
Buffer.from([0x81, 0x00]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SNOWLUMA_DEV_NO_NATIVE stub', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.SNOWLUMA_DEV_NO_NATIVE;
|
||||
restoreExistsSync();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(cleanupLoaderTest);
|
||||
|
||||
it('stubs after a missing binary when the env var is exactly 1', async () => {
|
||||
process.env.SNOWLUMA_DEV_NO_NATIVE = '1';
|
||||
existsSyncMock.mockReturnValue(false);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const addon = await loadNative();
|
||||
const reason = notFoundMessage();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalledWith(stubWarnMessage(reason));
|
||||
|
||||
const parser = new addon.Parser({ isServer: true, maxPayload: 1, allowedRsv: 0 });
|
||||
expect(() => parser.push(Buffer.alloc(0))).toThrow(stubUseMessage(reason));
|
||||
expect(() => addon.buildFrame(0x1, true, Buffer.alloc(0), null, 0)).toThrow(
|
||||
stubUseMessage(reason),
|
||||
);
|
||||
expect(() => addon.computeAcceptKey(RFC_KEY)).toThrow(stubUseMessage(reason));
|
||||
});
|
||||
|
||||
it('stubs after process.dlopen fails when the env var is exactly 1', async () => {
|
||||
process.env.SNOWLUMA_DEV_NO_NATIVE = '1';
|
||||
existsSyncMock.mockReturnValue(true);
|
||||
vi.spyOn(process, 'dlopen').mockImplementation(() => {
|
||||
throw new Error('not a valid Node addon');
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const addon = await loadNative();
|
||||
expect(warn).toHaveBeenCalledWith(stubWarnMessage('not a valid Node addon'));
|
||||
expect(() => addon.computeAcceptKey(RFC_KEY)).toThrow(
|
||||
stubUseMessage('not a valid Node addon'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows a missing-binary error when the env var is not 1', async () => {
|
||||
existsSyncMock.mockReturnValue(false);
|
||||
|
||||
for (const value of [undefined, '', '0', 'true', '2'] as const) {
|
||||
vi.resetModules();
|
||||
|
||||
if (value === undefined) {
|
||||
delete process.env.SNOWLUMA_DEV_NO_NATIVE;
|
||||
} else {
|
||||
process.env.SNOWLUMA_DEV_NO_NATIVE = value;
|
||||
}
|
||||
|
||||
let thrown: unknown;
|
||||
|
||||
try {
|
||||
await loadNative();
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
expect((thrown as Error).message).toBe(notFoundMessage());
|
||||
}
|
||||
});
|
||||
|
||||
it('rethrows a dlopen failure when the env var is unset', async () => {
|
||||
existsSyncMock.mockReturnValue(true);
|
||||
vi.spyOn(process, 'dlopen').mockImplementation(() => {
|
||||
throw new Error('not a valid Node addon');
|
||||
});
|
||||
|
||||
await expect(loadNative()).rejects.toThrow('not a valid Node addon');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in new issue
Block a user