Files
SnowLuma/packages/websocket/tests/server.test.ts
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

1166 lines
40 KiB
TypeScript

import { EventEmitter } from 'node:events';
import http from 'node:http';
import https from 'node:https';
import type { IncomingMessage } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { Duplex } from 'node:stream';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WebSocketServer } from '../src/server';
// RFC 6455 §1.3 example.
const RFC_KEY = 'dGhlIHNhbXBsZSBub25jZQ==';
const RFC_SWITCHING =
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n' +
'\r\n';
const METHOD_NOT_ALLOWED =
'HTTP/1.1 405 Method Not Allowed\r\n' +
'Connection: close\r\n' +
'Content-Length: 18\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Method not allowed';
const BAD_UPGRADE_HEADER =
'HTTP/1.1 400 Bad Request\r\n' +
'Connection: close\r\n' +
'Content-Length: 32\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Upgrade header must be websocket';
const BAD_CONNECTION_HEADER =
'HTTP/1.1 400 Bad Request\r\n' +
'Connection: close\r\n' +
'Content-Length: 40\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Connection header must contain "upgrade"';
const BAD_KEY =
'HTTP/1.1 400 Bad Request\r\n' +
'Connection: close\r\n' +
'Content-Length: 25\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Invalid Sec-WebSocket-Key';
const VERSION_REQUIRED =
'HTTP/1.1 426 Upgrade Required\r\n' +
'Sec-WebSocket-Version: 13\r\n' +
'Connection: close\r\n' +
'\r\n';
const UNAUTHORIZED =
'HTTP/1.1 401 Unauthorized\r\n' +
'Connection: close\r\n' +
'Content-Length: 12\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Unauthorized';
const BAD_PATH =
'HTTP/1.1 400 Bad Request\r\n' +
'Connection: close\r\n' +
'Content-Length: 22\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Bad path for WebSocket';
const SHUTDOWN_CLOSE_FRAME = Buffer.from([
0x88, 0x16, 0x03, 0xe9, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x20, 0x73, 0x68, 0x75, 0x74, 0x74,
0x69, 0x6e, 0x67, 0x20, 0x64, 0x6f, 0x77, 0x6e,
]);
class MockSocket extends EventEmitter {
readonly writes: Buffer[] = [];
destroyed = false;
ended = false;
timeout: number | undefined;
noDelay: boolean | undefined;
encrypted: boolean | undefined;
private readonly failWrite: boolean;
private readonly failDestroy: boolean;
constructor(opts?: { encrypted?: boolean; throwOnWrite?: boolean; throwOnDestroy?: boolean }) {
super();
this.encrypted = opts?.encrypted;
this.failWrite = opts?.throwOnWrite === true;
this.failDestroy = opts?.throwOnDestroy === true;
}
write(data: string | Buffer): boolean {
if (this.failWrite) {
throw new Error('write failed');
}
this.writes.push(typeof data === 'string' ? Buffer.from(data) : Buffer.from(data));
return true;
}
get text(): string {
return Buffer.concat(this.writes).toString('utf8');
}
setTimeout(ms: number): this {
this.timeout = ms;
return this;
}
setNoDelay(value: boolean): this {
this.noDelay = value;
return this;
}
end(data?: string | Buffer): this {
if (data !== undefined) {
this.write(data);
}
this.ended = true;
this.emit('end');
this.destroy();
return this;
}
destroy(): this {
if (this.failDestroy) {
throw new Error('destroy failed');
}
this.destroyed = true;
this.emit('close');
return this;
}
}
function asSocket(socket: MockSocket): Duplex {
return socket as unknown as Duplex;
}
function upgradeRequest(
overrides: {
method?: string;
url?: string;
headers?: Record<string, string | string[] | undefined>;
} = {},
): IncomingMessage {
return {
method: overrides.method ?? 'GET',
url: 'url' in overrides ? overrides.url : '/',
headers: {
upgrade: 'websocket',
connection: 'Upgrade',
'sec-websocket-version': '13',
'sec-websocket-key': RFC_KEY,
...overrides.headers,
},
} as IncomingMessage;
}
function listen(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
const onError = (err: Error) => {
server.off('listening', onListening);
reject(err);
};
const onListening = () => {
server.off('error', onError);
resolve((server.address() as AddressInfo).port);
};
server.once('error', onError);
server.listen(0, '127.0.0.1', onListening);
});
}
function waitListening(wss: WebSocketServer): Promise<void> {
return new Promise((resolve, reject) => {
const onError = (err: Error) => {
wss.off('listening', onListening);
reject(err);
};
const onListening = () => {
wss.off('error', onError);
resolve();
};
wss.once('listening', onListening);
wss.once('error', onError);
});
}
function closeWss(wss: WebSocketServer): Promise<void> {
return new Promise((resolve) => {
wss.close(() => resolve());
});
}
function closeHttp(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
function stubListenServer(address = { address: '127.0.0.1', family: 'IPv4', port: 8443 }) {
const stub = {
listen: vi.fn((..._args: unknown[]) => stub),
on: vi.fn(),
close: (cb?: (err?: Error) => void) => {
cb?.();
},
address: () => address,
};
return stub;
}
const ownedServers: WebSocketServer[] = [];
const httpServers: http.Server[] = [];
afterEach(async () => {
const pendingWss = ownedServers.splice(0);
const pendingHttp = httpServers.splice(0);
await Promise.all(pendingWss.map((wss) => closeWss(wss)));
await Promise.all(pendingHttp.map((server) => closeHttp(server)));
vi.restoreAllMocks();
});
describe('WebSocketServer constructor', () => {
it('throws when neither port, server, nor noServer is provided', () => {
expect(() => new WebSocketServer()).toThrowError(
'WebSocketServer requires { port } or { server } or { noServer: true }',
);
expect(() => new WebSocketServer({})).toThrowError(
'WebSocketServer requires { port } or { server } or { noServer: true }',
);
});
it('stores options and skips listening in noServer mode', () => {
const verifyClient = () => true;
const wss = new WebSocketServer({
noServer: true,
path: '/ws',
maxPayload: 4096,
verifyClient,
protocols: ['chat'],
perMessageDeflate: true,
backlog: 128,
host: '127.0.0.1',
port: 9,
});
ownedServers.push(wss);
expect(wss.options).toEqual({
port: 9,
host: '127.0.0.1',
server: undefined,
noServer: true,
path: '/ws',
maxPayload: 4096,
verifyClient,
protocols: ['chat'],
perMessageDeflate: true,
backlog: 128,
tls: undefined,
});
expect(wss.clients).toEqual(new Set());
expect(wss.address()).toBeNull();
});
it('does not attach to a provided server when noServer is set', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ noServer: true, server });
ownedServers.push(wss);
expect(server.listenerCount('upgrade')).toBe(0);
});
it('copies listed TLS keys into options.tls and ignores non-TLS fields', () => {
const wss = new WebSocketServer({
noServer: true,
cert: 'CERT',
key: 'KEY',
rejectUnauthorized: false,
ca: undefined,
host: '127.0.0.1',
} as never);
ownedServers.push(wss);
expect(wss.options.tls).toEqual({
cert: 'CERT',
key: 'KEY',
rejectUnauthorized: false,
});
});
it('lets a tls object overwrite top-level TLS keys', () => {
const wss = new WebSocketServer({
noServer: true,
cert: 'TOP',
key: 'TOPKEY',
tls: { cert: 'NESTED', passphrase: 'p' },
});
ownedServers.push(wss);
expect(wss.options.tls).toEqual({
cert: 'NESTED',
key: 'TOPKEY',
passphrase: 'p',
});
});
it('leaves options.tls unset when no TLS material is present', () => {
const wss = new WebSocketServer({ noServer: true, tls: {} });
ownedServers.push(wss);
expect(wss.options.tls).toBeUndefined();
});
it('attaches an upgrade listener to an existing server', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server, path: '/ws' });
ownedServers.push(wss);
expect(wss.options.server).toBe(server);
expect(server.listenerCount('upgrade')).toBe(1);
expect(wss.address()).toBeNull();
});
it('listens on a standalone port, emits listening, and answers plain HTTP with 426', async () => {
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' });
ownedServers.push(wss);
await waitListening(wss);
const addr = wss.address() as AddressInfo;
expect(addr.address).toBe('127.0.0.1');
expect(addr.port).toBeGreaterThan(0);
const res = await fetch(`http://127.0.0.1:${addr.port}/`);
expect(res.status).toBe(426);
expect(res.headers.get('content-type')).toBe('text/plain');
expect(res.headers.get('content-length')).toBe('16');
expect(await res.text()).toBe('Upgrade Required');
});
it('creates an HTTPS server when tls options are present', () => {
const stub = stubListenServer();
const spy = vi.spyOn(https, 'createServer').mockReturnValue(stub as never);
const wss = new WebSocketServer({
port: 8443,
host: '127.0.0.1',
backlog: 511,
tls: { cert: 'CERT', key: 'KEY' },
});
ownedServers.push(wss);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0]![0]).toEqual({ cert: 'CERT', key: 'KEY' });
expect(stub.listen).toHaveBeenCalledWith(8443, '127.0.0.1', 511, expect.any(Function));
expect(wss.address()).toEqual({ address: '127.0.0.1', family: 'IPv4', port: 8443 });
});
it('creates an HTTPS server from top-level cert and key', () => {
const stub = stubListenServer();
const spy = vi.spyOn(https, 'createServer').mockReturnValue(stub as never);
const wss = new WebSocketServer({
port: 8443,
host: '127.0.0.1',
cert: 'CERT',
key: 'KEY',
} as never);
ownedServers.push(wss);
expect(spy.mock.calls[0]![0]).toEqual({ cert: 'CERT', key: 'KEY' });
});
it('forwards a listen error from the owned HTTP server', async () => {
const blocker = http.createServer();
httpServers.push(blocker);
const port = await listen(blocker);
const wss = new WebSocketServer({ port, host: '127.0.0.1' });
ownedServers.push(wss);
const err = await new Promise<Error>((resolve) => {
wss.once('error', resolve);
});
expect((err as NodeJS.ErrnoException).code).toBe('EADDRINUSE');
});
});
describe('WebSocketServer.address', () => {
it('returns the listening address of an attached external server', async () => {
const server = http.createServer();
httpServers.push(server);
const port = await listen(server);
const wss = new WebSocketServer({ server });
ownedServers.push(wss);
const addr = wss.address() as AddressInfo;
expect(addr.address).toBe('127.0.0.1');
expect(addr.port).toBe(port);
});
it('returns null when an external server has no address method', () => {
const server = {
on() {},
removeListener() {},
};
const wss = new WebSocketServer({ server: server as never });
ownedServers.push(wss);
expect(wss.address()).toBeNull();
});
});
describe('WebSocketServer.handleUpgrade', () => {
it('rejects a non-GET method with 405', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
let accepted = false;
wss.handleUpgrade(
upgradeRequest({ method: 'POST' }),
asSocket(socket),
Buffer.alloc(0),
() => {
accepted = true;
},
);
expect(accepted).toBe(false);
expect(socket.text).toBe(METHOD_NOT_ALLOWED);
expect(socket.destroyed).toBe(true);
});
it('rejects a missing or non-websocket Upgrade header with 400', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const missing = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { upgrade: undefined } }),
asSocket(missing),
Buffer.alloc(0),
() => {},
);
expect(missing.text).toBe(BAD_UPGRADE_HEADER);
expect(missing.destroyed).toBe(true);
const httpUpgrade = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { upgrade: 'h2c' } }),
asSocket(httpUpgrade),
Buffer.alloc(0),
() => {},
);
expect(httpUpgrade.text).toBe(BAD_UPGRADE_HEADER);
});
it('rejects a Connection header that does not contain upgrade', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { connection: 'keep-alive' } }),
asSocket(socket),
Buffer.alloc(0),
() => {},
);
expect(socket.text).toBe(BAD_CONNECTION_HEADER);
expect(socket.destroyed).toBe(true);
});
it('rejects a Sec-WebSocket-Version other than 13 with 426', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const old = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-version': '8' } }),
asSocket(old),
Buffer.alloc(0),
() => {},
);
expect(old.text).toBe(VERSION_REQUIRED);
expect(old.destroyed).toBe(true);
const missing = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-version': undefined } }),
asSocket(missing),
Buffer.alloc(0),
() => {},
);
expect(missing.text).toBe(VERSION_REQUIRED);
});
it('rejects a missing, non-string, or malformed Sec-WebSocket-Key', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const cases: Array<string | string[] | undefined> = [
undefined,
['dGhlIHNhbXBsZSBub25jZQ=='],
'short==',
'AAAAAAAAAAAAAAAAAAAAA==',
'AAAAAAAAAAAAAAAAAAAAAA',
'**********************==',
];
for (const key of cases) {
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-key': key } }),
asSocket(socket),
Buffer.alloc(0),
() => {},
);
expect(socket.text).toBe(BAD_KEY);
expect(socket.destroyed).toBe(true);
}
});
it('swallows write and destroy errors while aborting', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket({ throwOnWrite: true, throwOnDestroy: true });
expect(() => {
wss.handleUpgrade(
upgradeRequest({ method: 'PUT' }),
asSocket(socket),
Buffer.alloc(0),
() => {},
);
}).not.toThrow();
});
it('swallows write and destroy errors on the 426 version path', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket({ throwOnWrite: true, throwOnDestroy: true });
expect(() => {
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-version': '7' } }),
asSocket(socket),
Buffer.alloc(0),
() => {},
);
}).not.toThrow();
});
it('accepts a RFC 6455 handshake and tracks the client', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
const req = upgradeRequest({ headers: { upgrade: 'WebSocket' } });
let acceptedReq: IncomingMessage | undefined;
wss.handleUpgrade(req, asSocket(socket), Buffer.alloc(0), (ws, request) => {
acceptedReq = request;
expect(ws.readyState).toBe(1);
expect(ws.protocol).toBe('');
expect(ws.extensions).toBe('');
expect(wss.clients.has(ws)).toBe(true);
ws.terminate();
});
expect(acceptedReq).toBe(req);
expect(socket.text).toBe(RFC_SWITCHING);
expect(socket.timeout).toBe(0);
expect(socket.noDelay).toBe(true);
expect(wss.clients.size).toBe(0);
});
it('joins an array Connection header before testing for upgrade', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { connection: ['Keep-Alive', 'Upgrade'] } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => ws.terminate(),
);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('accepts Connection: keep-alive, Upgrade', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { connection: 'keep-alive, Upgrade' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => ws.terminate(),
);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('skips setTimeout and setNoDelay when the socket does not implement them', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
(socket as { setTimeout?: unknown }).setTimeout = undefined;
(socket as { setNoDelay?: unknown }).setNoDelay = undefined;
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), (ws) =>
ws.terminate(),
);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('selects the first server protocol that the client offered', () => {
const wss = new WebSocketServer({ noServer: true, protocols: ['chat.v2', 'chat.v1'] });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-protocol': 'chat.v1, chat.v2' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('chat.v2');
ws.terminate();
},
);
expect(socket.text).toBe(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n' +
'Sec-WebSocket-Protocol: chat.v2\r\n' +
'\r\n',
);
});
it('accepts a comma-separated protocols string and a Set', () => {
const asString = new WebSocketServer({ noServer: true, protocols: 'chat' });
ownedServers.push(asString);
const stringSocket = new MockSocket();
asString.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-protocol': 'chat' } }),
asSocket(stringSocket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('chat');
ws.terminate();
},
);
expect(stringSocket.text).toContain('Sec-WebSocket-Protocol: chat\r\n');
const asSet = new WebSocketServer({
noServer: true,
protocols: new Set(['superchat', 'chat']),
});
ownedServers.push(asSet);
const setSocket = new MockSocket();
asSet.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-protocol': 'chat, superchat' } }),
asSocket(setSocket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('superchat');
ws.terminate();
},
);
expect(setSocket.text).toContain('Sec-WebSocket-Protocol: superchat\r\n');
});
it('uses a protocol selector only when the result was requested', () => {
const requestedSeen: string[][] = [];
const wss = new WebSocketServer({
noServer: true,
protocols: (requested) => {
requestedSeen.push(requested);
return 'chat';
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-protocol': 'chat, extra' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('chat');
ws.terminate();
},
);
expect(requestedSeen).toEqual([['chat', 'extra']]);
expect(socket.text).toContain('Sec-WebSocket-Protocol: chat\r\n');
});
it('omits Sec-WebSocket-Protocol when the selector result was not offered', () => {
const wss = new WebSocketServer({
noServer: true,
protocols: () => 'other',
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-protocol': 'chat' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('');
ws.terminate();
},
);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('omits Sec-WebSocket-Protocol when nothing overlaps', () => {
const wss = new WebSocketServer({ noServer: true, protocols: ['json'] });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-protocol': 'chat' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('');
ws.terminate();
},
);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('negotiates permessage-deflate when offered and enabled', () => {
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-extensions': 'permessage-deflate' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
expect(ws.extensions).toBe('permessage-deflate');
ws.terminate();
},
);
expect(socket.text).toBe(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n' +
'Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n' +
'\r\n',
);
});
it('does not advertise permessage-deflate when disabled or not offered', () => {
const disabled = new WebSocketServer({ noServer: true, perMessageDeflate: false });
ownedServers.push(disabled);
const disabledSocket = new MockSocket();
disabled.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-extensions': 'permessage-deflate' } }),
asSocket(disabledSocket),
Buffer.alloc(0),
(ws) => {
expect(ws.extensions).toBe('');
ws.terminate();
},
);
expect(disabledSocket.text).toBe(RFC_SWITCHING);
const noOffer = new WebSocketServer({ noServer: true, perMessageDeflate: true });
ownedServers.push(noOffer);
const noOfferSocket = new MockSocket();
noOffer.handleUpgrade(upgradeRequest(), asSocket(noOfferSocket), Buffer.alloc(0), (ws) => {
expect(ws.extensions).toBe('');
ws.terminate();
});
expect(noOfferSocket.text).toBe(RFC_SWITCHING);
});
it('honors permessage-deflate takeover flags from the config object', () => {
const wss = new WebSocketServer({
noServer: true,
perMessageDeflate: { clientNoContextTakeover: false, serverNoContextTakeover: false },
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ headers: { 'sec-websocket-extensions': 'permessage-deflate' } }),
asSocket(socket),
Buffer.alloc(0),
(ws) => ws.terminate(),
);
expect(socket.text).toBe(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n' +
'Sec-WebSocket-Extensions: permessage-deflate\r\n' +
'\r\n',
);
});
it('writes extensions before protocol when both are selected', () => {
const wss = new WebSocketServer({
noServer: true,
protocols: ['chat'],
perMessageDeflate: true,
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({
headers: {
'sec-websocket-protocol': 'chat',
'sec-websocket-extensions': 'permessage-deflate',
},
}),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
expect(ws.protocol).toBe('chat');
expect(ws.extensions).toBe('permessage-deflate');
ws.terminate();
},
);
expect(socket.text).toBe(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n' +
'Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover; client_no_context_takeover\r\n' +
'Sec-WebSocket-Protocol: chat\r\n' +
'\r\n',
);
});
it('destroys the socket and skips the callback when the 101 write fails', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket({ throwOnWrite: true });
let accepted = false;
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), () => {
accepted = true;
});
expect(accepted).toBe(false);
expect(wss.clients.size).toBe(0);
expect(socket.destroyed).toBe(true);
});
it('feeds leftover handshake bytes into the new socket', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
const ping = Buffer.from([0x89, 0x80, 0x00, 0x00, 0x00, 0x00]);
wss.handleUpgrade(upgradeRequest(), asSocket(socket), ping, (ws) => ws.terminate());
expect(socket.writes[0]!.toString('utf8')).toBe(RFC_SWITCHING);
expect(socket.writes[1]!.equals(Buffer.from([0x8a, 0x00]))).toBe(true);
});
it('does not emit connection from handleUpgrade in noServer mode', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
let connected = false;
wss.on('connection', () => {
connected = true;
});
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), (ws) =>
ws.terminate(),
);
expect(connected).toBe(false);
});
it('ignores options.path inside handleUpgrade itself', () => {
const wss = new WebSocketServer({ noServer: true, path: '/ws' });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(
upgradeRequest({ url: '/other' }),
asSocket(socket),
Buffer.alloc(0),
(ws) => {
ws.terminate();
},
);
expect(socket.text).toBe(RFC_SWITCHING);
});
});
describe('WebSocketServer verifyClient', () => {
it('accepts when the sync verifier returns true', () => {
const infos: Array<{ origin?: string; secure: boolean; req: IncomingMessage }> = [];
const req = upgradeRequest({ headers: { origin: 'https://chat.example' } });
const wss = new WebSocketServer({
noServer: true,
verifyClient: (info) => {
infos.push(info);
return true;
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(req, asSocket(socket), Buffer.alloc(0), (ws) => ws.terminate());
expect(infos).toEqual([{ origin: 'https://chat.example', secure: false, req }]);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('rejects a sync verifier false with 401 Unauthorized', () => {
const wss = new WebSocketServer({ noServer: true, verifyClient: () => false });
ownedServers.push(wss);
const socket = new MockSocket();
let accepted = false;
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), () => {
accepted = true;
});
expect(accepted).toBe(false);
expect(socket.text).toBe(UNAUTHORIZED);
expect(socket.destroyed).toBe(true);
});
it('marks verify info secure when the socket is encrypted', () => {
let secure: boolean | undefined;
const wss = new WebSocketServer({
noServer: true,
verifyClient: (info) => {
secure = info.secure;
return true;
},
});
ownedServers.push(wss);
const socket = new MockSocket({ encrypted: true });
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), (ws) =>
ws.terminate(),
);
expect(secure).toBe(true);
});
it('accepts when the async verifier calls back true', () => {
const wss = new WebSocketServer({
noServer: true,
verifyClient: (_info, cb) => {
cb(true);
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), (ws) =>
ws.terminate(),
);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('rejects an async verifier false with the default 401 body', () => {
const wss = new WebSocketServer({
noServer: true,
verifyClient: (_info, cb) => {
cb(false);
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), () => {});
expect(socket.text).toBe(UNAUTHORIZED);
});
it('rejects an async verifier with a custom status and message', () => {
const wss = new WebSocketServer({
noServer: true,
verifyClient: (_info, cb) => {
cb(false, 403, 'go away');
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), () => {});
expect(socket.text).toBe(
'HTTP/1.1 403 Forbidden\r\n' +
'Connection: close\r\n' +
'Content-Length: 7\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'go away',
);
});
it('uses status text Error and an empty body for an unknown async reject code', () => {
const wss = new WebSocketServer({
noServer: true,
verifyClient: (_info, cb) => {
cb(false, 599);
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), () => {});
expect(socket.text).toBe(
'HTTP/1.1 599 Error\r\n' +
'Connection: close\r\n' +
'Content-Length: 0\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n',
);
});
it('waits for a deferred async verifier before writing 101', () => {
let decide: ((allow: boolean) => void) | undefined;
const wss = new WebSocketServer({
noServer: true,
verifyClient: (_info, cb) => {
decide = (allow) => cb(allow);
},
});
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), (ws) =>
ws.terminate(),
);
expect(socket.writes).toEqual([]);
decide!(true);
expect(socket.text).toBe(RFC_SWITCHING);
});
});
describe('WebSocketServer path filtering', () => {
it('aborts a mismatched path when it is the only upgrade listener', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server, path: '/ws' });
ownedServers.push(wss);
const socket = new MockSocket();
server.emit(
'upgrade',
upgradeRequest({ url: '/other' }),
asSocket(socket),
Buffer.alloc(0),
);
expect(socket.text).toBe(BAD_PATH);
expect(socket.destroyed).toBe(true);
});
it('leaves a mismatched path alone when another upgrade listener exists', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server, path: '/ws' });
ownedServers.push(wss);
let other = false;
server.on('upgrade', () => {
other = true;
});
const socket = new MockSocket();
server.emit(
'upgrade',
upgradeRequest({ url: '/other' }),
asSocket(socket),
Buffer.alloc(0),
);
expect(other).toBe(true);
expect(socket.writes).toEqual([]);
expect(socket.destroyed).toBe(false);
});
it('matches the path after stripping the query string', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server, path: '/ws' });
ownedServers.push(wss);
const req = upgradeRequest({ url: '/ws?x=1' });
const socket = new MockSocket();
const seen: IncomingMessage[] = [];
wss.on('connection', (ws, request) => {
seen.push(request);
ws.terminate();
});
server.emit('upgrade', req, asSocket(socket), Buffer.alloc(0));
expect(seen).toEqual([req]);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('treats a missing url as /', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server, path: '/' });
ownedServers.push(wss);
const req = upgradeRequest({ url: undefined });
const socket = new MockSocket();
let connected = false;
wss.on('connection', (ws) => {
connected = true;
ws.terminate();
});
server.emit('upgrade', req, asSocket(socket), Buffer.alloc(0));
expect(connected).toBe(true);
expect(socket.text).toBe(RFC_SWITCHING);
});
it('rejects a missing url when the configured path is not /', () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server, path: '/ws' });
ownedServers.push(wss);
const socket = new MockSocket();
server.emit(
'upgrade',
upgradeRequest({ url: undefined }),
asSocket(socket),
Buffer.alloc(0),
);
expect(socket.text).toBe(BAD_PATH);
});
});
describe('WebSocketServer.close', () => {
it('invokes the callback on the next immediate tick in noServer mode', async () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
let called = false;
wss.close(() => {
called = true;
});
expect(called).toBe(false);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(called).toBe(true);
});
it('returns without throwing when noServer close has no callback', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
expect(() => wss.close()).not.toThrow();
});
it('detaches the upgrade listener from an external server and still calls back', async () => {
const server = http.createServer();
httpServers.push(server);
const wss = new WebSocketServer({ server });
ownedServers.push(wss);
expect(server.listenerCount('upgrade')).toBe(1);
await closeWss(wss);
expect(server.listenerCount('upgrade')).toBe(0);
await closeWss(wss);
expect(server.listenerCount('upgrade')).toBe(0);
});
it('sends 1001 Server shutting down to live clients', () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
const socket = new MockSocket();
wss.handleUpgrade(upgradeRequest(), asSocket(socket), Buffer.alloc(0), () => {});
wss.close();
expect(socket.writes[0]!.toString('utf8')).toBe(RFC_SWITCHING);
expect(socket.writes[1]!.equals(SHUTDOWN_CLOSE_FRAME)).toBe(true);
});
it('swallows a client close that throws', async () => {
const wss = new WebSocketServer({ noServer: true });
ownedServers.push(wss);
wss.clients.add({
close() {
throw new Error('boom');
},
} as never);
await closeWss(wss);
});
it('forwards the owned HTTP server close callback', async () => {
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' });
ownedServers.push(wss);
await waitListening(wss);
await closeWss(wss);
});
});