2022-03-19 10:37:45 +00:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
const EventEmitter = require('node:events');
|
|
|
|
const { setImmediate } = require('node:timers');
|
|
|
|
const { setTimeout: sleep } = require('node:timers/promises');
|
|
|
|
const { Collection } = require('@discordjs/collection');
|
2022-03-24 10:55:32 +00:00
|
|
|
const { RPCErrorCodes } = require('discord-api-types/v9');
|
2022-03-19 10:37:45 +00:00
|
|
|
const WebSocketShard = require('./WebSocketShard');
|
|
|
|
const PacketHandlers = require('./handlers');
|
|
|
|
const { Error } = require('../../errors');
|
2022-03-24 10:55:32 +00:00
|
|
|
const { Events, ShardEvents, Status, WSCodes, WSEvents } = require('../../util/Constants');
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
const BeforeReadyWhitelist = [
|
2022-03-24 10:55:32 +00:00
|
|
|
WSEvents.READY,
|
|
|
|
WSEvents.RESUMED,
|
|
|
|
WSEvents.GUILD_CREATE,
|
|
|
|
WSEvents.GUILD_DELETE,
|
|
|
|
WSEvents.GUILD_MEMBERS_CHUNK,
|
|
|
|
WSEvents.GUILD_MEMBER_ADD,
|
|
|
|
WSEvents.GUILD_MEMBER_REMOVE,
|
2022-03-19 10:37:45 +00:00
|
|
|
];
|
|
|
|
|
2022-03-24 10:55:32 +00:00
|
|
|
const UNRECOVERABLE_CLOSE_CODES = Object.keys(WSCodes).slice(1).map(Number);
|
|
|
|
const UNRESUMABLE_CLOSE_CODES = [
|
|
|
|
RPCErrorCodes.UnknownError,
|
|
|
|
RPCErrorCodes.InvalidPermissions,
|
|
|
|
RPCErrorCodes.InvalidClientId,
|
2022-03-19 10:37:45 +00:00
|
|
|
];
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The WebSocket manager for this client.
|
|
|
|
* <info>This class forwards raw dispatch events,
|
|
|
|
* read more about it here {@link https://discord.com/developers/docs/topics/gateway}</info>
|
|
|
|
* @extends EventEmitter
|
|
|
|
*/
|
|
|
|
class WebSocketManager extends EventEmitter {
|
|
|
|
constructor(client) {
|
|
|
|
super();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The client that instantiated this WebSocketManager
|
|
|
|
* @type {Client}
|
|
|
|
* @readonly
|
|
|
|
* @name WebSocketManager#client
|
|
|
|
*/
|
|
|
|
Object.defineProperty(this, 'client', { value: client });
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The gateway this manager uses
|
|
|
|
* @type {?string}
|
|
|
|
*/
|
|
|
|
this.gateway = null;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The amount of shards this manager handles
|
|
|
|
* @private
|
|
|
|
* @type {number}
|
|
|
|
*/
|
|
|
|
this.totalShards = this.client.options.shards.length;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* A collection of all shards this manager handles
|
|
|
|
* @type {Collection<number, WebSocketShard>}
|
|
|
|
*/
|
|
|
|
this.shards = new Collection();
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An array of shards to be connected or that need to reconnect
|
|
|
|
* @type {Set<WebSocketShard>}
|
|
|
|
* @private
|
|
|
|
* @name WebSocketManager#shardQueue
|
|
|
|
*/
|
|
|
|
Object.defineProperty(this, 'shardQueue', { value: new Set(), writable: true });
|
|
|
|
|
|
|
|
/**
|
|
|
|
* An array of queued events before this WebSocketManager became ready
|
|
|
|
* @type {Object[]}
|
|
|
|
* @private
|
|
|
|
* @name WebSocketManager#packetQueue
|
|
|
|
*/
|
|
|
|
Object.defineProperty(this, 'packetQueue', { value: [] });
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The current status of this WebSocketManager
|
|
|
|
* @type {Status}
|
|
|
|
*/
|
2022-03-24 10:55:32 +00:00
|
|
|
this.status = Status.IDLE;
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* If this manager was destroyed. It will prevent shards from reconnecting
|
|
|
|
* @type {boolean}
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
this.destroyed = false;
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If this manager is currently reconnecting one or multiple shards
|
|
|
|
* @type {boolean}
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
this.reconnecting = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* The average ping of all WebSocketShards
|
|
|
|
* @type {number}
|
|
|
|
* @readonly
|
|
|
|
*/
|
|
|
|
get ping() {
|
|
|
|
const sum = this.shards.reduce((a, b) => a + b.ping, 0);
|
|
|
|
return sum / this.shards.size;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Emits a debug message.
|
|
|
|
* @param {string} message The debug message
|
|
|
|
* @param {?WebSocketShard} [shard] The shard that emitted this message, if any
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
debug(message, shard) {
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.DEBUG, `[WS => ${shard ? `Shard ${shard.id}` : 'Manager'}] ${message}`);
|
2022-03-19 10:37:45 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Connects this manager to the gateway.
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
async connect() {
|
2022-03-24 10:55:32 +00:00
|
|
|
const invalidToken = new Error(WSCodes[4004]);
|
2022-03-19 10:37:45 +00:00
|
|
|
const {
|
|
|
|
url: gatewayURL,
|
|
|
|
shards: recommendedShards,
|
|
|
|
session_start_limit: sessionStartLimit,
|
|
|
|
} = await this.client.api.gateway.bot.get().catch(error => {
|
|
|
|
throw error.httpStatus === 401 ? invalidToken : error;
|
|
|
|
});
|
|
|
|
|
|
|
|
const { total, remaining } = sessionStartLimit;
|
|
|
|
|
|
|
|
this.debug(`Fetched Gateway Information
|
|
|
|
URL: ${gatewayURL}
|
|
|
|
Recommended Shards: ${recommendedShards}`);
|
|
|
|
|
|
|
|
this.debug(`Session Limit Information
|
|
|
|
Total: ${total}
|
|
|
|
Remaining: ${remaining}`);
|
|
|
|
|
|
|
|
this.gateway = `${gatewayURL}/`;
|
|
|
|
|
|
|
|
let { shards } = this.client.options;
|
|
|
|
|
|
|
|
if (shards === 'auto') {
|
|
|
|
this.debug(`Using the recommended shard count provided by Discord: ${recommendedShards}`);
|
|
|
|
this.totalShards = this.client.options.shardCount = recommendedShards;
|
|
|
|
shards = this.client.options.shards = Array.from({ length: recommendedShards }, (_, i) => i);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.totalShards = shards.length;
|
|
|
|
this.debug(`Spawning shards: ${shards.join(', ')}`);
|
|
|
|
this.shardQueue = new Set(shards.map(id => new WebSocketShard(this, id)));
|
|
|
|
|
|
|
|
return this.createShards();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the creation of a shard.
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
async createShards() {
|
|
|
|
// If we don't have any shards to handle, return
|
|
|
|
if (!this.shardQueue.size) return false;
|
|
|
|
|
|
|
|
const [shard] = this.shardQueue;
|
|
|
|
|
|
|
|
this.shardQueue.delete(shard);
|
|
|
|
|
|
|
|
if (!shard.eventsAttached) {
|
2022-03-24 10:55:32 +00:00
|
|
|
shard.on(ShardEvents.ALL_READY, unavailableGuilds => {
|
2022-03-19 10:37:45 +00:00
|
|
|
/**
|
|
|
|
* Emitted when a shard turns ready.
|
|
|
|
* @event Client#shardReady
|
|
|
|
* @param {number} id The shard id that turned ready
|
|
|
|
* @param {?Set<Snowflake>} unavailableGuilds Set of unavailable guild ids, if any
|
|
|
|
*/
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.SHARD_READY, shard.id, unavailableGuilds);
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
if (!this.shardQueue.size) this.reconnecting = false;
|
|
|
|
this.checkShardsReady();
|
|
|
|
});
|
|
|
|
|
2022-03-24 10:55:32 +00:00
|
|
|
shard.on(ShardEvents.CLOSE, event => {
|
2022-03-19 10:37:45 +00:00
|
|
|
if (event.code === 1_000 ? this.destroyed : UNRECOVERABLE_CLOSE_CODES.includes(event.code)) {
|
|
|
|
/**
|
|
|
|
* Emitted when a shard's WebSocket disconnects and will no longer reconnect.
|
|
|
|
* @event Client#shardDisconnect
|
|
|
|
* @param {CloseEvent} event The WebSocket close event
|
|
|
|
* @param {number} id The shard id that disconnected
|
|
|
|
*/
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.SHARD_DISCONNECT, event, shard.id);
|
|
|
|
this.debug(WSCodes[event.code], shard);
|
2022-03-19 10:37:45 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (UNRESUMABLE_CLOSE_CODES.includes(event.code)) {
|
|
|
|
// These event codes cannot be resumed
|
|
|
|
shard.sessionId = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Emitted when a shard is attempting to reconnect or re-identify.
|
|
|
|
* @event Client#shardReconnecting
|
|
|
|
* @param {number} id The shard id that is attempting to reconnect
|
|
|
|
*/
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.SHARD_RECONNECTING, shard.id);
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
this.shardQueue.add(shard);
|
|
|
|
|
|
|
|
if (shard.sessionId) {
|
|
|
|
this.debug(`Session id is present, attempting an immediate reconnect...`, shard);
|
|
|
|
this.reconnect();
|
|
|
|
} else {
|
|
|
|
shard.destroy({ reset: true, emit: false, log: false });
|
|
|
|
this.reconnect();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2022-03-24 10:55:32 +00:00
|
|
|
shard.on(ShardEvents.INVALID_SESSION, () => {
|
|
|
|
this.client.emit(Events.SHARD_RECONNECTING, shard.id);
|
2022-03-19 10:37:45 +00:00
|
|
|
});
|
|
|
|
|
2022-03-24 10:55:32 +00:00
|
|
|
shard.on(ShardEvents.DESTROYED, () => {
|
2022-03-19 10:37:45 +00:00
|
|
|
this.debug('Shard was destroyed but no WebSocket connection was present! Reconnecting...', shard);
|
|
|
|
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.SHARD_RECONNECTING, shard.id);
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
this.shardQueue.add(shard);
|
|
|
|
this.reconnect();
|
|
|
|
});
|
|
|
|
|
|
|
|
shard.eventsAttached = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.shards.set(shard.id, shard);
|
|
|
|
|
|
|
|
try {
|
|
|
|
await shard.connect();
|
|
|
|
} catch (error) {
|
|
|
|
if (error?.code && UNRECOVERABLE_CLOSE_CODES.includes(error.code)) {
|
2022-03-24 10:55:32 +00:00
|
|
|
throw new Error(WSCodes[error.code]);
|
2022-03-19 10:37:45 +00:00
|
|
|
// Undefined if session is invalid, error event for regular closes
|
|
|
|
} else if (!error || error.code) {
|
|
|
|
this.debug('Failed to connect to the gateway, requeueing...', shard);
|
|
|
|
this.shardQueue.add(shard);
|
|
|
|
} else {
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// If we have more shards, add a 5s delay
|
|
|
|
if (this.shardQueue.size) {
|
|
|
|
this.debug(`Shard Queue Size: ${this.shardQueue.size}; continuing in 5 seconds...`);
|
|
|
|
await sleep(5_000);
|
|
|
|
return this.createShards();
|
|
|
|
}
|
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles reconnects for this manager.
|
|
|
|
* @private
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
*/
|
|
|
|
async reconnect() {
|
2022-03-24 10:55:32 +00:00
|
|
|
if (this.reconnecting || this.status !== Status.READY) return false;
|
2022-03-19 10:37:45 +00:00
|
|
|
this.reconnecting = true;
|
|
|
|
try {
|
|
|
|
await this.createShards();
|
|
|
|
} catch (error) {
|
|
|
|
this.debug(`Couldn't reconnect or fetch information about the gateway. ${error}`);
|
|
|
|
if (error.httpStatus !== 401) {
|
|
|
|
this.debug(`Possible network error occurred. Retrying in 5s...`);
|
|
|
|
await sleep(5_000);
|
|
|
|
this.reconnecting = false;
|
|
|
|
return this.reconnect();
|
|
|
|
}
|
|
|
|
// If we get an error at this point, it means we cannot reconnect anymore
|
2022-03-24 10:55:32 +00:00
|
|
|
if (this.client.listenerCount(Events.INVALIDATED)) {
|
2022-03-19 10:37:45 +00:00
|
|
|
/**
|
|
|
|
* Emitted when the client's session becomes invalidated.
|
|
|
|
* You are expected to handle closing the process gracefully and preventing a boot loop
|
|
|
|
* if you are listening to this event.
|
|
|
|
* @event Client#invalidated
|
|
|
|
*/
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.INVALIDATED);
|
2022-03-19 10:37:45 +00:00
|
|
|
// Destroy just the shards. This means you have to handle the cleanup yourself
|
|
|
|
this.destroy();
|
|
|
|
} else {
|
|
|
|
this.client.destroy();
|
|
|
|
}
|
|
|
|
} finally {
|
|
|
|
this.reconnecting = false;
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Broadcasts a packet to every shard this manager handles.
|
|
|
|
* @param {Object} packet The packet to send
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
broadcast(packet) {
|
|
|
|
for (const shard of this.shards.values()) shard.send(packet);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Destroys this manager and all its shards.
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
destroy() {
|
|
|
|
if (this.destroyed) return;
|
|
|
|
this.debug(`Manager was destroyed. Called by:\n${new Error('MANAGER_DESTROYED').stack}`);
|
|
|
|
this.destroyed = true;
|
|
|
|
this.shardQueue.clear();
|
|
|
|
for (const shard of this.shards.values()) shard.destroy({ closeCode: 1_000, reset: true, emit: false, log: false });
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Processes a packet and queues it if this WebSocketManager is not ready.
|
|
|
|
* @param {Object} [packet] The packet to be handled
|
|
|
|
* @param {WebSocketShard} [shard] The shard that will handle this packet
|
|
|
|
* @returns {boolean}
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
handlePacket(packet, shard) {
|
2022-03-24 10:55:32 +00:00
|
|
|
if (packet && this.status !== Status.READY) {
|
2022-03-19 10:37:45 +00:00
|
|
|
if (!BeforeReadyWhitelist.includes(packet.t)) {
|
|
|
|
this.packetQueue.push({ packet, shard });
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.packetQueue.length) {
|
|
|
|
const item = this.packetQueue.shift();
|
|
|
|
setImmediate(() => {
|
|
|
|
this.handlePacket(item.packet, item.shard);
|
|
|
|
}).unref();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (packet && PacketHandlers[packet.t]) {
|
|
|
|
PacketHandlers[packet.t](this.client, packet, shard);
|
2022-04-14 14:32:11 +00:00
|
|
|
} else if (packet) {
|
|
|
|
/* Debug mode */
|
|
|
|
// console.log(`Unhandled packet: ${packet.t}`, packet);
|
2022-04-16 04:59:23 +00:00
|
|
|
this.client.emit(Events.UNHANDLED_PACKET, packet, shard);
|
2022-03-19 10:37:45 +00:00
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Checks whether the client is ready to be marked as ready.
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
checkShardsReady() {
|
2022-03-24 10:55:32 +00:00
|
|
|
if (this.status === Status.READY) return;
|
|
|
|
if (this.shards.size !== this.totalShards || this.shards.some(s => s.status !== Status.READY)) {
|
2022-03-19 10:37:45 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.triggerClientReady();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Causes the client to be marked as ready and emits the ready event.
|
|
|
|
* @private
|
|
|
|
*/
|
|
|
|
triggerClientReady() {
|
2022-03-24 10:55:32 +00:00
|
|
|
this.status = Status.READY;
|
2022-03-19 10:37:45 +00:00
|
|
|
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.readyAt = new Date();
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Emitted when the client becomes ready to start working.
|
|
|
|
* @event Client#ready
|
|
|
|
* @param {Client} client The client
|
|
|
|
*/
|
2022-03-24 10:55:32 +00:00
|
|
|
this.client.emit(Events.CLIENT_READY, this.client);
|
2022-03-19 10:37:45 +00:00
|
|
|
|
|
|
|
this.handlePacket();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = WebSocketManager;
|