mirror of
https://github.com/discordjs/discord.js.git
synced 2026-03-12 17:43:30 +01:00
chore: monorepo setup (#7175)
This commit is contained in:
594
packages/voice/src/networking/Networking.ts
Normal file
594
packages/voice/src/networking/Networking.ts
Normal 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;
|
||||
}
|
||||
212
packages/voice/src/networking/VoiceUDPSocket.ts
Normal file
212
packages/voice/src/networking/VoiceUDPSocket.ts
Normal 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 };
|
||||
}
|
||||
179
packages/voice/src/networking/VoiceWebSocket.ts
Normal file
179
packages/voice/src/networking/VoiceWebSocket.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
packages/voice/src/networking/__tests__/VoiceUDPSocket.test.ts
Normal file
170
packages/voice/src/networking/__tests__/VoiceUDPSocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
packages/voice/src/networking/__tests__/VoiceWebSocket.test.ts
Normal file
121
packages/voice/src/networking/__tests__/VoiceWebSocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
3
packages/voice/src/networking/index.ts
Normal file
3
packages/voice/src/networking/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Networking';
|
||||
export * from './VoiceUDPSocket';
|
||||
export * from './VoiceWebSocket';
|
||||
Reference in New Issue
Block a user