chore: monorepo setup (#7175)

This commit is contained in:
Noel
2022-01-07 17:18:25 +01:00
committed by GitHub
parent 780b7ed39f
commit 16390efe6e
504 changed files with 25459 additions and 22830 deletions

View File

@@ -0,0 +1,594 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import { VoiceUDPSocket } from './VoiceUDPSocket';
import { VoiceWebSocket } from './VoiceWebSocket';
import * as secretbox from '../util/Secretbox';
import { Awaited, noop } from '../util/util';
import type { CloseEvent } from 'ws';
import { TypedEmitter } from 'tiny-typed-emitter';
// The number of audio channels required by Discord
const CHANNELS = 2;
const TIMESTAMP_INC = (48000 / 100) * CHANNELS;
const MAX_NONCE_SIZE = 2 ** 32 - 1;
export const SUPPORTED_ENCRYPTION_MODES = ['xsalsa20_poly1305_lite', 'xsalsa20_poly1305_suffix', 'xsalsa20_poly1305'];
/**
* The different statuses that a networking instance can hold. The order
* of the states between OpeningWs and Ready is chronological (first the
* instance enters OpeningWs, then it enters Identifying etc.)
*/
export enum NetworkingStatusCode {
OpeningWs,
Identifying,
UdpHandshaking,
SelectingProtocol,
Ready,
Resuming,
Closed,
}
/**
* The initial Networking state. Instances will be in this state when a WebSocket connection to a Discord
* voice gateway is being opened.
*/
export interface NetworkingOpeningWsState {
code: NetworkingStatusCode.OpeningWs;
ws: VoiceWebSocket;
connectionOptions: ConnectionOptions;
}
/**
* The state that a Networking instance will be in when it is attempting to authorize itself.
*/
export interface NetworkingIdentifyingState {
code: NetworkingStatusCode.Identifying;
ws: VoiceWebSocket;
connectionOptions: ConnectionOptions;
}
/**
* The state that a Networking instance will be in when opening a UDP connection to the IP and port provided
* by Discord, as well as performing IP discovery.
*/
export interface NetworkingUdpHandshakingState {
code: NetworkingStatusCode.UdpHandshaking;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: Pick<ConnectionData, 'ssrc'>;
}
/**
* The state that a Networking instance will be in when selecting an encryption protocol for audio packets.
*/
export interface NetworkingSelectingProtocolState {
code: NetworkingStatusCode.SelectingProtocol;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: Pick<ConnectionData, 'ssrc'>;
}
/**
* The state that a Networking instance will be in when it has a fully established connection to a Discord
* voice server.
*/
export interface NetworkingReadyState {
code: NetworkingStatusCode.Ready;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: ConnectionData;
preparedPacket?: Buffer;
}
/**
* The state that a Networking instance will be in when its connection has been dropped unexpectedly, and it
* is attempting to resume an existing session.
*/
export interface NetworkingResumingState {
code: NetworkingStatusCode.Resuming;
ws: VoiceWebSocket;
udp: VoiceUDPSocket;
connectionOptions: ConnectionOptions;
connectionData: ConnectionData;
preparedPacket?: Buffer;
}
/**
* The state that a Networking instance will be in when it has been destroyed. It cannot be recovered from this
* state.
*/
export interface NetworkingClosedState {
code: NetworkingStatusCode.Closed;
}
/**
* The various states that a networking instance can be in.
*/
export type NetworkingState =
| NetworkingOpeningWsState
| NetworkingIdentifyingState
| NetworkingUdpHandshakingState
| NetworkingSelectingProtocolState
| NetworkingReadyState
| NetworkingResumingState
| NetworkingClosedState;
/**
* Details required to connect to the Discord voice gateway. These details
* are first received on the main bot gateway, in the form of VOICE_SERVER_UPDATE
* and VOICE_STATE_UPDATE packets.
*/
interface ConnectionOptions {
serverId: string;
userId: string;
sessionId: string;
token: string;
endpoint: string;
}
/**
* Information about the current connection, e.g. which encryption mode is to be used on
* the connection, timing information for playback of streams.
*/
export interface ConnectionData {
ssrc: number;
encryptionMode: string;
secretKey: Uint8Array;
sequence: number;
timestamp: number;
packetsPlayed: number;
nonce: number;
nonceBuffer: Buffer;
speaking: boolean;
}
/**
* An empty buffer that is reused in packet encryption by many different networking instances.
*/
const nonce = Buffer.alloc(24);
export interface NetworkingEvents {
debug: (message: string) => Awaited<void>;
error: (error: Error) => Awaited<void>;
stateChange: (oldState: NetworkingState, newState: NetworkingState) => Awaited<void>;
close: (code: number) => Awaited<void>;
}
/**
* Manages the networking required to maintain a voice connection and dispatch audio packets
*/
export class Networking extends TypedEmitter<NetworkingEvents> {
private _state: NetworkingState;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* Creates a new Networking instance.
*/
public constructor(options: ConnectionOptions, debug: boolean) {
super();
this.onWsOpen = this.onWsOpen.bind(this);
this.onChildError = this.onChildError.bind(this);
this.onWsPacket = this.onWsPacket.bind(this);
this.onWsClose = this.onWsClose.bind(this);
this.onWsDebug = this.onWsDebug.bind(this);
this.onUdpDebug = this.onUdpDebug.bind(this);
this.onUdpClose = this.onUdpClose.bind(this);
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
this._state = {
code: NetworkingStatusCode.OpeningWs,
ws: this.createWebSocket(options.endpoint),
connectionOptions: options,
};
}
/**
* Destroys the Networking instance, transitioning it into the Closed state.
*/
public destroy() {
this.state = {
code: NetworkingStatusCode.Closed,
};
}
/**
* The current state of the networking instance.
*/
public get state(): NetworkingState {
return this._state;
}
/**
* Sets a new state for the networking instance, performing clean-up operations where necessary.
*/
public set state(newState: NetworkingState) {
const oldWs = Reflect.get(this._state, 'ws') as VoiceWebSocket | undefined;
const newWs = Reflect.get(newState, 'ws') as VoiceWebSocket | undefined;
if (oldWs && oldWs !== newWs) {
// The old WebSocket is being freed - remove all handlers from it
oldWs.off('debug', this.onWsDebug);
oldWs.on('error', noop);
oldWs.off('error', this.onChildError);
oldWs.off('open', this.onWsOpen);
oldWs.off('packet', this.onWsPacket);
oldWs.off('close', this.onWsClose);
oldWs.destroy();
}
const oldUdp = Reflect.get(this._state, 'udp') as VoiceUDPSocket | undefined;
const newUdp = Reflect.get(newState, 'udp') as VoiceUDPSocket | undefined;
if (oldUdp && oldUdp !== newUdp) {
oldUdp.on('error', noop);
oldUdp.off('error', this.onChildError);
oldUdp.off('close', this.onUdpClose);
oldUdp.off('debug', this.onUdpDebug);
oldUdp.destroy();
}
const oldState = this._state;
this._state = newState;
this.emit('stateChange', oldState, newState);
/**
* Debug event for Networking.
*
* @event Networking#debug
* @type {string}
*/
this.debug?.(`state change:\nfrom ${stringifyState(oldState)}\nto ${stringifyState(newState)}`);
}
/**
* Creates a new WebSocket to a Discord Voice gateway.
*
* @param endpoint - The endpoint to connect to
* @param debug - Whether to enable debug logging
*/
private createWebSocket(endpoint: string) {
const ws = new VoiceWebSocket(`wss://${endpoint}?v=4`, Boolean(this.debug));
ws.on('error', this.onChildError);
ws.once('open', this.onWsOpen);
ws.on('packet', this.onWsPacket);
ws.once('close', this.onWsClose);
ws.on('debug', this.onWsDebug);
return ws;
}
/**
* Propagates errors from the children VoiceWebSocket and VoiceUDPSocket.
*
* @param error - The error that was emitted by a child
*/
private onChildError(error: Error) {
this.emit('error', error);
}
/**
* Called when the WebSocket opens. Depending on the state that the instance is in,
* it will either identify with a new session, or it will attempt to resume an existing session.
*/
private onWsOpen() {
if (this.state.code === NetworkingStatusCode.OpeningWs) {
const packet = {
op: VoiceOpcodes.Identify,
d: {
server_id: this.state.connectionOptions.serverId,
user_id: this.state.connectionOptions.userId,
session_id: this.state.connectionOptions.sessionId,
token: this.state.connectionOptions.token,
},
};
this.state.ws.sendPacket(packet);
this.state = {
...this.state,
code: NetworkingStatusCode.Identifying,
};
} else if (this.state.code === NetworkingStatusCode.Resuming) {
const packet = {
op: VoiceOpcodes.Resume,
d: {
server_id: this.state.connectionOptions.serverId,
session_id: this.state.connectionOptions.sessionId,
token: this.state.connectionOptions.token,
},
};
this.state.ws.sendPacket(packet);
}
}
/**
* Called when the WebSocket closes. Based on the reason for closing (given by the code parameter),
* the instance will either attempt to resume, or enter the closed state and emit a 'close' event
* with the close code, allowing the user to decide whether or not they would like to reconnect.
*
* @param code - The close code
*/
private onWsClose({ code }: CloseEvent) {
const canResume = code === 4015 || code < 4000;
if (canResume && this.state.code === NetworkingStatusCode.Ready) {
this.state = {
...this.state,
code: NetworkingStatusCode.Resuming,
ws: this.createWebSocket(this.state.connectionOptions.endpoint),
};
} else if (this.state.code !== NetworkingStatusCode.Closed) {
this.destroy();
this.emit('close', code);
}
}
/**
* Called when the UDP socket has closed itself if it has stopped receiving replies from Discord.
*/
private onUdpClose() {
if (this.state.code === NetworkingStatusCode.Ready) {
this.state = {
...this.state,
code: NetworkingStatusCode.Resuming,
ws: this.createWebSocket(this.state.connectionOptions.endpoint),
};
}
}
/**
* Called when a packet is received on the connection's WebSocket.
*
* @param packet - The received packet
*/
private onWsPacket(packet: any) {
if (packet.op === VoiceOpcodes.Hello && this.state.code !== NetworkingStatusCode.Closed) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.state.ws.setHeartbeatInterval(packet.d.heartbeat_interval);
} else if (packet.op === VoiceOpcodes.Ready && this.state.code === NetworkingStatusCode.Identifying) {
const { ip, port, ssrc, modes } = packet.d;
const udp = new VoiceUDPSocket({ ip, port });
udp.on('error', this.onChildError);
udp.on('debug', this.onUdpDebug);
udp.once('close', this.onUdpClose);
udp
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
.performIPDiscovery(ssrc)
.then((localConfig) => {
if (this.state.code !== NetworkingStatusCode.UdpHandshaking) return;
this.state.ws.sendPacket({
op: VoiceOpcodes.SelectProtocol,
d: {
protocol: 'udp',
data: {
address: localConfig.ip,
port: localConfig.port,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
mode: chooseEncryptionMode(modes),
},
},
});
this.state = {
...this.state,
code: NetworkingStatusCode.SelectingProtocol,
};
})
.catch((error: Error) => this.emit('error', error));
this.state = {
...this.state,
code: NetworkingStatusCode.UdpHandshaking,
udp,
connectionData: {
ssrc,
},
};
} else if (
packet.op === VoiceOpcodes.SessionDescription &&
this.state.code === NetworkingStatusCode.SelectingProtocol
) {
const { mode: encryptionMode, secret_key: secretKey } = packet.d;
this.state = {
...this.state,
code: NetworkingStatusCode.Ready,
connectionData: {
...this.state.connectionData,
encryptionMode,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
secretKey: new Uint8Array(secretKey),
sequence: randomNBit(16),
timestamp: randomNBit(32),
nonce: 0,
nonceBuffer: Buffer.alloc(24),
speaking: false,
packetsPlayed: 0,
},
};
} else if (packet.op === VoiceOpcodes.Resumed && this.state.code === NetworkingStatusCode.Resuming) {
this.state = {
...this.state,
code: NetworkingStatusCode.Ready,
};
this.state.connectionData.speaking = false;
}
}
/**
* Propagates debug messages from the child WebSocket.
*
* @param message - The emitted debug message
*/
private onWsDebug(message: string) {
this.debug?.(`[WS] ${message}`);
}
/**
* Propagates debug messages from the child UDPSocket.
*
* @param message - The emitted debug message
*/
private onUdpDebug(message: string) {
this.debug?.(`[UDP] ${message}`);
}
/**
* Prepares an Opus packet for playback. This includes attaching metadata to it and encrypting it.
* It will be stored within the instance, and can be played by dispatchAudio()
*
* @remarks
* Calling this method while there is already a prepared audio packet that has not yet been dispatched
* will overwrite the existing audio packet. This should be avoided.
*
* @param opusPacket - The Opus packet to encrypt
*
* @returns The audio packet that was prepared
*/
public prepareAudioPacket(opusPacket: Buffer) {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return;
state.preparedPacket = this.createAudioPacket(opusPacket, state.connectionData);
return state.preparedPacket;
}
/**
* Dispatches the audio packet previously prepared by prepareAudioPacket(opusPacket). The audio packet
* is consumed and cannot be dispatched again.
*/
public dispatchAudio() {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return false;
if (typeof state.preparedPacket !== 'undefined') {
this.playAudioPacket(state.preparedPacket);
state.preparedPacket = undefined;
return true;
}
return false;
}
/**
* Plays an audio packet, updating timing metadata used for playback.
*
* @param audioPacket - The audio packet to play
*/
private playAudioPacket(audioPacket: Buffer) {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return;
const { connectionData } = state;
connectionData.packetsPlayed++;
connectionData.sequence++;
connectionData.timestamp += TIMESTAMP_INC;
if (connectionData.sequence >= 2 ** 16) connectionData.sequence = 0;
if (connectionData.timestamp >= 2 ** 32) connectionData.timestamp = 0;
this.setSpeaking(true);
state.udp.send(audioPacket);
}
/**
* Sends a packet to the voice gateway indicating that the client has start/stopped sending
* audio.
*
* @param speaking - Whether or not the client should be shown as speaking
*/
public setSpeaking(speaking: boolean) {
const state = this.state;
if (state.code !== NetworkingStatusCode.Ready) return;
if (state.connectionData.speaking === speaking) return;
state.connectionData.speaking = speaking;
state.ws.sendPacket({
op: VoiceOpcodes.Speaking,
d: {
speaking: speaking ? 1 : 0,
delay: 0,
ssrc: state.connectionData.ssrc,
},
});
}
/**
* Creates a new audio packet from an Opus packet. This involves encrypting the packet,
* then prepending a header that includes metadata.
*
* @param opusPacket - The Opus packet to prepare
* @param connectionData - The current connection data of the instance
*/
private createAudioPacket(opusPacket: Buffer, connectionData: ConnectionData) {
const packetBuffer = Buffer.alloc(12);
packetBuffer[0] = 0x80;
packetBuffer[1] = 0x78;
const { sequence, timestamp, ssrc } = connectionData;
packetBuffer.writeUIntBE(sequence, 2, 2);
packetBuffer.writeUIntBE(timestamp, 4, 4);
packetBuffer.writeUIntBE(ssrc, 8, 4);
packetBuffer.copy(nonce, 0, 0, 12);
return Buffer.concat([packetBuffer, ...this.encryptOpusPacket(opusPacket, connectionData)]);
}
/**
* Encrypts an Opus packet using the format agreed upon by the instance and Discord.
*
* @param opusPacket - The Opus packet to encrypt
* @param connectionData - The current connection data of the instance
*/
private encryptOpusPacket(opusPacket: Buffer, connectionData: ConnectionData) {
const { secretKey, encryptionMode } = connectionData;
if (encryptionMode === 'xsalsa20_poly1305_lite') {
connectionData.nonce++;
if (connectionData.nonce > MAX_NONCE_SIZE) connectionData.nonce = 0;
connectionData.nonceBuffer.writeUInt32BE(connectionData.nonce, 0);
return [
secretbox.methods.close(opusPacket, connectionData.nonceBuffer, secretKey),
connectionData.nonceBuffer.slice(0, 4),
];
} else if (encryptionMode === 'xsalsa20_poly1305_suffix') {
const random = secretbox.methods.random(24, connectionData.nonceBuffer);
return [secretbox.methods.close(opusPacket, random, secretKey), random];
}
return [secretbox.methods.close(opusPacket, nonce, secretKey)];
}
}
/**
* Returns a random number that is in the range of n bits.
*
* @param n - The number of bits
*/
function randomNBit(n: number) {
return Math.floor(Math.random() * 2 ** n);
}
/**
* Stringifies a NetworkingState.
*
* @param state - The state to stringify
*/
function stringifyState(state: NetworkingState) {
return JSON.stringify({
...state,
ws: Reflect.has(state, 'ws'),
udp: Reflect.has(state, 'udp'),
});
}
/**
* Chooses an encryption mode from a list of given options. Chooses the most preferred option.
*
* @param options - The available encryption options
*/
function chooseEncryptionMode(options: string[]): string {
const option = options.find((option) => SUPPORTED_ENCRYPTION_MODES.includes(option));
if (!option) {
throw new Error(`No compatible encryption modes. Available include: ${options.join(', ')}`);
}
return option;
}

View File

@@ -0,0 +1,212 @@
import { createSocket, Socket } from 'node:dgram';
import { isIPv4 } from 'node:net';
import { TypedEmitter } from 'tiny-typed-emitter';
import type { Awaited } from '../util/util';
/**
* Stores an IP address and port. Used to store socket details for the local client as well as
* for Discord.
*/
export interface SocketConfig {
ip: string;
port: number;
}
interface KeepAlive {
value: number;
timestamp: number;
}
export interface VoiceUDPSocketEvents {
error: (error: Error) => Awaited<void>;
close: () => Awaited<void>;
debug: (message: string) => Awaited<void>;
message: (message: Buffer) => Awaited<void>;
}
/**
* The interval in milliseconds at which keep alive datagrams are sent.
*/
const KEEP_ALIVE_INTERVAL = 5e3;
/**
* The maximum number of keep alive packets which can be missed.
*/
const KEEP_ALIVE_LIMIT = 12;
/**
* The maximum value of the keep alive counter.
*/
const MAX_COUNTER_VALUE = 2 ** 32 - 1;
/**
* Manages the UDP networking for a voice connection.
*/
export class VoiceUDPSocket extends TypedEmitter<VoiceUDPSocketEvents> {
/**
* The underlying network Socket for the VoiceUDPSocket.
*/
private readonly socket: Socket;
/**
* The socket details for Discord (remote)
*/
private readonly remote: SocketConfig;
/**
* A list of keep alives that are waiting to be acknowledged.
*/
private readonly keepAlives: KeepAlive[];
/**
* The counter used in the keep alive mechanism.
*/
private keepAliveCounter = 0;
/**
* The buffer used to write the keep alive counter into.
*/
private readonly keepAliveBuffer: Buffer;
/**
* The Node.js interval for the keep-alive mechanism.
*/
private readonly keepAliveInterval: NodeJS.Timeout;
/**
* The time taken to receive a response to keep alive messages.
*/
public ping?: number;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* Creates a new VoiceUDPSocket.
*
* @param remote - Details of the remote socket
*/
public constructor(remote: SocketConfig, debug = false) {
super();
this.socket = createSocket('udp4');
this.socket.on('error', (error: Error) => this.emit('error', error));
this.socket.on('message', (buffer: Buffer) => this.onMessage(buffer));
this.socket.on('close', () => this.emit('close'));
this.remote = remote;
this.keepAlives = [];
this.keepAliveBuffer = Buffer.alloc(8);
this.keepAliveInterval = setInterval(() => this.keepAlive(), KEEP_ALIVE_INTERVAL);
setImmediate(() => this.keepAlive());
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
}
/**
* Called when a message is received on the UDP socket.
*
* @param buffer The received buffer
*/
private onMessage(buffer: Buffer): void {
// Handle keep alive message
if (buffer.length === 8) {
const counter = buffer.readUInt32LE(0);
const index = this.keepAlives.findIndex(({ value }) => value === counter);
if (index === -1) return;
this.ping = Date.now() - this.keepAlives[index].timestamp;
// Delete all keep alives up to and including the received one
this.keepAlives.splice(0, index);
}
// Propagate the message
this.emit('message', buffer);
}
/**
* Called at a regular interval to check whether we are still able to send datagrams to Discord.
*/
private keepAlive() {
if (this.keepAlives.length >= KEEP_ALIVE_LIMIT) {
this.debug?.('UDP socket has not received enough responses from Discord - closing socket');
this.destroy();
return;
}
this.keepAliveBuffer.writeUInt32LE(this.keepAliveCounter, 0);
this.send(this.keepAliveBuffer);
this.keepAlives.push({
value: this.keepAliveCounter,
timestamp: Date.now(),
});
this.keepAliveCounter++;
if (this.keepAliveCounter > MAX_COUNTER_VALUE) {
this.keepAliveCounter = 0;
}
}
/**
* Sends a buffer to Discord.
*
* @param buffer - The buffer to send
*/
public send(buffer: Buffer) {
return this.socket.send(buffer, this.remote.port, this.remote.ip);
}
/**
* Closes the socket, the instance will not be able to be reused.
*/
public destroy() {
try {
this.socket.close();
} catch {}
clearInterval(this.keepAliveInterval);
}
/**
* Performs IP discovery to discover the local address and port to be used for the voice connection.
*
* @param ssrc - The SSRC received from Discord
*/
public performIPDiscovery(ssrc: number): Promise<SocketConfig> {
return new Promise((resolve, reject) => {
const listener = (message: Buffer) => {
try {
if (message.readUInt16BE(0) !== 2) return;
const packet = parseLocalPacket(message);
this.socket.off('message', listener);
resolve(packet);
} catch {}
};
this.socket.on('message', listener);
this.socket.once('close', () => reject(new Error('Cannot perform IP discovery - socket closed')));
const discoveryBuffer = Buffer.alloc(74);
discoveryBuffer.writeUInt16BE(1, 0);
discoveryBuffer.writeUInt16BE(70, 2);
discoveryBuffer.writeUInt32BE(ssrc, 4);
this.send(discoveryBuffer);
});
}
}
/**
* Parses the response from Discord to aid with local IP discovery.
*
* @param message - The received message
*/
export function parseLocalPacket(message: Buffer): SocketConfig {
const packet = Buffer.from(message);
const ip = packet.slice(8, packet.indexOf(0, 8)).toString('utf-8');
if (!isIPv4(ip)) {
throw new Error('Malformed IP address');
}
const port = packet.readUInt16BE(packet.length - 2);
return { ip, port };
}

View File

@@ -0,0 +1,179 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import WebSocket, { MessageEvent } from 'ws';
import { TypedEmitter } from 'tiny-typed-emitter';
import type { Awaited } from '../util/util';
/**
* Debug event for VoiceWebSocket.
*
* @event VoiceWebSocket#debug
* @type {string}
*/
export interface VoiceWebSocketEvents {
error: (error: Error) => Awaited<void>;
open: (event: WebSocket.Event) => Awaited<void>;
close: (event: WebSocket.CloseEvent) => Awaited<void>;
debug: (message: string) => Awaited<void>;
packet: (packet: any) => Awaited<void>;
}
/**
* An extension of the WebSocket class to provide helper functionality when interacting
* with the Discord Voice gateway.
*/
export class VoiceWebSocket extends TypedEmitter<VoiceWebSocketEvents> {
/**
* The current heartbeat interval, if any.
*/
private heartbeatInterval?: NodeJS.Timeout;
/**
* The time (milliseconds since UNIX epoch) that the last heartbeat acknowledgement packet was received.
* This is set to 0 if an acknowledgement packet hasn't been received yet.
*/
private lastHeartbeatAck: number;
/**
* The time (milliseconds since UNIX epoch) that the last heartbeat was sent. This is set to 0 if a heartbeat
* hasn't been sent yet.
*/
private lastHeatbeatSend: number;
/**
* The number of consecutively missed heartbeats.
*/
private missedHeartbeats = 0;
/**
* The last recorded ping.
*/
public ping?: number;
/**
* The debug logger function, if debugging is enabled.
*/
private readonly debug: null | ((message: string) => void);
/**
* The underlying WebSocket of this wrapper.
*/
private readonly ws: WebSocket;
/**
* Creates a new VoiceWebSocket.
*
* @param address - The address to connect to
*/
public constructor(address: string, debug: boolean) {
super();
this.ws = new WebSocket(address);
this.ws.onmessage = (e) => this.onMessage(e);
this.ws.onopen = (e) => this.emit('open', e);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
this.ws.onerror = (e: Error | WebSocket.ErrorEvent) => this.emit('error', e instanceof Error ? e : e.error);
this.ws.onclose = (e) => this.emit('close', e);
this.lastHeartbeatAck = 0;
this.lastHeatbeatSend = 0;
this.debug = debug ? (message: string) => this.emit('debug', message) : null;
}
/**
* Destroys the VoiceWebSocket. The heartbeat interval is cleared, and the connection is closed.
*/
public destroy() {
try {
this.debug?.('destroyed');
this.setHeartbeatInterval(-1);
this.ws.close(1000);
} catch (error) {
const e = error as Error;
this.emit('error', e);
}
}
/**
* Handles message events on the WebSocket. Attempts to JSON parse the messages and emit them
* as packets.
*
* @param event - The message event
*/
public onMessage(event: MessageEvent) {
if (typeof event.data !== 'string') return;
this.debug?.(`<< ${event.data}`);
let packet: any;
try {
packet = JSON.parse(event.data);
} catch (error) {
const e = error as Error;
this.emit('error', e);
return;
}
if (packet.op === VoiceOpcodes.HeartbeatAck) {
this.lastHeartbeatAck = Date.now();
this.missedHeartbeats = 0;
this.ping = this.lastHeartbeatAck - this.lastHeatbeatSend;
}
/**
* Packet event.
*
* @event VoiceWebSocket#packet
* @type {any}
*/
this.emit('packet', packet);
}
/**
* Sends a JSON-stringifiable packet over the WebSocket.
*
* @param packet - The packet to send
*/
public sendPacket(packet: any) {
try {
const stringified = JSON.stringify(packet);
this.debug?.(`>> ${stringified}`);
return this.ws.send(stringified);
} catch (error) {
const e = error as Error;
this.emit('error', e);
}
}
/**
* Sends a heartbeat over the WebSocket.
*/
private sendHeartbeat() {
this.lastHeatbeatSend = Date.now();
this.missedHeartbeats++;
const nonce = this.lastHeatbeatSend;
return this.sendPacket({
op: VoiceOpcodes.Heartbeat,
d: nonce,
});
}
/**
* Sets/clears an interval to send heartbeats over the WebSocket.
*
* @param ms - The interval in milliseconds. If negative, the interval will be unset
*/
public setHeartbeatInterval(ms: number) {
if (typeof this.heartbeatInterval !== 'undefined') clearInterval(this.heartbeatInterval);
if (ms > 0) {
this.heartbeatInterval = setInterval(() => {
if (this.lastHeatbeatSend !== 0 && this.missedHeartbeats >= 3) {
// Missed too many heartbeats - disconnect
this.ws.close();
this.setHeartbeatInterval(-1);
}
this.sendHeartbeat();
}, ms);
}
}
}

View File

@@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { createSocket as _createSocket } from 'node:dgram';
import EventEmitter, { once } from 'node:events';
import { VoiceUDPSocket } from '../VoiceUDPSocket';
jest.mock('node:dgram');
jest.useFakeTimers();
const createSocket = _createSocket as unknown as jest.Mock<typeof _createSocket>;
beforeEach(() => {
createSocket.mockReset();
});
class FakeSocket extends EventEmitter {
public send(buffer: Buffer, port: number, address: string) {}
public close() {
this.emit('close');
}
}
// ip = 91.90.123.93, port = 54148
// eslint-disable-next-line prettier/prettier
const VALID_RESPONSE = Buffer.from([
0x0, 0x2, 0x0, 0x46, 0x0, 0x4, 0xeb, 0x23, 0x39, 0x31, 0x2e, 0x39, 0x30, 0x2e, 0x31, 0x32, 0x33, 0x2e, 0x39, 0x33,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd3, 0x84,
]);
function wait() {
return new Promise((resolve) => {
setImmediate(resolve);
jest.advanceTimersToNextTimer();
});
}
describe('VoiceUDPSocket#performIPDiscovery', () => {
let socket: VoiceUDPSocket;
afterEach(() => {
socket.destroy();
});
/*
Ensures that the UDP socket sends data and parses the response correctly
*/
test('Resolves and cleans up with a successful flow', async () => {
const fake = new FakeSocket();
fake.send = jest.fn().mockImplementation((buffer: Buffer, port: number, address: string) => {
fake.emit('message', VALID_RESPONSE);
});
createSocket.mockImplementation((type) => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
expect(createSocket).toHaveBeenCalledWith('udp4');
expect(fake.listenerCount('message')).toBe(1);
await expect(socket.performIPDiscovery(1234)).resolves.toEqual({
ip: '91.90.123.93',
port: 54148,
});
// Ensure clean up occurs
expect(fake.listenerCount('message')).toBe(1);
});
/*
In the case where an unrelated message is received before the IP discovery buffer,
the UDP socket should wait indefinitely until the correct buffer arrives.
*/
test('Waits for a valid response in an unexpected flow', async () => {
const fake = new FakeSocket();
const fakeResponse = Buffer.from([1, 2, 3, 4, 5]);
fake.send = jest.fn().mockImplementation(async (buffer: Buffer, port: number, address: string) => {
fake.emit('message', fakeResponse);
await wait();
fake.emit('message', VALID_RESPONSE);
});
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
expect(createSocket).toHaveBeenCalledWith('udp4');
expect(fake.listenerCount('message')).toBe(1);
await expect(socket.performIPDiscovery(1234)).resolves.toEqual({
ip: '91.90.123.93',
port: 54148,
});
// Ensure clean up occurs
expect(fake.listenerCount('message')).toBe(1);
});
test('Rejects if socket closes before IP discovery can be completed', async () => {
const fake = new FakeSocket();
fake.send = jest.fn().mockImplementation(async (buffer: Buffer, port: number, address: string) => {
await wait();
fake.close();
});
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
expect(createSocket).toHaveBeenCalledWith('udp4');
await expect(socket.performIPDiscovery(1234)).rejects.toThrowError();
});
test('Stays alive when messages are echoed back', async () => {
const fake = new FakeSocket();
fake.send = jest.fn().mockImplementation(async (buffer: Buffer) => {
await wait();
fake.emit('message', buffer);
});
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
let closed = false;
// @ts-expect-error
socket.on('close', () => (closed = true));
for (let i = 0; i < 30; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
expect(closed).toBe(false);
});
test('Emits an error when no response received to keep alive messages', async () => {
const fake = new FakeSocket();
fake.send = jest.fn();
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
let closed = false;
// @ts-expect-error
socket.on('close', () => (closed = true));
for (let i = 0; i < 15; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
expect(closed).toBe(true);
});
test('Recovers from intermittent responses', async () => {
const fake = new FakeSocket();
const fakeSend = jest.fn();
fake.send = fakeSend;
createSocket.mockImplementation(() => fake as any);
socket = new VoiceUDPSocket({ ip: '1.2.3.4', port: 25565 });
let closed = false;
// @ts-expect-error
socket.on('close', () => (closed = true));
for (let i = 0; i < 10; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
fakeSend.mockImplementation(async (buffer: Buffer) => {
await wait();
fake.emit('message', buffer);
});
expect(closed).toBe(false);
for (let i = 0; i < 30; i++) {
jest.advanceTimersToNextTimer();
await wait();
}
expect(closed).toBe(false);
});
});

View File

@@ -0,0 +1,121 @@
import { VoiceOpcodes } from 'discord-api-types/voice/v4';
import EventEmitter, { once } from 'node:events';
import WS from 'jest-websocket-mock';
import { VoiceWebSocket } from '../VoiceWebSocket';
beforeEach(() => {
WS.clean();
});
function onceIgnoreError<T extends EventEmitter>(target: T, event: string) {
return new Promise((resolve) => {
target.on(event, resolve);
});
}
function onceOrThrow<T extends EventEmitter>(target: T, event: string, after: number) {
return new Promise((resolve, reject) => {
target.on(event, resolve);
setTimeout(() => reject(new Error('Time up')), after);
});
}
describe('VoiceWebSocket: packet parsing', () => {
test('Parses and emits packets', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint, { jsonProtocol: true });
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const dummy = { value: 3 };
const rcv = once(ws, 'packet');
server.send(dummy);
await expect(rcv).resolves.toEqual([dummy]);
});
test('Recovers from invalid packets', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
let rcv = once(ws, 'packet');
server.send('asdf');
await expect(rcv).rejects.toThrowError();
const dummy = { op: 1234 };
rcv = once(ws, 'packet');
server.send(JSON.stringify(dummy));
await expect(rcv).resolves.toEqual([dummy]);
});
});
describe('VoiceWebSocket: event propagation', () => {
test('open', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
const rcv = once(ws, 'open');
await server.connected;
await expect(rcv).resolves.toBeTruthy();
});
test('close (clean)', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const rcv = once(ws, 'close');
server.close();
await expect(rcv).resolves.toBeTruthy();
});
test('close (error)', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint);
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const rcvError = once(ws, 'error');
const rcvClose = onceIgnoreError(ws, 'close');
server.error();
await expect(rcvError).resolves.toBeTruthy();
await expect(rcvClose).resolves.toBeTruthy();
});
});
describe('VoiceWebSocket: heartbeating', () => {
test('Normal heartbeat flow', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint, { jsonProtocol: true });
const ws = new VoiceWebSocket(endpoint, false);
await server.connected;
const rcv = onceOrThrow(ws, 'close', 750);
ws.setHeartbeatInterval(50);
for (let i = 0; i < 10; i++) {
const packet: any = await server.nextMessage;
expect(packet).toMatchObject({
op: VoiceOpcodes.Heartbeat,
});
server.send({
op: VoiceOpcodes.HeartbeatAck,
d: packet.d,
});
expect(ws.ping).toBeGreaterThanOrEqual(0);
}
ws.setHeartbeatInterval(-1);
await expect(rcv).rejects.toThrowError();
});
test('Closes when no ack is received', async () => {
const endpoint = 'ws://localhost:1234';
const server = new WS(endpoint, { jsonProtocol: true });
const ws = new VoiceWebSocket(endpoint, false);
// eslint-disable-next-line @typescript-eslint/no-empty-function
ws.on('error', () => {});
await server.connected;
const rcv = onceIgnoreError(ws, 'close');
ws.setHeartbeatInterval(50);
await expect(rcv).resolves.toBeTruthy();
expect(ws.ping).toBe(undefined);
expect(server.messages.length).toBe(3);
});
});

View File

@@ -0,0 +1,3 @@
export * from './Networking';
export * from './VoiceUDPSocket';
export * from './VoiceWebSocket';