1089 lines
42 KiB
TypeScript
1089 lines
42 KiB
TypeScript
import crypto, { createHash } from 'node:crypto';
|
|
import net from 'node:net';
|
|
import type { AddressInfo, Socket } from 'node:net';
|
|
import tls from 'node:tls';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { WebSocketClient } from '../src/client';
|
|
|
|
const ACCEPT_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
|
|
// Throwaway self-signed pair (openssl, CN=snowluma-test). Used only with
|
|
// rejectUnauthorized: false so expiry/SAN are irrelevant.
|
|
const TLS_CERT = `-----BEGIN CERTIFICATE-----
|
|
MIIDETCCAfmgAwIBAgIUNiu66PmcO6Do6cUaB92UKD8j3qYwDQYJKoZIhvcNAQEL
|
|
BQAwGDEWMBQGA1UEAwwNc25vd2x1bWEtdGVzdDAeFw0yNjA2MTgwNjIwMTZaFw0z
|
|
NjA2MTUwNjIwMTZaMBgxFjAUBgNVBAMMDXNub3dsdW1hLXRlc3QwggEiMA0GCSqG
|
|
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCm0sVJhqlG75gOFJVsUJOfR+oqvb9eSq4t
|
|
k5QItuwXr85E2mrzuKFnSzVVbi3VqqyN8t4otL11mGll2ANSjusmWN4hzpaOBBdX
|
|
bP4UNX/YA7oHId+QKbVicxJgxDE18aPTwaWullyw23LsMsij8T4nLk4eHiOkeRfi
|
|
uLinG1COe3gUGkCK4uyyMT4Vz+y+5Oj9dvoXYL/A/KmcNtq7JrX7F7qYnYsJKLZZ
|
|
aZeFyIGSE8ihb0k53iyJ3agWY+rMUD+p5J7pgIZzBd5dcZuI+KwixVKZgapG5n5R
|
|
ghrnN2ZAQFz13yTRXYmDYy42m4Ue73hMmmt1xVXyf+WVhvrD6pphAgMBAAGjUzBR
|
|
MB0GA1UdDgQWBBRW/HP12nj9fYMYNxa3jyqmnUigvTAfBgNVHSMEGDAWgBRW/HP1
|
|
2nj9fYMYNxa3jyqmnUigvTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA
|
|
A4IBAQCijzVQ/jHNoqu6stvkkigUv2lTKrd1EHcTZLfzwQkmNv/hfY2EMobO/Qxs
|
|
FhmITreKFALJ/dUwTt0UTO00LV9whEgr2of4x8wwjZ9wRstY6uyRYBP85QC8+8mZ
|
|
zWlcf611HugrmpOWjWfEVmmxdI1m26YWTn52nZFPnJqDWg2+RlLJWl55lVotbXEZ
|
|
Fvas4Vcf2KOk0QwBQKvpt0BISeTIQhbT4GnducxSxyoXGeBQOjQNYb/vTpMn4F9U
|
|
IAxSsfs9WVoHKXOabK8GV89BCxWoRk4UaSahTq/2Vnbgt86tWibt3lA4y49+XhA2
|
|
6z7n9HgJAUKqhsDrYvZmM7/VZ2d5
|
|
-----END CERTIFICATE-----
|
|
`;
|
|
const TLS_KEY = `-----BEGIN PRIVATE KEY-----
|
|
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCm0sVJhqlG75gO
|
|
FJVsUJOfR+oqvb9eSq4tk5QItuwXr85E2mrzuKFnSzVVbi3VqqyN8t4otL11mGll
|
|
2ANSjusmWN4hzpaOBBdXbP4UNX/YA7oHId+QKbVicxJgxDE18aPTwaWullyw23Ls
|
|
Msij8T4nLk4eHiOkeRfiuLinG1COe3gUGkCK4uyyMT4Vz+y+5Oj9dvoXYL/A/Kmc
|
|
Ntq7JrX7F7qYnYsJKLZZaZeFyIGSE8ihb0k53iyJ3agWY+rMUD+p5J7pgIZzBd5d
|
|
cZuI+KwixVKZgapG5n5RghrnN2ZAQFz13yTRXYmDYy42m4Ue73hMmmt1xVXyf+WV
|
|
hvrD6pphAgMBAAECggEAJ13+u4qhIMHCnrQBzPU42PImEt8DLXOvJcc5PFM6ZJ6S
|
|
rRHkAk60HAWV+OqOu2jS3o6NGYsJWJpWaPewVQev+zUmelDfm3TgsztfvBIh8K50
|
|
dGFsef81tB1WnWo++K1kzUBZ4ljOV9f5hz62tWVlFubo/Vd8bsA6wEB6Jskd2fls
|
|
ZOO0hJCb/M6IIwe3cWw4sb/YFTTLMUnVHM0aJfnQ/QH+uraRwecJMv118d0YBYVG
|
|
DsNmYqrv9TYNBh4P9mKv6/0x8zaKsJZVLAMwHztILHItOXBfxCbSDivGWVUGiDq2
|
|
NxEuBOgxzOelxEYzUzf7B6sjHoEeIL5wIKQbwUbmfwKBgQDZRLhucrMZPw7ous1t
|
|
igkO32p1ku7FRxm0/JAskc66oX61KLIYbZFM9N7nTXAEs8ZGhzUkdSgxNcdHWIdK
|
|
RI+6pd2FhhcKU1feEohlOHvZTXXEMhm61phWnN391GZXfwMKSpscqsu9kIix2jtw
|
|
4YrzKLx82EDDHcLfBh9AtArKywKBgQDEj+7UJp5wuqz9hj+gogXeq4Buv9be1u4u
|
|
HCJZMZzLCwVjvoqYw0n1afXP3057v8hQPGsBaxzQpOg7i1lYGGfuZ9T6T8fFrtqw
|
|
m/LJLHhMQxeyCEQi1/EoNewvVGSwBJkLOuOs8T2WmMXmOgdHbEYdzkKtT71jst6y
|
|
TeJ15hVuAwKBgBMK4t9LTkc4L6ZWOQsQvhp/mmUTq7m+sZIbUMeXP/c7kE9wcauS
|
|
btm/3ImJT/gZiZdE4nN/kTY+8GhgafsoZzCEuRWq2vocs+bS2QGGIdS55Uh826R0
|
|
ioWM2igVJaMljq6oO1AX6COFN3XfGraaDgOh3mNS0NpJEXtangKdxRRhAoGAP9SF
|
|
t/r6hJz6RDHuQ5mZ0l9bC5vciOy+19ZnCRPlWMIxc9ySYV05jSplmqVndSQoRnX4
|
|
QbOo3dBPYda0orj6Nx8cuFRkCTvo5GUgCFgakJlQ/o1UowQA2g/4rL35HHfBwzXS
|
|
bXzBhUADM+owJu9wLYmneWRlmhSh4MEOAz8+QkUCgYAw0fA1Im2/ftrA2Z2IENTi
|
|
U4cLXqeUeGJfXjIBklUmXhc4qrd/9V9eEHjackI/JI7Qhp/QfPWMXLudlSrppnNJ
|
|
+s3dVevEKosiZbf4qtTtlmR/IifuTKjK5O94KlBT+evxNsL4Hv0b0oTsVNnEfOOl
|
|
OUsvSYNSnfvl87tyLIxYLA==
|
|
-----END PRIVATE KEY-----
|
|
`;
|
|
|
|
const closers: Array<() => void | Promise<void>> = [];
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
const pending = closers.splice(0).reverse();
|
|
|
|
for (const close of pending) {
|
|
await close();
|
|
}
|
|
});
|
|
|
|
function acceptKey(key: string): string {
|
|
return createHash('sha1')
|
|
.update(key + ACCEPT_GUID)
|
|
.digest('base64');
|
|
}
|
|
|
|
function serverFrame(opcode: number, payload: Buffer): Buffer {
|
|
if (payload.length > 125) {
|
|
throw new Error('test frames must be <=125 bytes');
|
|
}
|
|
|
|
return Buffer.concat([Buffer.from([0x80 | opcode, payload.length]), payload]);
|
|
}
|
|
|
|
function tryDecodeClientFrame(
|
|
buf: Buffer,
|
|
): { opcode: number; payload: Buffer; rest: Buffer } | null {
|
|
if (buf.length < 2) {
|
|
return null;
|
|
}
|
|
|
|
const opcode = buf[0]! & 0x0f;
|
|
const masked = (buf[1]! & 0x80) !== 0;
|
|
let length = buf[1]! & 0x7f;
|
|
let offset = 2;
|
|
|
|
if (length === 126) {
|
|
if (buf.length < 4) {
|
|
return null;
|
|
}
|
|
|
|
length = buf.readUInt16BE(2);
|
|
offset = 4;
|
|
}
|
|
|
|
const maskLen = masked ? 4 : 0;
|
|
|
|
if (buf.length < offset + maskLen + length) {
|
|
return null;
|
|
}
|
|
|
|
let mask: Buffer | undefined;
|
|
|
|
if (masked) {
|
|
mask = buf.subarray(offset, offset + 4);
|
|
offset += 4;
|
|
}
|
|
|
|
const payload = Buffer.from(buf.subarray(offset, offset + length));
|
|
|
|
if (mask) {
|
|
for (let i = 0; i < payload.length; i++) {
|
|
payload[i]! ^= mask[i & 3]!;
|
|
}
|
|
}
|
|
|
|
return { opcode, payload, rest: buf.subarray(offset + length) };
|
|
}
|
|
|
|
function collectFrames(socket: Socket) {
|
|
let buf = Buffer.alloc(0);
|
|
socket.on('data', (chunk: Buffer) => {
|
|
buf = Buffer.concat([buf, chunk]);
|
|
});
|
|
return {
|
|
async next(): Promise<{ opcode: number; payload: Buffer }> {
|
|
for (;;) {
|
|
const decoded = tryDecodeClientFrame(buf);
|
|
|
|
if (decoded) {
|
|
buf = decoded.rest;
|
|
return { opcode: decoded.opcode, payload: decoded.payload };
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(
|
|
() => reject(new Error('timeout waiting for frame')),
|
|
3000,
|
|
);
|
|
|
|
const onChunk = () => {
|
|
clearTimeout(timer);
|
|
socket.off('data', onChunk);
|
|
socket.off('error', onErr);
|
|
resolve();
|
|
};
|
|
|
|
const onErr = (err: Error) => {
|
|
clearTimeout(timer);
|
|
socket.off('data', onChunk);
|
|
reject(err);
|
|
};
|
|
|
|
socket.on('data', onChunk);
|
|
socket.on('error', onErr);
|
|
|
|
if (tryDecodeClientFrame(buf)) {
|
|
onChunk();
|
|
}
|
|
});
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function handshakeResponse(key: string, extraHeaders: string[] = [], rest?: Buffer): Buffer {
|
|
const lines = [
|
|
'HTTP/1.1 101 Switching Protocols',
|
|
'Upgrade: websocket',
|
|
'Connection: Upgrade',
|
|
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
|
...extraHeaders,
|
|
'',
|
|
'',
|
|
];
|
|
const head = Buffer.from(lines.join('\r\n'), 'latin1');
|
|
return rest && rest.length > 0 ? Buffer.concat([head, rest]) : head;
|
|
}
|
|
|
|
type ParsedRequest = {
|
|
requestLine: string;
|
|
headers: Record<string, string>;
|
|
};
|
|
|
|
function parseRequest(raw: string): ParsedRequest {
|
|
const lines = raw.split('\r\n');
|
|
const headers: Record<string, string> = Object.create(null);
|
|
|
|
for (const line of lines.slice(1)) {
|
|
const colon = line.indexOf(':');
|
|
|
|
if (colon < 0) {
|
|
continue;
|
|
}
|
|
|
|
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
}
|
|
|
|
return { requestLine: lines[0] ?? '', headers };
|
|
}
|
|
|
|
function readRequest(socket: Socket): Promise<ParsedRequest> {
|
|
return new Promise((resolve, reject) => {
|
|
let buf = Buffer.alloc(0);
|
|
const timer = setTimeout(
|
|
() => reject(new Error('timeout waiting for handshake request')),
|
|
3000,
|
|
);
|
|
|
|
const onError = (err: Error) => {
|
|
clearTimeout(timer);
|
|
reject(err);
|
|
};
|
|
|
|
const onData = (chunk: Buffer) => {
|
|
buf = Buffer.concat([buf, chunk]);
|
|
const idx = buf.indexOf('\r\n\r\n');
|
|
|
|
if (idx < 0) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(timer);
|
|
socket.off('data', onData);
|
|
socket.off('error', onError);
|
|
resolve(parseRequest(buf.subarray(0, idx).toString('latin1')));
|
|
};
|
|
|
|
socket.on('data', onData);
|
|
socket.on('error', onError);
|
|
});
|
|
}
|
|
|
|
function track(client: WebSocketClient) {
|
|
const errors: Error[] = [];
|
|
const closes: Array<{ code: number; reason: string }> = [];
|
|
const messages: Array<{ data: Buffer; isBinary: boolean }> = [];
|
|
const pings: Buffer[] = [];
|
|
const pongs: Buffer[] = [];
|
|
let upgrade:
|
|
{ statusCode: number; statusMessage: string; headers: Record<string, string> } | undefined;
|
|
let opened = false;
|
|
|
|
client.on('error', (err) => errors.push(err));
|
|
client.on('close', (code, reason) => closes.push({ code, reason }));
|
|
client.on('message', (data, isBinary) => messages.push({ data, isBinary }));
|
|
client.on('ping', (data) => pings.push(data));
|
|
client.on('pong', (data) => pongs.push(data));
|
|
client.on('upgrade', (parsed) => {
|
|
upgrade = parsed as {
|
|
statusCode: number;
|
|
statusMessage: string;
|
|
headers: Record<string, string>;
|
|
};
|
|
});
|
|
client.on('open', () => {
|
|
opened = true;
|
|
});
|
|
|
|
const wait = <T>(
|
|
label: string,
|
|
ready: () => T | undefined,
|
|
subscribe: (finish: (value: T) => void) => void,
|
|
): Promise<T> => {
|
|
const existing = ready();
|
|
|
|
if (existing !== undefined) {
|
|
return Promise.resolve(existing);
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${label}`)), 3000);
|
|
subscribe((value) => {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
});
|
|
});
|
|
};
|
|
|
|
return {
|
|
errors,
|
|
closes,
|
|
messages,
|
|
pings,
|
|
pongs,
|
|
get upgrade() {
|
|
return upgrade;
|
|
},
|
|
waitOpen: () =>
|
|
new Promise<void>((resolve, reject) => {
|
|
if (opened) {
|
|
return resolve();
|
|
}
|
|
|
|
if (errors.length) {
|
|
return reject(errors[0]);
|
|
}
|
|
|
|
const timer = setTimeout(() => reject(new Error('timeout waiting for open')), 3000);
|
|
|
|
const onOpen = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
|
|
const onError = (err: Error) => {
|
|
cleanup();
|
|
reject(err);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(timer);
|
|
client.off('open', onOpen);
|
|
client.off('error', onError);
|
|
};
|
|
|
|
client.once('open', onOpen);
|
|
client.once('error', onError);
|
|
}),
|
|
waitError: () =>
|
|
wait(
|
|
'error',
|
|
() => errors[0],
|
|
(finish) => {
|
|
client.once('error', (err) => finish(err));
|
|
},
|
|
),
|
|
waitClose: () =>
|
|
wait(
|
|
'close',
|
|
() => closes[0],
|
|
(finish) => {
|
|
client.once('close', (code, reason) => finish({ code, reason }));
|
|
},
|
|
),
|
|
waitMessage: () =>
|
|
wait(
|
|
'message',
|
|
() => messages[0],
|
|
(finish) => {
|
|
client.once('message', (data, isBinary) => finish({ data, isBinary }));
|
|
},
|
|
),
|
|
waitPing: () =>
|
|
wait(
|
|
'ping',
|
|
() => pings[0],
|
|
(finish) => {
|
|
client.once('ping', (data) => finish(data));
|
|
},
|
|
),
|
|
waitPong: () =>
|
|
wait(
|
|
'pong',
|
|
() => pongs[0],
|
|
(finish) => {
|
|
client.once('pong', (data) => finish(data));
|
|
},
|
|
),
|
|
};
|
|
}
|
|
|
|
async function listenPlain(): Promise<{ port: number; nextSocket: () => Promise<Socket> }> {
|
|
const sockets = new Set<Socket>();
|
|
const queued: Socket[] = [];
|
|
const waiters: Array<(socket: Socket) => void> = [];
|
|
const server = net.createServer((socket) => {
|
|
sockets.add(socket);
|
|
const waiter = waiters.shift();
|
|
|
|
if (waiter) {
|
|
waiter(socket);
|
|
} else {
|
|
queued.push(socket);
|
|
}
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => reject(err);
|
|
server.once('error', onError);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.off('error', onError);
|
|
resolve();
|
|
});
|
|
});
|
|
const port = (server.address() as AddressInfo).port;
|
|
closers.push(() => {
|
|
for (const socket of sockets) {
|
|
socket.destroy();
|
|
}
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
});
|
|
return {
|
|
port,
|
|
nextSocket: () => {
|
|
const socket = queued.shift();
|
|
|
|
if (socket) {
|
|
return Promise.resolve(socket);
|
|
}
|
|
|
|
return new Promise((resolve) => waiters.push(resolve));
|
|
},
|
|
};
|
|
}
|
|
|
|
async function listenTls(): Promise<{ port: number; nextSocket: () => Promise<tls.TLSSocket> }> {
|
|
const sockets = new Set<tls.TLSSocket>();
|
|
const queued: tls.TLSSocket[] = [];
|
|
const waiters: Array<(socket: tls.TLSSocket) => void> = [];
|
|
const server = tls.createServer({ cert: TLS_CERT, key: TLS_KEY }, (socket) => {
|
|
sockets.add(socket);
|
|
const waiter = waiters.shift();
|
|
|
|
if (waiter) {
|
|
waiter(socket);
|
|
} else {
|
|
queued.push(socket);
|
|
}
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => reject(err);
|
|
server.once('error', onError);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.off('error', onError);
|
|
resolve();
|
|
});
|
|
});
|
|
const port = (server.address() as AddressInfo).port;
|
|
closers.push(() => {
|
|
for (const socket of sockets) {
|
|
socket.destroy();
|
|
}
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
});
|
|
return {
|
|
port,
|
|
nextSocket: () => {
|
|
const socket = queued.shift();
|
|
|
|
if (socket) {
|
|
return Promise.resolve(socket);
|
|
}
|
|
|
|
return new Promise((resolve) => waiters.push(resolve));
|
|
},
|
|
};
|
|
}
|
|
|
|
function createClient(
|
|
address: string,
|
|
protocols?: string | string[] | ConstructorParameters<typeof WebSocketClient>[1],
|
|
options?: ConstructorParameters<typeof WebSocketClient>[2],
|
|
): WebSocketClient {
|
|
const client = new WebSocketClient(address, protocols, options);
|
|
client.on('error', () => {});
|
|
closers.push(() => {
|
|
try {
|
|
client.terminate();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
});
|
|
return client;
|
|
}
|
|
|
|
async function unusedPort(): Promise<number> {
|
|
const server = net.createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const onError = (err: Error) => reject(err);
|
|
server.once('error', onError);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
server.off('error', onError);
|
|
resolve();
|
|
});
|
|
});
|
|
const port = (server.address() as AddressInfo).port;
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((err) => (err ? reject(err) : resolve()));
|
|
});
|
|
return port;
|
|
}
|
|
|
|
async function writeAndTick(socket: Socket, data: Buffer | string): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
socket.write(data, (err) => (err ? reject(err) : resolve()));
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
describe('WebSocketClient', () => {
|
|
it('exposes the standard ready-state constants', () => {
|
|
expect(WebSocketClient.CONNECTING).toBe(0);
|
|
expect(WebSocketClient.OPEN).toBe(1);
|
|
expect(WebSocketClient.CLOSING).toBe(2);
|
|
expect(WebSocketClient.CLOSED).toBe(3);
|
|
});
|
|
|
|
it('rejects non-ws URLs before connecting', () => {
|
|
expect(() => new WebSocketClient('http://127.0.0.1/')).toThrowError(
|
|
new Error('Only ws: and wss: URLs are supported'),
|
|
);
|
|
expect(() => new WebSocketClient('https://127.0.0.1/')).toThrowError(
|
|
new Error('Only ws: and wss: URLs are supported'),
|
|
);
|
|
expect(() => new WebSocketClient('foo')).toThrow(TypeError);
|
|
});
|
|
|
|
it('starts in CONNECTING', async () => {
|
|
const { port } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
expect(client.readyState).toBe(0);
|
|
expect(client.protocol).toBe('');
|
|
expect(client.extensions).toBe('');
|
|
});
|
|
|
|
it('sends RFC 6455 handshake fields, path, query, protocols, deflate offer, and extra headers', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(
|
|
`ws://127.0.0.1:${port}/rooms/1?token=abc`,
|
|
['chat', 'superchat'],
|
|
{
|
|
perMessageDeflate: true,
|
|
headers: { 'X-Token': 'secret', Authorization: 'Bearer t' },
|
|
},
|
|
);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
|
|
expect(req.requestLine).toBe('GET /rooms/1?token=abc HTTP/1.1');
|
|
expect(req.headers.host).toBe(`127.0.0.1:${port}`);
|
|
expect(req.headers.upgrade).toBe('websocket');
|
|
expect(req.headers.connection).toBe('Upgrade');
|
|
expect(req.headers['sec-websocket-version']).toBe('13');
|
|
expect(req.headers['sec-websocket-key']).toMatch(/^[A-Za-z0-9+/]{22}==$/);
|
|
expect(req.headers['sec-websocket-protocol']).toBe('chat, superchat');
|
|
expect(req.headers['sec-websocket-extensions']).toBe(
|
|
'permessage-deflate; client_no_context_takeover; server_no_context_takeover',
|
|
);
|
|
expect(req.headers['x-token']).toBe('secret');
|
|
expect(req.headers.authorization).toBe('Bearer t');
|
|
expect(client.readyState).toBe(0);
|
|
expect(events.errors).toEqual([]);
|
|
});
|
|
|
|
it('treats a string protocol as a one-element list', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
createClient(`ws://127.0.0.1:${port}`, 'chat');
|
|
const req = await readRequest(await nextSocket());
|
|
expect(req.headers['sec-websocket-protocol']).toBe('chat');
|
|
});
|
|
|
|
it('omits Sec-WebSocket-Protocol when the list is empty', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
createClient(`ws://127.0.0.1:${port}`, []);
|
|
const req = await readRequest(await nextSocket());
|
|
expect(req.headers['sec-websocket-protocol']).toBeUndefined();
|
|
});
|
|
|
|
it('treats a non-array object second argument as options, not protocols', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
createClient(`ws://127.0.0.1:${port}`, {
|
|
headers: { 'X-From-Options': 'yes' },
|
|
perMessageDeflate: false,
|
|
});
|
|
const req = await readRequest(await nextSocket());
|
|
expect(req.headers['sec-websocket-protocol']).toBeUndefined();
|
|
expect(req.headers['sec-websocket-extensions']).toBeUndefined();
|
|
expect(req.headers['x-from-options']).toBe('yes');
|
|
});
|
|
|
|
it('offers only the requested permessage-deflate flags', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
createClient(`ws://127.0.0.1:${port}`, {
|
|
perMessageDeflate: { clientNoContextTakeover: false },
|
|
});
|
|
const req = await readRequest(await nextSocket());
|
|
expect(req.headers['sec-websocket-extensions']).toBe(
|
|
'permessage-deflate; server_no_context_takeover',
|
|
);
|
|
});
|
|
|
|
it('uses Host without a port when the URL port is the ws default', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
createClient('ws://127.0.0.1:80/chat', {
|
|
socketOptions: { port, host: '127.0.0.1' },
|
|
});
|
|
const req = await readRequest(await nextSocket());
|
|
expect(req.requestLine).toBe('GET /chat HTTP/1.1');
|
|
expect(req.headers.host).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('emits upgrade+open, records protocol/extensions, and forwards leftover frames', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`, ['chat', 'superchat'], {
|
|
perMessageDeflate: true,
|
|
});
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
const rest = serverFrame(0x1, Buffer.from('hello'));
|
|
socket.write(
|
|
handshakeResponse(
|
|
req.headers['sec-websocket-key']!,
|
|
[
|
|
'NoColonLine',
|
|
'Sec-WebSocket-Protocol: superchat',
|
|
'Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover; server_no_context_takeover',
|
|
],
|
|
rest,
|
|
),
|
|
);
|
|
|
|
await events.waitOpen();
|
|
const message = await events.waitMessage();
|
|
expect(client.readyState).toBe(1);
|
|
expect(client.protocol).toBe('superchat');
|
|
expect(client.extensions).toBe('permessage-deflate');
|
|
expect(events.upgrade).toMatchObject({
|
|
statusCode: 101,
|
|
statusMessage: 'Switching Protocols',
|
|
headers: {
|
|
upgrade: 'websocket',
|
|
connection: 'Upgrade',
|
|
'sec-websocket-accept': acceptKey(req.headers['sec-websocket-key']!),
|
|
'sec-websocket-protocol': 'superchat',
|
|
'sec-websocket-extensions':
|
|
'permessage-deflate; client_no_context_takeover; server_no_context_takeover',
|
|
},
|
|
});
|
|
expect(message.data).toEqual(Buffer.from('hello'));
|
|
expect(message.isBinary).toBe(false);
|
|
});
|
|
|
|
it('accepts a 101 that arrives in two TCP chunks and Upgrade/Connection variants', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
await writeAndTick(socket, 'HTTP/1.1 101\r\n');
|
|
expect(client.readyState).toBe(0);
|
|
socket.write(
|
|
`Upgrade: WebSocket\r\nConnection: keep-alive, Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(req.headers['sec-websocket-key']!)}\r\n\r\n`,
|
|
);
|
|
await events.waitOpen();
|
|
expect(client.readyState).toBe(1);
|
|
expect(events.upgrade?.statusMessage).toBe('');
|
|
expect(events.upgrade?.headers.upgrade).toBe('WebSocket');
|
|
expect(events.upgrade?.headers.connection).toBe('keep-alive, Upgrade');
|
|
});
|
|
|
|
it('accepts the RFC 6455 example Sec-WebSocket-Accept', async () => {
|
|
vi.spyOn(crypto, 'randomBytes').mockImplementation(((size: number) => {
|
|
expect(size).toBe(16);
|
|
return Buffer.from('the sample nonce');
|
|
}) as typeof crypto.randomBytes);
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
expect(req.headers['sec-websocket-key']).toBe('dGhlIHNhbXBsZSBub25jZQ==');
|
|
socket.write(
|
|
[
|
|
'HTTP/1.1 101 Switching Protocols',
|
|
'Upgrade: websocket',
|
|
'Connection: Upgrade',
|
|
'Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=',
|
|
'',
|
|
'',
|
|
].join('\r\n'),
|
|
);
|
|
await events.waitOpen();
|
|
expect(client.readyState).toBe(1);
|
|
});
|
|
|
|
it('keeps CONNECTING while the response is incomplete and aborts once it exceeds 16384 bytes', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
await writeAndTick(socket, Buffer.alloc(16384, 0x58));
|
|
expect(client.readyState).toBe(0);
|
|
expect(events.errors).toEqual([]);
|
|
socket.write('Y');
|
|
const err = await events.waitError();
|
|
expect(err.message).toBe('WebSocket handshake failed: Handshake response too large');
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'a non-HTTP status line',
|
|
respond: () => 'NOT-A-STATUS\r\n\r\n',
|
|
message: 'WebSocket handshake failed: Bad HTTP status line: NOT-A-STATUS',
|
|
},
|
|
{
|
|
name: 'HTTP/1.0',
|
|
respond: () => 'HTTP/1.0 101 Switching Protocols\r\n\r\n',
|
|
message:
|
|
'WebSocket handshake failed: Bad HTTP status line: HTTP/1.0 101 Switching Protocols',
|
|
},
|
|
{
|
|
name: 'an empty header block',
|
|
respond: () => '\r\n\r\n',
|
|
message: 'WebSocket handshake failed: Bad HTTP status line: ',
|
|
},
|
|
{
|
|
name: 'a non-101 status',
|
|
respond: () => 'HTTP/1.1 400 Bad Request\r\n\r\n',
|
|
message: 'WebSocket handshake failed: Unexpected response status 400',
|
|
},
|
|
{
|
|
name: 'a missing Upgrade header',
|
|
respond: (key: string) =>
|
|
`HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
|
|
message: 'WebSocket handshake failed: Missing/invalid Upgrade header',
|
|
},
|
|
{
|
|
name: 'an Upgrade header that is not websocket',
|
|
respond: (key: string) =>
|
|
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
|
|
message: 'WebSocket handshake failed: Missing/invalid Upgrade header',
|
|
},
|
|
{
|
|
name: 'a Connection header without upgrade',
|
|
respond: (key: string) =>
|
|
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: keep-alive\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
|
|
message: 'WebSocket handshake failed: Missing Connection: Upgrade header',
|
|
},
|
|
{
|
|
name: 'a missing Connection header',
|
|
respond: (key: string) =>
|
|
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
|
|
message: 'WebSocket handshake failed: Missing Connection: Upgrade header',
|
|
},
|
|
{
|
|
name: 'an invalid Sec-WebSocket-Accept',
|
|
respond: () =>
|
|
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: AAAAAAAAAAAAAAAAAAAAAAAAAAA=\r\n\r\n',
|
|
message: 'WebSocket handshake failed: Invalid Sec-WebSocket-Accept',
|
|
},
|
|
{
|
|
name: 'an unexpected extension when none were offered',
|
|
respond: (key: string) =>
|
|
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\nSec-WebSocket-Extensions: permessage-deflate\r\n\r\n`,
|
|
message: 'WebSocket handshake failed: Unexpected Sec-WebSocket-Extensions',
|
|
},
|
|
{
|
|
name: 'an unexpected subprotocol when none were requested',
|
|
respond: (key: string) =>
|
|
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${acceptKey(key)}\r\nSec-WebSocket-Protocol: chat\r\n\r\n`,
|
|
message: 'WebSocket handshake failed: Unexpected Sec-WebSocket-Protocol',
|
|
},
|
|
])('aborts the handshake on $name', async ({ respond, message }) => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(respond(req.headers['sec-websocket-key'] ?? ''));
|
|
const err = await events.waitError();
|
|
expect(err.message).toBe(message);
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('aborts when the selected subprotocol is not in the offered list', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`, ['chat']);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(
|
|
handshakeResponse(req.headers['sec-websocket-key']!, ['Sec-WebSocket-Protocol: other']),
|
|
);
|
|
const err = await events.waitError();
|
|
expect(err.message).toBe('WebSocket handshake failed: Unexpected Sec-WebSocket-Protocol');
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('aborts when the server returns an unsupported permessage-deflate parameter', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`, { perMessageDeflate: true });
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(
|
|
handshakeResponse(req.headers['sec-websocket-key']!, [
|
|
'Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=10',
|
|
]),
|
|
);
|
|
const err = await events.waitError();
|
|
expect(err.message).toBe(
|
|
'WebSocket handshake failed: Unsupported permessage-deflate parameter: server_max_window_bits',
|
|
);
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('opens without extensions when deflate was offered but the server omitted them', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`, { perMessageDeflate: true });
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
expect(client.extensions).toBe('');
|
|
expect(client.protocol).toBe('');
|
|
});
|
|
|
|
it('forwards a binary leftover frame as isBinary=true', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(
|
|
handshakeResponse(
|
|
req.headers['sec-websocket-key']!,
|
|
[],
|
|
serverFrame(0x2, Buffer.from([0xde, 0xad])),
|
|
),
|
|
);
|
|
await events.waitOpen();
|
|
const message = await events.waitMessage();
|
|
expect(message.data).toEqual(Buffer.from([0xde, 0xad]));
|
|
expect(message.isBinary).toBe(true);
|
|
});
|
|
|
|
it('queues send() until the socket is open, including options-as-callback', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const queued = new Promise<Error | null | undefined>((resolve) => {
|
|
client.send('queued', (err) => resolve(err));
|
|
});
|
|
client.send('bin', { binary: true });
|
|
client.ping('too-early');
|
|
client.pong('too-early');
|
|
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
const frames = collectFrames(socket);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
expect(await queued).toBeFalsy();
|
|
|
|
const first = await frames.next();
|
|
expect(first.opcode).toBe(0x1);
|
|
expect(first.payload).toEqual(Buffer.from('queued'));
|
|
const second = await frames.next();
|
|
expect(second.opcode).toBe(0x2);
|
|
expect(second.payload).toEqual(Buffer.from('bin'));
|
|
});
|
|
|
|
it('delegates send/ping/pong/close after open and forwards control events', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
const frames = collectFrames(socket);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
|
|
const sent = new Promise<Error | null | undefined>((resolve) => {
|
|
client.send('after-open', (err) => resolve(err));
|
|
});
|
|
expect(await frames.next()).toEqual({ opcode: 0x1, payload: Buffer.from('after-open') });
|
|
expect(await sent).toBeFalsy();
|
|
|
|
client.ping('hi');
|
|
expect(await frames.next()).toEqual({ opcode: 0x9, payload: Buffer.from('hi') });
|
|
|
|
client.pong(Buffer.from('xy'));
|
|
expect(await frames.next()).toEqual({ opcode: 0xa, payload: Buffer.from('xy') });
|
|
|
|
socket.write(serverFrame(0x9, Buffer.from('srv-ping')));
|
|
expect(await events.waitPing()).toEqual(Buffer.from('srv-ping'));
|
|
expect(await frames.next()).toEqual({ opcode: 0xa, payload: Buffer.from('srv-ping') });
|
|
|
|
socket.write(serverFrame(0xa, Buffer.from('srv-pong')));
|
|
expect(await events.waitPong()).toEqual(Buffer.from('srv-pong'));
|
|
|
|
client.close(1000, 'done');
|
|
expect(client.readyState).toBe(2);
|
|
const close = await frames.next();
|
|
expect(close.opcode).toBe(0x8);
|
|
expect(close.payload.readUInt16BE(0)).toBe(1000);
|
|
expect(close.payload.subarray(2).toString('utf8')).toBe('done');
|
|
socket.destroy();
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
});
|
|
|
|
it('forwards a peer close code and reason', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
|
|
const reason = Buffer.from('bye');
|
|
const payload = Buffer.alloc(2 + reason.length);
|
|
payload.writeUInt16BE(1001, 0);
|
|
reason.copy(payload, 2);
|
|
socket.write(serverFrame(0x8, payload));
|
|
socket.end();
|
|
expect(await events.waitClose()).toEqual({ code: 1001, reason: 'bye' });
|
|
});
|
|
|
|
it('rejects invalid close codes before touching the socket', async () => {
|
|
const { port } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
expect(() => client.close(1005)).toThrowError(new RangeError('Invalid close code: 1005'));
|
|
expect(() => client.close(1006)).toThrowError(new RangeError('Invalid close code: 1006'));
|
|
expect(() => client.close(2500)).toThrowError(new RangeError('Invalid close code: 2500'));
|
|
expect(client.readyState).toBe(0);
|
|
});
|
|
|
|
it('destroys the TCP socket when close() is called before the handshake finishes', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
// The handshake request may already be sitting unread. A paused socket
|
|
// with buffered data does not emit 'close' after the peer destroy()s.
|
|
socket.resume();
|
|
const socketClosed = new Promise<void>((resolve) => socket.once('close', () => resolve()));
|
|
client.close(1000, 'ignored-before-open');
|
|
await socketClosed;
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('destroys the TCP socket when terminate() is called before the handshake finishes', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
socket.resume();
|
|
const socketClosed = new Promise<void>((resolve) => socket.once('close', () => resolve()));
|
|
client.terminate();
|
|
await socketClosed;
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('terminates an open connection without a close frame', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
client.terminate();
|
|
expect(() => client.send('nope')).toThrowError(new Error('WebSocket is not open'));
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
});
|
|
|
|
it('forwards a socket error after the handshake', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
// destroy(err) emits only on this socket. RST the peer so the client
|
|
// sees a real TCP error (ECONNRESET) after the handshake listeners move.
|
|
socket.resetAndDestroy();
|
|
const err = await events.waitError();
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect((err as NodeJS.ErrnoException).code).toBe('ECONNRESET');
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
});
|
|
|
|
it('emits the TCP connect error and then a 1006 close', async () => {
|
|
const port = await unusedPort();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const err = await events.waitError();
|
|
expect((err as NodeJS.ErrnoException).code).toBe('ECONNREFUSED');
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('emits 1006 when the peer closes before the handshake', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`);
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
socket.destroy();
|
|
expect(await events.waitClose()).toEqual({ code: 1006, reason: '' });
|
|
expect(client.readyState).toBe(3);
|
|
});
|
|
|
|
it('forwards maxPayload into the post-handshake socket', async () => {
|
|
const { port, nextSocket } = await listenPlain();
|
|
const client = createClient(`ws://127.0.0.1:${port}`, { maxPayload: 4 });
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
const req = await readRequest(socket);
|
|
socket.write(
|
|
handshakeResponse(
|
|
req.headers['sec-websocket-key']!,
|
|
[],
|
|
serverFrame(0x1, Buffer.from('12345')),
|
|
),
|
|
);
|
|
const err = await events.waitError();
|
|
expect(err.message).toBe('WebSocket protocol error: Payload exceeds configured max');
|
|
expect((err as Error & { code?: number }).code).toBe(1009);
|
|
});
|
|
|
|
it('completes a wss handshake and auto-sets SNI from a non-IP hostname', async () => {
|
|
const { port, nextSocket } = await listenTls();
|
|
const client = createClient('wss://sni.example.test:443/secure', {
|
|
socketOptions: {
|
|
host: '127.0.0.1',
|
|
port,
|
|
rejectUnauthorized: false,
|
|
},
|
|
});
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
expect(socket.servername).toBe('sni.example.test');
|
|
const req = await readRequest(socket);
|
|
expect(req.requestLine).toBe('GET /secure HTTP/1.1');
|
|
expect(req.headers.host).toBe('sni.example.test');
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
expect(client.readyState).toBe(1);
|
|
});
|
|
|
|
it('does not overwrite an explicit TLS servername', async () => {
|
|
const { port, nextSocket } = await listenTls();
|
|
createClient('wss://sni.example.test:443/', {
|
|
socketOptions: {
|
|
host: '127.0.0.1',
|
|
port,
|
|
rejectUnauthorized: false,
|
|
servername: 'explicit.example',
|
|
},
|
|
});
|
|
const socket = await nextSocket();
|
|
expect(socket.servername).toBe('explicit.example');
|
|
});
|
|
|
|
it('does not invent SNI for an IPv4 wss URL and includes a non-default port in Host', async () => {
|
|
const { port, nextSocket } = await listenTls();
|
|
const client = createClient(`wss://127.0.0.1:${port}/`, {
|
|
socketOptions: { rejectUnauthorized: false },
|
|
});
|
|
const events = track(client);
|
|
const socket = await nextSocket();
|
|
expect(socket.servername || '').toBe('');
|
|
const req = await readRequest(socket);
|
|
expect(req.headers.host).toBe(`127.0.0.1:${port}`);
|
|
socket.write(handshakeResponse(req.headers['sec-websocket-key']!));
|
|
await events.waitOpen();
|
|
expect(client.readyState).toBe(1);
|
|
});
|
|
});
|