diff --git a/package.json b/package.json index 351e5cb42..fc5d8d1e0 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@types/node": "^10.12.24", + "@types/ws": "^6.0.1", "discord.js-docgen": "discordjs/docgen", "eslint": "^5.13.0", "json-filter-loader": "^1.0.0", diff --git a/src/client/Client.js b/src/client/Client.js index 06b8fb489..73767f696 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -15,8 +15,7 @@ const UserStore = require('../stores/UserStore'); const ChannelStore = require('../stores/ChannelStore'); const GuildStore = require('../stores/GuildStore'); const GuildEmojiStore = require('../stores/GuildEmojiStore'); -const { Events, WSCodes, browser, DefaultOptions } = require('../util/Constants'); -const { delayFor } = require('../util/Util'); +const { Events, browser, DefaultOptions } = require('../util/Constants'); const DataResolver = require('../util/DataResolver'); const Structures = require('../util/Structures'); const { Error, TypeError, RangeError } = require('../errors'); @@ -40,23 +39,33 @@ class Client extends BaseClient { } catch (_) { // Do nothing } + if (this.options.shards === DefaultOptions.shards) { if ('SHARDS' in data) { this.options.shards = JSON.parse(data.SHARDS); } } + if (this.options.totalShardCount === DefaultOptions.totalShardCount) { if ('TOTAL_SHARD_COUNT' in data) { this.options.totalShardCount = Number(data.TOTAL_SHARD_COUNT); - } else if (Array.isArray(this.options.shards)) { + } else if (this.options.shards instanceof Array) { this.options.totalShardCount = this.options.shards.length; } else { this.options.totalShardCount = this.options.shardCount; } } - if (typeof this.options.shards === 'undefined' && this.options.shardCount) { - this.options.shards = []; - for (let i = 0; i < this.options.shardCount; ++i) this.options.shards.push(i); + + if (typeof this.options.shards === 'undefined' && typeof this.options.shardCount === 'number') { + this.options.shards = Array.from({ length: this.options.shardCount }, (_, i) => i); + } + + if (typeof this.options.shards === 'number') this.options.shards = [this.options.shards]; + + if (typeof this.options.shards !== 'undefined') { + this.options.shards = [...new Set( + this.options.shards.filter(item => !isNaN(item) && item >= 0 && item < Infinity) + )]; } this._validateOptions(); @@ -199,55 +208,21 @@ class Client extends BaseClient { async login(token = this.token) { if (!token || typeof token !== 'string') throw new Error('TOKEN_INVALID'); this.token = token = token.replace(/^(Bot|Bearer)\s*/i, ''); - this.emit(Events.DEBUG, `Authenticating using token ${token}`); - let endpoint = this.api.gateway; - if (this.options.shardCount === 'auto') endpoint = endpoint.bot; - const res = await endpoint.get(); + this.emit(Events.DEBUG, `Provided token: ${token}`); + if (this.options.presence) { this.options.ws.presence = await this.presence._parse(this.options.presence); } - if (res.session_start_limit && res.session_start_limit.remaining === 0) { - const { session_start_limit: { reset_after } } = res; - this.emit(Events.DEBUG, `Exceeded identify threshold, setting a timeout for ${reset_after} ms`); - await delayFor(reset_after); + + this.emit(Events.DEBUG, 'Preparing to connect to the gateway...'); + + try { + await this.ws.connect(); + return this.token; + } catch (error) { + this.destroy(); + throw error; } - const gateway = `${res.url}/`; - if (this.options.shardCount === 'auto') { - this.emit(Events.DEBUG, `Using recommended shard count ${res.shards}`); - this.options.shardCount = res.shards; - this.options.totalShardCount = res.shards; - if (typeof this.options.shards === 'undefined' || !this.options.shards.length) { - this.options.shards = []; - for (let i = 0; i < this.options.shardCount; ++i) this.options.shards.push(i); - } - } - this.emit(Events.DEBUG, `Using gateway ${gateway}`); - this.ws.connect(gateway); - await new Promise((resolve, reject) => { - const onready = () => { - clearTimeout(timeout); - this.removeListener(Events.DISCONNECT, ondisconnect); - resolve(); - }; - const ondisconnect = event => { - clearTimeout(timeout); - this.removeListener(Events.READY, onready); - this.destroy(); - if (WSCodes[event.code]) { - reject(new Error(WSCodes[event.code])); - } - }; - const timeout = setTimeout(() => { - this.removeListener(Events.READY, onready); - this.removeListener(Events.DISCONNECT, ondisconnect); - this.destroy(); - reject(new Error('WS_CONNECTION_TIMEOUT')); - }, this.options.shardCount * 25e3); - if (timeout.unref !== undefined) timeout.unref(); - this.once(Events.READY, onready); - this.once(Events.DISCONNECT, ondisconnect); - }); - return token; } /** @@ -397,9 +372,10 @@ class Client extends BaseClient { if (options.shardCount !== 'auto' && (typeof options.shardCount !== 'number' || isNaN(options.shardCount))) { throw new TypeError('CLIENT_INVALID_OPTION', 'shardCount', 'a number or "auto"'); } - if (options.shards && typeof options.shards !== 'number' && !Array.isArray(options.shards)) { + if (options.shards && !(options.shards instanceof Array)) { throw new TypeError('CLIENT_INVALID_OPTION', 'shards', 'a number or array'); } + if (options.shards && !options.shards.length) throw new RangeError('CLIENT_INVALID_PROVIDED_SHARDS'); if (options.shardCount < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'shardCount', 'at least 1'); if (typeof options.messageCacheMaxSize !== 'number' || isNaN(options.messageCacheMaxSize)) { throw new TypeError('CLIENT_INVALID_OPTION', 'messageCacheMaxSize', 'a number'); diff --git a/src/client/websocket/WebSocketManager.js b/src/client/websocket/WebSocketManager.js index bc34b84b3..c483e803b 100644 --- a/src/client/websocket/WebSocketManager.js +++ b/src/client/websocket/WebSocketManager.js @@ -1,9 +1,10 @@ 'use strict'; +const { Error: DJSError } = require('../../errors'); const Collection = require('../../util/Collection'); const Util = require('../../util/Util'); const WebSocketShard = require('./WebSocketShard'); -const { Events, Status, WSEvents } = require('../../util/Constants'); +const { Events, ShardEvents, Status, WSCodes, WSEvents } = require('../../util/Constants'); const PacketHandlers = require('./handlers'); const BeforeReadyWhitelist = [ @@ -16,6 +17,8 @@ const BeforeReadyWhitelist = [ WSEvents.GUILD_MEMBER_REMOVE, ]; +const UNRECOVERABLE_CLOSE_CODES = [4004, 4010, 4011]; + /** * The WebSocket manager for this client. */ @@ -25,6 +28,7 @@ class WebSocketManager { * The client that instantiated this WebSocketManager * @type {Client} * @readonly + * @name WebSocketManager#client */ Object.defineProperty(this, 'client', { value: client }); @@ -34,6 +38,13 @@ class WebSocketManager { */ this.gateway = undefined; + /** + * The amount of shards this manager handles + * @private + * @type {number|string} + */ + this.totalShards = this.client.options.shardCount; + /** * A collection of all shards this manager handles * @type {Collection} @@ -41,18 +52,20 @@ class WebSocketManager { this.shards = new Collection(); /** - * An array of shards to be spawned or reconnected - * @type {Array} + * An array of shards to be connected or that need to reconnect + * @type {Set} * @private + * @name WebSocketManager#shardQueue */ - this.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 */ - this.packetQueue = []; + Object.defineProperty(this, 'packetQueue', { value: [] }); /** * The current status of this WebSocketManager @@ -61,28 +74,28 @@ class WebSocketManager { this.status = Status.IDLE; /** - * If this manager is expected to close + * If this manager was destroyed. It will prevent shards from reconnecting * @type {boolean} * @private */ - this.expectingClose = false; + this.destroyed = false; + + /** + * If this manager is currently reconnecting one or multiple shards + * @type {boolean} + * @private + */ + this.reconnecting = false; /** * The current session limit of the client - * @type {?Object} * @private + * @type {?Object} * @prop {number} total Total number of identifies available * @prop {number} remaining Number of identifies remaining * @prop {number} reset_after Number of milliseconds after which the limit resets */ - this.sessionStartLimit = null; - - /** - * If the manager is currently reconnecting shards - * @type {boolean} - * @private - */ - this.isReconnectingShards = false; + this.sessionStartLimit = undefined; } /** @@ -96,121 +109,198 @@ class WebSocketManager { } /** - * Emits a debug event. - * @param {string} message Debug message + * Emits a debug message. + * @param {string} message The debug message + * @param {?WebSocketShard} [shard] The shard that emitted this message, if any * @private */ - debug(message) { - this.client.emit(Events.DEBUG, message); + debug(message, shard) { + this.client.emit(Events.DEBUG, `[WS => ${shard ? `Shard ${shard.id}` : 'Manager'}] ${message}`); } /** - * Checks if a new identify payload can be sent. + * Connects this manager to the gateway. * @private - * @returns {Promise} */ - async _checkSessionLimit() { - this.sessionStartLimit = await this.client.api.gateway.bot.get().then(r => r.session_start_limit); - const { remaining, reset_after } = this.sessionStartLimit; - if (remaining !== 0) return true; - return reset_after; - } + async connect() { + const invalidToken = new DJSError(WSCodes[4004]); + const { + url: gatewayURL, + shards: recommendedShards, + session_start_limit: sessionStartLimit, + } = await this.client.api.gateway.bot.get().catch(error => { + throw error.httpStatus === 401 ? invalidToken : error; + }); - /** - * Handles the session identify rate limit for creating a shard. - * @private - */ - async _handleSessionLimit() { - const canSpawn = await this._checkSessionLimit(); - if (typeof canSpawn === 'number') { - this.debug(`Exceeded identify threshold, setting a timeout for ${canSpawn}ms`); - await Util.delayFor(canSpawn); + this.sessionStartLimit = sessionStartLimit; + + const { total, remaining, reset_after } = sessionStartLimit; + + this.debug(`Fetched Gateway Information + URL: ${gatewayURL} + Recommended Shards: ${recommendedShards}`); + + this.debug(`Session Limit Information + Total: ${total} + Remaining: ${remaining}`); + + this.gateway = `${gatewayURL}/`; + + if (this.totalShards === 'auto') { + this.debug(`Using the recommended shard count provided by Discord: ${recommendedShards}`); + this.totalShards = this.client.options.shardCount = this.client.options.totalShardCount = recommendedShards; + if (typeof this.client.options.shards === 'undefined' || !this.client.options.shards.length) { + this.client.options.shards = Array.from({ length: recommendedShards }, (_, i) => i); + } } - this.create(); - } - /** - * Creates a connection to a gateway. - * @param {string} [gateway=this.gateway] The gateway to connect to - * @private - */ - connect(gateway = this.gateway) { - this.gateway = gateway; - - if (typeof this.client.options.shards === 'number') { - this.debug(`Spawning shard with ID ${this.client.options.shards}`); - this.shardQueue.push(this.client.options.shards); - } else if (Array.isArray(this.client.options.shards)) { - this.debug(`Spawning ${this.client.options.shards.length} shards`); - this.shardQueue.push(...this.client.options.shards); + if (this.client.options.shards instanceof Array) { + const { shards } = this.client.options; + this.totalShards = shards.length; + this.debug(`Spawning shards: ${shards.join(', ')}`); + this.shardQueue = new Set(shards.map(id => new WebSocketShard(this, id))); } else { - this.debug(`Spawning ${this.client.options.shardCount} shards`); - this.shardQueue.push(...Array.from({ length: this.client.options.shardCount }, (_, index) => index)); + this.debug(`Spawning ${this.totalShards} shards`); + this.shardQueue = new Set(Array.from({ length: this.totalShards }, (_, id) => new WebSocketShard(this, id))); } - this.create(); + + await this._handleSessionLimit(remaining, reset_after); + + return this.createShards(); } /** - * Creates or reconnects a shard. + * Handles the creation of a shard. + * @returns {Promise} * @private */ - create() { - // Nothing to create - if (!this.shardQueue.length) return; + async createShards() { + // If we don't have any shards to handle, return + if (!this.shardQueue.size) return false; - let item = this.shardQueue.shift(); - if (typeof item === 'string' && !isNaN(item)) item = Number(item); + const [shard] = this.shardQueue; - if (item instanceof WebSocketShard) { - const timeout = setTimeout(() => { - this.debug(`[Shard ${item.id}] Failed to connect in 15s... Destroying and trying again`); - item.destroy(); - if (!this.shardQueue.includes(item)) this.shardQueue.push(item); - this.reconnect(true); - }, 15000); - item.once(Events.READY, this._shardReady.bind(this, timeout)); - item.once(Events.RESUMED, this._shardReady.bind(this, timeout)); - item.connect(); - return; + this.shardQueue.delete(shard); + + if (!shard.eventsAttached) { + shard.on(ShardEvents.READY, () => { + /** + * Emitted when a shard turns ready. + * @event Client#shardReady + * @param {number} id The shard ID that turned ready + */ + this.client.emit(Events.SHARD_READY, shard.id); + + if (!this.shardQueue.size) this.reconnecting = false; + }); + + shard.on(ShardEvents.RESUMED, () => { + /** + * Emitted when a shard resumes successfully. + * @event Client#shardResumed + * @param {number} id The shard ID that resumed + */ + this.client.emit(Events.SHARD_RESUMED, shard.id); + }); + + shard.on(ShardEvents.CLOSE, event => { + if (event.code === 1000 ? this.destroyed : UNRECOVERABLE_CLOSE_CODES.includes(event.code)) { + /** + * Emitted when a shard's WebSocket disconnects and will no longer reconnect. + * @event Client#shardDisconnected + * @param {CloseEvent} event The WebSocket close event + * @param {number} id The shard ID that disconnected + */ + this.client.emit(Events.SHARD_DISCONNECTED, event, shard.id); + this.debug(WSCodes[event.code], shard); + return; + } + + if (event.code >= 1000 && event.code <= 2000) { + // Any event code in this range cannot be resumed. + shard.sessionID = undefined; + } + + /** + * 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 + */ + this.client.emit(Events.SHARD_RECONNECTING, shard.id); + + if (shard.sessionID) { + this.debug(`Session ID is present, attempting an immediate reconnect...`, shard); + shard.connect().catch(() => null); + return; + } + + shard.destroy(); + + this.shardQueue.add(shard); + this.reconnect(); + }); + + shard.on(ShardEvents.INVALID_SESSION, () => { + this.client.emit(Events.SHARD_RECONNECTING, shard.id); + + this.shardQueue.add(shard); + this.reconnect(); + }); + + shard.on(ShardEvents.DESTROYED, () => { + this.debug('Shard was destroyed but no WebSocket connection existed... Reconnecting...', shard); + + this.client.emit(Events.SHARD_RECONNECTING, shard.id); + + this.shardQueue.add(shard); + this.reconnect(); + }); + + shard.eventsAttached = true; } - const shard = new WebSocketShard(this, item); - this.shards.set(item, shard); - shard.once(Events.READY, this._shardReady.bind(this)); + this.shards.set(shard.id, shard); + + try { + await shard.connect(); + } catch (error) { + if (error && error.code && UNRECOVERABLE_CLOSE_CODES.includes(error.code)) { + throw new DJSError(WSCodes[error.code]); + } else { + this.debug('Failed to connect to the gateway, requeueing...', shard); + this.shardQueue.add(shard); + } + } + // 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 Util.delayFor(5000); + await this._handleSessionLimit(); + return this.createShards(); + } + + return true; } /** - * Shared handler for shards turning ready or resuming. - * @param {Timeout} [timeout=null] Optional timeout to clear if shard didn't turn ready in time + * Handles reconnects for this manager. * @private + * @returns {Promise} */ - _shardReady(timeout = null) { - if (timeout) clearTimeout(timeout); - if (this.shardQueue.length) { - this.client.setTimeout(this._handleSessionLimit.bind(this), 5000); - } else { - this.isReconnectingShards = false; - } - } - - /** - * Handles the reconnect of a shard. - * @param {WebSocketShard|boolean} shard The shard to reconnect, or a boolean to indicate an immediate reconnect - * @private - */ - async reconnect(shard) { - // If the item is a shard, add it to the queue - if (shard instanceof WebSocketShard) this.shardQueue.push(shard); - if (typeof shard === 'boolean') { - // If a boolean is passed, force a reconnect right now - } else if (this.isReconnectingShards) { - // If we're already reconnecting shards, and no boolean was provided, return - return; - } - this.isReconnectingShards = true; + async reconnect() { + if (this.reconnecting || this.status !== Status.READY) return false; + this.reconnecting = true; try { await this._handleSessionLimit(); + 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 occured. Retrying in 5s...`); + await Util.delayFor(5000); + this.reconnecting = false; + return this.reconnect(); + } // If we get an error at this point, it means we cannot reconnect anymore if (this.client.listenerCount(Events.INVALIDATED)) { /** @@ -225,6 +315,52 @@ class WebSocketManager { } 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(); + } + + /** + * Handles the timeout required if we cannot identify anymore. + * @param {number} [remaining] The amount of remaining identify sessions that can be done today + * @param {number} [resetAfter] The amount of time in which the identify counter resets + * @private + */ + async _handleSessionLimit(remaining, resetAfter) { + if (typeof remaining === 'undefined' && typeof resetAfter === 'undefined') { + const { session_start_limit } = await this.client.api.gateway.bot.get(); + this.sessionStartLimit = session_start_limit; + remaining = session_start_limit.remaining; + resetAfter = session_start_limit.reset_after; + this.debug(`Session Limit Information + Total: ${session_start_limit.total} + Remaining: ${remaining}`); + } + if (!remaining) { + this.debug(`Exceeded identify threshold. Will attempt a connection in ${resetAfter}ms`); + await Util.delayFor(resetAfter); } } @@ -263,15 +399,13 @@ class WebSocketManager { * @private */ checkReady() { - if (this.shards.size !== this.client.options.shardCount || - this.shards.some(s => s.status !== Status.READY)) { + if (this.shards.size !== this.totalShards || this.shards.some(s => s.status !== Status.READY)) { return false; } - let unavailableGuilds = 0; - for (const guild of this.client.guilds.values()) { - if (!guild.available) unavailableGuilds++; - } + const unavailableGuilds = this.client.guilds.reduce((acc, guild) => guild.available ? acc : acc + 1, 0); + + // TODO: Rethink implementation for this if (unavailableGuilds === 0) { this.status = Status.NEARLY; if (!this.client.options.fetchAllMembers) return this.triggerReady(); @@ -280,16 +414,18 @@ class WebSocketManager { Promise.all(promises) .then(() => this.triggerReady()) .catch(e => { - this.debug(`Failed to fetch all members before ready! ${e}`); + this.debug(`Failed to fetch all members before ready! ${e}\n${e.stack}`); this.triggerReady(); }); + } else { + this.debug(`There are ${unavailableGuilds} unavailable guilds. Waiting for their GUILD_CREATE packets`); } + return true; } /** * Causes the client to be marked as ready and emits the ready event. - * @returns {void} * @private */ triggerReady() { @@ -303,31 +439,10 @@ class WebSocketManager { * Emitted when the client becomes ready to start working. * @event Client#ready */ - this.client.emit(Events.READY); + this.client.emit(Events.CLIENT_READY); this.handlePacket(); } - - /** - * Broadcasts a message to every shard in this WebSocketManager. - * @param {*} packet The packet to send - * @private - */ - broadcast(packet) { - for (const shard of this.shards.values()) shard.send(packet); - } - - /** - * Destroys all shards. - * @private - */ - destroy() { - if (this.expectingClose) return; - this.expectingClose = true; - this.isReconnectingShards = false; - this.shardQueue.length = 0; - for (const shard of this.shards.values()) shard.destroy(); - } } module.exports = WebSocketManager; diff --git a/src/client/websocket/WebSocketShard.js b/src/client/websocket/WebSocketShard.js index 96ab06a44..19cb11e4b 100644 --- a/src/client/websocket/WebSocketShard.js +++ b/src/client/websocket/WebSocketShard.js @@ -2,8 +2,7 @@ const EventEmitter = require('events'); const WebSocket = require('../../WebSocket'); -const { Status, Events, OPCodes, WSEvents, WSCodes } = require('../../util/Constants'); -const Util = require('../../util/Util'); +const { Status, Events, ShardEvents, OPCodes, WSEvents } = require('../../util/Constants'); let zlib; try { @@ -21,13 +20,13 @@ class WebSocketShard extends EventEmitter { super(); /** - * The WebSocket Manager of this connection + * The WebSocketManager of the shard * @type {WebSocketManager} */ this.manager = manager; /** - * The ID of the this shard + * The ID of the shard * @type {number} */ this.id = id; @@ -91,20 +90,22 @@ class WebSocketShard extends EventEmitter { * @type {Object} * @private */ - this.ratelimit = { - queue: [], - total: 120, - remaining: 120, - time: 60e3, - timer: null, - }; + Object.defineProperty(this, 'ratelimit', { + value: { + queue: [], + total: 120, + remaining: 120, + time: 60e3, + timer: null, + }, + }); /** * The WebSocket connection for the current shard * @type {?WebSocket} * @private */ - this.connection = null; + Object.defineProperty(this, 'connection', { value: null, writable: true }); /** * @external Inflate @@ -116,9 +117,21 @@ class WebSocketShard extends EventEmitter { * @type {?Inflate} * @private */ - this.inflate = null; + Object.defineProperty(this, 'inflate', { value: null, writable: true }); - if (this.manager.gateway) this.connect(); + /** + * The HELLO timeout + * @type {?NodeJS.Timer} + * @private + */ + Object.defineProperty(this, 'helloTimeout', { value: null, writable: true }); + + /** + * If the manager attached its event handlers on the shard + * @type {boolean} + * @private + */ + Object.defineProperty(this, 'eventsAttached', { value: false, writable: true }); } /** @@ -133,82 +146,86 @@ class WebSocketShard extends EventEmitter { /** * Emits a debug event. - * @param {string} message Debug message + * @param {string} message The debug message * @private */ debug(message) { - this.manager.debug(`[Shard ${this.id}] ${message}`); + this.manager.debug(message, this); } /** - * Sends a heartbeat to the WebSocket. - * If this shard didn't receive a heartbeat last time, it will destroy it and reconnect - * @private - */ - sendHeartbeat() { - if (!this.lastHeartbeatAcked) { - this.debug("Didn't receive a heartbeat ack last time, assuming zombie conenction. Destroying and reconnecting."); - this.connection.close(4000); - return; - } - this.debug('Sending a heartbeat'); - this.lastHeartbeatAcked = false; - this.lastPingTimestamp = Date.now(); - this.send({ op: OPCodes.HEARTBEAT, d: this.sequence }); - } - - /** - * Sets the heartbeat timer for this shard. - * @param {number} time If -1, clears the interval, any other number sets an interval - * @private - */ - setHeartbeatTimer(time) { - if (time === -1) { - if (this.heartbeatInterval) { - this.debug('Clearing heartbeat interval'); - this.manager.client.clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } - return; - } - this.debug(`Setting a heartbeat interval for ${time}ms`); - this.heartbeatInterval = this.manager.client.setInterval(() => this.sendHeartbeat(), time); - } - - /** - * Acknowledges a heartbeat. - * @private - */ - ackHeartbeat() { - this.lastHeartbeatAcked = true; - const latency = Date.now() - this.lastPingTimestamp; - this.debug(`Heartbeat acknowledged, latency of ${latency}ms`); - this.pings.unshift(latency); - if (this.pings.length > 3) this.pings.length = 3; - } - - /** - * Connects this shard to the gateway. + * Connects the shard to the gateway. * @private + * @returns {Promise} A promise that will resolve if the shard turns ready successfully, + * or reject if we couldn't connect */ connect() { - const { expectingClose, gateway } = this.manager; - if (expectingClose) return; - this.inflate = new zlib.Inflate({ - chunkSize: 65535, - flush: zlib.Z_SYNC_FLUSH, - to: WebSocket.encoding === 'json' ? 'string' : '', + const { gateway, client } = this.manager; + + if (this.status === Status.READY && this.connection && this.connection.readyState === WebSocket.OPEN) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const onReady = () => { + this.off(ShardEvents.CLOSE, onClose); + this.off(ShardEvents.RESUMED, onResumed); + this.off(ShardEvents.INVALID_SESSION, onInvalid); + resolve(); + }; + + const onResumed = () => { + this.off(ShardEvents.CLOSE, onClose); + this.off(ShardEvents.READY, onReady); + this.off(ShardEvents.INVALID_SESSION, onInvalid); + resolve(); + }; + + const onClose = event => { + this.off(ShardEvents.READY, onReady); + this.off(ShardEvents.RESUMED, onResumed); + this.off(ShardEvents.INVALID_SESSION, onInvalid); + reject(event); + }; + + const onInvalid = () => { + this.off(ShardEvents.READY, onReady); + this.off(ShardEvents.RESUMED, onResumed); + this.off(ShardEvents.CLOSE, onClose); + // eslint-disable-next-line prefer-promise-reject-errors + reject(); + }; + + this.once(ShardEvents.READY, onReady); + this.once(ShardEvents.RESUMED, onResumed); + this.once(ShardEvents.CLOSE, onClose); + this.once(ShardEvents.INVALID_SESSION, onInvalid); + + if (this.connection && this.connection.readyState === WebSocket.OPEN) { + this.identifyNew(); + return; + } + + this.inflate = new zlib.Inflate({ + chunkSize: 65535, + flush: zlib.Z_SYNC_FLUSH, + to: WebSocket.encoding === 'json' ? 'string' : '', + }); + + this.debug(`Trying to connect to ${gateway}, version ${client.options.ws.version}`); + + this.status = this.status === Status.DISCONNECTED ? Status.RECONNECTING : Status.CONNECTING; + this.setHelloTimeout(); + + const ws = this.connection = WebSocket.create(gateway, { + v: client.options.ws.version, + compress: 'zlib-stream', + }); + ws.onopen = this.onOpen.bind(this); + ws.onmessage = this.onMessage.bind(this); + ws.onerror = this.onError.bind(this); + ws.onclose = this.onClose.bind(this); }); - this.debug(`Connecting to ${gateway}`); - const ws = this.connection = WebSocket.create(gateway, { - v: this.manager.client.options.ws.version, - compress: 'zlib-stream', - }); - ws.onopen = this.onOpen.bind(this); - ws.onmessage = this.onMessage.bind(this); - ws.onerror = this.onError.bind(this); - ws.onclose = this.onClose.bind(this); - this.status = Status.CONNECTING; } /** @@ -216,7 +233,8 @@ class WebSocketShard extends EventEmitter { * @private */ onOpen() { - this.debug('Connected to the gateway'); + this.debug('Opened a connection to the gateway successfully.'); + this.status = Status.NEARLY; } /** @@ -240,53 +258,106 @@ class WebSocketShard extends EventEmitter { packet = WebSocket.unpack(this.inflate.result); this.manager.client.emit(Events.RAW, packet, this.id); } catch (err) { - this.manager.client.emit(Events.ERROR, err); + this.manager.client.emit(Events.SHARD_ERROR, err, this.id); return; } this.onPacket(packet); } + /** + * Called whenever an error occurs with the WebSocket. + * @param {ErrorEvent} error The error that occurred + * @private + */ + onError({ error }) { + if (error && error.message === 'uWs client connection error') { + this.debug('Received a uWs error. Closing the connection and reconnecting...'); + this.connection.close(4000); + return; + } + + /** + * Emitted whenever a shard's WebSocket encounters a connection error. + * @event Client#shardError + * @param {Error} error The encountered error + * @param {number} shardID The shard that encountered this error + */ + this.manager.client.emit(Events.SHARD_ERROR, error, this.id); + } + + /** + * @external CloseEvent + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent} + */ + + /** + * @external ErrorEvent + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent} + */ + + /** + * Called whenever a connection to the gateway is closed. + * @param {CloseEvent} event Close event that was received + * @private + */ + onClose(event) { + this.closeSequence = this.sequence; + this.sequence = -1; + this.debug(`WebSocket was closed. + Event Code: ${event.code} + Clean: ${event.wasClean} + Reason: ${event.reason || 'No reason received'}`); + + this.status = Status.DISCONNECTED; + + /** + * Emitted when a shard's WebSocket closes. + * @private + * @event WebSocketShard#close + * @param {CloseEvent} event The received event + */ + this.emit(ShardEvents.CLOSE, event); + } + /** * Called whenever a packet is received. - * @param {Object} packet Packet received + * @param {Object} packet The received packet * @private */ onPacket(packet) { if (!packet) { - this.debug('Received null or broken packet'); + this.debug(`Received broken packet: ${packet}.`); return; } switch (packet.t) { case WSEvents.READY: /** - * Emitted when a shard becomes ready. + * Emitted when the shard becomes ready * @event WebSocketShard#ready */ - this.emit(Events.READY); - /** - * Emitted when a shard becomes ready. - * @event Client#shardReady - * @param {number} shardID The ID of the shard - */ - this.manager.client.emit(Events.SHARD_READY, this.id); + this.emit(ShardEvents.READY); this.sessionID = packet.d.session_id; this.trace = packet.d._trace; this.status = Status.READY; - this.debug(`READY ${this.trace.join(' -> ')} | Session ${this.sessionID}`); + this.debug(`READY ${this.trace.join(' -> ')} | Session ${this.sessionID}.`); this.lastHeartbeatAcked = true; this.sendHeartbeat(); break; case WSEvents.RESUMED: { - this.emit(Events.RESUMED); + /** + * Emitted when the shard resumes successfully + * @event WebSocketShard#resumed + */ + this.emit(ShardEvents.RESUMED); + this.trace = packet.d._trace; this.status = Status.READY; const replayed = packet.s - this.closeSequence; - this.debug(`RESUMED ${this.trace.join(' -> ')} | Replayed ${replayed} events.`); + this.debug(`RESUMED ${this.trace.join(' -> ')} | Session ${this.sessionID} | Replayed ${replayed} events.`); this.lastHeartbeatAcked = true; this.sendHeartbeat(); - break; } } @@ -294,6 +365,7 @@ class WebSocketShard extends EventEmitter { switch (packet.op) { case OPCodes.HELLO: + this.setHelloTimeout(-1); this.setHeartbeatTimer(packet.d.heartbeat_interval); this.identify(); break; @@ -301,21 +373,20 @@ class WebSocketShard extends EventEmitter { this.connection.close(1001); break; case OPCodes.INVALID_SESSION: - this.debug(`Session was invalidated. Resumable: ${packet.d}.`); - // If the session isn't resumable - if (!packet.d) { - // Reset the sequence, since it isn't valid anymore - this.sequence = -1; - // If we had a session ID before - if (this.sessionID) { - this.sessionID = null; - this.connection.close(1000); - return; - } - this.connection.close(1000); + this.debug(`Session invalidated. Resumable: ${packet.d}.`); + // If we can resume the session, do so immediately + if (packet.d) { + this.identifyResume(); return; } - this.identifyResume(); + // Reset the sequence + this.sequence = -1; + // Reset the session ID as it's invalid + this.sessionID = null; + // Set the status to reconnecting + this.status = Status.RECONNECTING; + // Finally, emit the INVALID_SESSION event + this.emit(ShardEvents.INVALID_SESSION); break; case OPCodes.HEARTBEAT_ACK: this.ackHeartbeat(); @@ -329,10 +400,78 @@ class WebSocketShard extends EventEmitter { } /** - * Identifies the client on a connection. - * @returns {void} + * Sets the HELLO packet timeout. + * @param {number} [time] If set to -1, it will clear the hello timeout timeout * @private */ + setHelloTimeout(time) { + if (time === -1) { + if (this.helloTimeout) { + this.debug('Clearing the HELLO timeout.'); + this.manager.client.clearTimeout(this.helloTimeout); + this.helloTimeout = null; + } + return; + } + this.debug('Setting a HELLO timeout for 20s.'); + this.helloTimeout = this.manager.client.setTimeout(() => { + this.debug('Did not receive HELLO in time. Destroying and connecting again.'); + this.destroy(4009); + }, 20000); + } + + /** + * Sets the heartbeat timer for this shard. + * @param {number} time If -1, clears the interval, any other number sets an interval + * @private + */ + setHeartbeatTimer(time) { + if (time === -1) { + if (this.heartbeatInterval) { + this.debug('Clearing the heartbeat interval.'); + this.manager.client.clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + return; + } + this.debug(`Setting a heartbeat interval for ${time}ms.`); + this.heartbeatInterval = this.manager.client.setInterval(() => this.sendHeartbeat(), time); + } + + /** + * Sends a heartbeat to the WebSocket. + * If this shard didn't receive a heartbeat last time, it will destroy it and reconnect + * @private + */ + sendHeartbeat() { + if (!this.lastHeartbeatAcked) { + this.debug("Didn't receive a heartbeat ack last time, assuming zombie conenction. Destroying and reconnecting."); + this.destroy(4009); + return; + } + this.debug('Sending a heartbeat.'); + this.lastHeartbeatAcked = false; + this.lastPingTimestamp = Date.now(); + this.send({ op: OPCodes.HEARTBEAT, d: this.sequence }, true); + } + + /** + * Acknowledges a heartbeat. + * @private + */ + ackHeartbeat() { + this.lastHeartbeatAcked = true; + const latency = Date.now() - this.lastPingTimestamp; + this.debug(`Heartbeat acknowledged, latency of ${latency}ms.`); + this.pings.unshift(latency); + if (this.pings.length > 3) this.pings.length = 3; + } + + /** + * Identifies the client on the connection. + * @private + * @returns {void} + */ identify() { return this.sessionID ? this.identifyResume() : this.identifyNew(); } @@ -342,31 +481,32 @@ class WebSocketShard extends EventEmitter { * @private */ identifyNew() { - if (!this.manager.client.token) { - this.debug('No token available to identify a new session with'); + const { client } = this.manager; + if (!client.token) { + this.debug('No token available to identify a new session.'); return; } - // Clone the generic payload and assign the token + + // Clone the identify payload and assign the token and shard info const d = { - ...this.manager.client.options.ws, - token: this.manager.client.token, - shard: [this.id, Number(this.manager.client.options.totalShardCount)], + ...client.options.ws, + token: client.token, + shard: [this.id, Number(client.options.totalShardCount)], }; - // Send the payload - this.debug('Identifying as a new session'); - this.send({ op: OPCodes.IDENTIFY, d }); + this.debug(`Identifying as a new session. Shard ${this.id}/${client.options.totalShardCount}`); + this.send({ op: OPCodes.IDENTIFY, d }, true); } /** * Resumes a session on the gateway. - * @returns {void} * @private */ identifyResume() { if (!this.sessionID) { - this.debug('Warning: wanted to resume but session ID not available; identifying as a new session instead'); - return this.identifyNew(); + this.debug('Warning: attempted to resume but no session ID was present; identifying as a new session.'); + this.identifyNew(); + return; } this.debug(`Attempting to resume session ${this.sessionID} at sequence ${this.closeSequence}`); @@ -377,85 +517,19 @@ class WebSocketShard extends EventEmitter { seq: this.closeSequence, }; - return this.send({ op: OPCodes.RESUME, d }); + this.send({ op: OPCodes.RESUME, d }, true); } /** - * Called whenever an error occurs with the WebSocket. - * @param {Error} error The error that occurred - * @private + * Adds a packet to the queue to be sent to the gateway. + * If you use this method, make sure you understand that you need to provide + * a full [Payload](https://discordapp.com/developers/docs/topics/gateway#commands-and-events-gateway-commands). + * Do not use this method if you don't know what you're doing. + * @param {Object} data The full packet to send + * @param {?boolean} [important=false] If this packet should be added first in queue */ - onError(error) { - if (error && error.message === 'uWs client connection error') { - this.connection.close(4000); - return; - } - - /** - * Emitted whenever the client's WebSocket encounters a connection error. - * @event Client#error - * @param {Error} error The encountered error - * @param {number} shardID The shard that encountered this error - */ - this.manager.client.emit(Events.ERROR, error, this.id); - } - - /** - * @external CloseEvent - * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent} - */ - - /** - * Called whenever a connection to the gateway is closed. - * @param {CloseEvent} event Close event that was received - * @private - */ - onClose(event) { - this.closeSequence = this.sequence; - this.debug(`WebSocket was closed. - Event Code: ${event.code} - Reason: ${event.reason}`); - - if (event.code === 1000 ? this.manager.expectingClose : WSCodes[event.code]) { - /** - * Emitted when the client's WebSocket disconnects and will no longer attempt to reconnect. - * @event Client#disconnect - * @param {CloseEvent} event The WebSocket close event - * @param {number} shardID The shard that disconnected - */ - this.manager.client.emit(Events.DISCONNECT, event, this.id); - this.debug(WSCodes[event.code]); - return; - } - - this.destroy(); - - this.status = Status.RECONNECTING; - - /** - * Emitted whenever a shard tries to reconnect to the WebSocket. - * @event Client#reconnecting - * @param {number} shardID The shard ID that is reconnecting - */ - this.manager.client.emit(Events.RECONNECTING, this.id); - - this.debug(`${this.sessionID ? `Reconnecting in 3500ms` : 'Queueing a reconnect'} to the gateway...`); - - if (this.sessionID) { - Util.delayFor(3500).then(() => this.connect()); - } else { - this.manager.reconnect(this); - } - } - - /** - * Adds data to the queue to be sent. - * @param {Object} data Packet to send - * @private - * @returns {void} - */ - send(data) { - this.ratelimit.queue.push(data); + send(data, important = false) { + this.ratelimit.queue[important ? 'unshift' : 'push'](data); this.processQueue(); } @@ -472,7 +546,7 @@ class WebSocketShard extends EventEmitter { } this.connection.send(WebSocket.pack(data), err => { - if (err) this.manager.client.emit(Events.ERROR, err); + if (err) this.manager.client.emit(Events.SHARD_ERROR, err, this.id); }); } @@ -499,21 +573,36 @@ class WebSocketShard extends EventEmitter { } /** - * Destroys this shard and closes its connection. + * Destroys this shard and closes its WebSocket connection. + * @param {?number} [closeCode=1000] The close code to use * @private */ - destroy() { + destroy(closeCode = 1000) { this.setHeartbeatTimer(-1); - if (this.connection) this.connection.close(1000); + this.setHelloTimeout(-1); + // Close the WebSocket connection, if any + if (this.connection) { + this.connection.close(closeCode); + } else { + /** + * Emitted when a shard is destroyed, but no WebSocket connection was present. + * @private + * @event WebSocketShard#destroyed + */ + this.emit(ShardEvents.DESTROYED); + } this.connection = null; + // Set the shard status this.status = Status.DISCONNECTED; + // Reset the sequence + this.sequence = -1; + // Reset the ratelimit data this.ratelimit.remaining = this.ratelimit.total; this.ratelimit.queue.length = 0; if (this.ratelimit.timer) { this.manager.client.clearTimeout(this.ratelimit.timer); this.ratelimit.timer = null; } - this.sequence = -1; } } diff --git a/src/errors/Messages.js b/src/errors/Messages.js index 5945c167f..1cfe4da9f 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -4,12 +4,12 @@ const { register } = require('./DJSError'); const Messages = { CLIENT_INVALID_OPTION: (prop, must) => `The ${prop} option must be ${must}`, + CLIENT_INVALID_PROVIDED_SHARDS: 'None of the provided shards were valid.', TOKEN_INVALID: 'An invalid token was provided.', TOKEN_MISSING: 'Request to use token, but token was unavailable to the client.', WS_CLOSE_REQUESTED: 'WebSocket closed due to user request.', - WS_CONNECTION_TIMEOUT: 'The connection to the gateway timed out.', WS_CONNECTION_EXISTS: 'There is already an existing WebSocket connection.', WS_NOT_OPEN: (data = 'data') => `Websocket not open to send ${data}`, diff --git a/src/rest/DiscordAPIError.js b/src/rest/DiscordAPIError.js index 559194f2c..3ccd2b33f 100644 --- a/src/rest/DiscordAPIError.js +++ b/src/rest/DiscordAPIError.js @@ -5,7 +5,7 @@ * @extends Error */ class DiscordAPIError extends Error { - constructor(path, error, method) { + constructor(path, error, method, status) { super(); const flattened = this.constructor.flattenErrors(error.errors || error).join('\n'); this.name = 'DiscordAPIError'; @@ -28,6 +28,12 @@ class DiscordAPIError extends Error { * @type {number} */ this.code = error.code; + + /** + * The HTTP status code + * @type {number} + */ + this.httpStatus = status; } /** diff --git a/src/rest/RequestHandler.js b/src/rest/RequestHandler.js index 44fca4fbc..45065a662 100644 --- a/src/rest/RequestHandler.js +++ b/src/rest/RequestHandler.js @@ -167,7 +167,7 @@ class RequestHandler { try { const data = await parseResponse(res); if (res.status >= 400 && res.status < 500) { - return reject(new DiscordAPIError(request.path, data, request.method)); + return reject(new DiscordAPIError(request.path, data, request.method, res.status)); } return null; } catch (err) { diff --git a/src/util/Constants.js b/src/util/Constants.js index b6c1b0355..dfd370f71 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -212,8 +212,7 @@ exports.VoiceOPCodes = { exports.Events = { RATE_LIMIT: 'rateLimit', - READY: 'ready', - RESUMED: 'resumed', + CLIENT_READY: 'ready', GUILD_CREATE: 'guildCreate', GUILD_DELETE: 'guildDelete', GUILD_UPDATE: 'guildUpdate', @@ -246,8 +245,6 @@ exports.Events = { MESSAGE_REACTION_REMOVE: 'messageReactionRemove', MESSAGE_REACTION_REMOVE_ALL: 'messageReactionRemoveAll', USER_UPDATE: 'userUpdate', - USER_NOTE_UPDATE: 'userNoteUpdate', - USER_SETTINGS_UPDATE: 'clientUserSettingsUpdate', PRESENCE_UPDATE: 'presenceUpdate', VOICE_SERVER_UPDATE: 'voiceServerUpdate', VOICE_STATE_UPDATE: 'voiceStateUpdate', @@ -256,16 +253,26 @@ exports.Events = { TYPING_START: 'typingStart', TYPING_STOP: 'typingStop', WEBHOOKS_UPDATE: 'webhookUpdate', - DISCONNECT: 'disconnect', - RECONNECTING: 'reconnecting', ERROR: 'error', WARN: 'warn', DEBUG: 'debug', + SHARD_DISCONNECTED: 'shardDisconnected', + SHARD_ERROR: 'shardError', + SHARD_RECONNECTING: 'shardReconnecting', SHARD_READY: 'shardReady', + SHARD_RESUMED: 'shardResumed', INVALIDATED: 'invalidated', RAW: 'raw', }; +exports.ShardEvents = { + CLOSE: 'close', + DESTROYED: 'destroyed', + INVALID_SESSION: 'invalidSession', + READY: 'ready', + RESUMED: 'resumed', +}; + /** * The type of Structure allowed to be a partial: * * USER @@ -312,7 +319,6 @@ exports.PartialTypes = keyMirror([ * * MESSAGE_REACTION_REMOVE * * MESSAGE_REACTION_REMOVE_ALL * * USER_UPDATE - * * USER_NOTE_UPDATE * * USER_SETTINGS_UPDATE * * PRESENCE_UPDATE * * VOICE_STATE_UPDATE diff --git a/typings/index.d.ts b/typings/index.d.ts index f77ab51ee..3c3e118f3 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -2,6 +2,7 @@ declare module 'discord.js' { import { EventEmitter } from 'events'; import { Stream, Readable, Writable } from 'stream'; import { ChildProcess } from 'child_process'; + import * as WebSocket from 'ws'; export const version: string; @@ -181,15 +182,18 @@ declare module 'discord.js' { public on(event: 'presenceUpdate', listener: (oldPresence: Presence | undefined, newPresence: Presence) => void): this; public on(event: 'rateLimit', listener: (rateLimitData: RateLimitData) => void): this; public on(event: 'ready', listener: () => void): this; - public on(event: 'reconnecting', listener: (shardID: number) => void): this; - public on(event: 'resumed', listener: (replayed: number, shardID: number) => void): this; public on(event: 'roleCreate' | 'roleDelete', listener: (role: Role) => void): this; public on(event: 'roleUpdate', listener: (oldRole: Role, newRole: Role) => void): this; - public on(event: 'shardReady', listener: (shardID: number) => void): this; public on(event: 'typingStart' | 'typingStop', listener: (channel: Channel, user: User) => void): this; public on(event: 'userUpdate', listener: (oldUser: User, newUser: User) => void): this; public on(event: 'voiceStateUpdate', listener: (oldState: VoiceState | undefined, newState: VoiceState) => void): this; public on(event: 'webhookUpdate', listener: (channel: TextChannel) => void): this; + public on(event: 'invalidated', listener: () => void): this; + public on(event: 'shardDisconnected', listener: (event: CloseEvent, id: number) => void): this; + public on(event: 'shardError', listener: (error: Error, id: number) => void): this; + public on(event: 'shardReconnecting', listener: (id: number) => void): this; + public on(event: 'shardReady', listener: (id: number) => void): this; + public on(event: 'shardResumed', listener: (id: number) => void): this; public on(event: string, listener: Function): this; public once(event: 'channelCreate' | 'channelDelete', listener: (channel: Channel) => void): this; @@ -215,15 +219,18 @@ declare module 'discord.js' { public once(event: 'presenceUpdate', listener: (oldPresence: Presence | undefined, newPresence: Presence) => void): this; public once(event: 'rateLimit', listener: (rateLimitData: RateLimitData) => void): this; public once(event: 'ready', listener: () => void): this; - public once(event: 'reconnecting', listener: (shardID: number) => void): this; - public once(event: 'resumed', listener: (replayed: number, shardID: number) => void): this; public once(event: 'roleCreate' | 'roleDelete', listener: (role: Role) => void): this; public once(event: 'roleUpdate', listener: (oldRole: Role, newRole: Role) => void): this; - public once(event: 'shardReady', listener: (shardID: number) => void): this; public once(event: 'typingStart' | 'typingStop', listener: (channel: Channel, user: User) => void): this; public once(event: 'userUpdate', listener: (oldUser: User, newUser: User) => void): this; public once(event: 'voiceStateUpdate', listener: (oldState: VoiceState | undefined, newState: VoiceState) => void): this; public once(event: 'webhookUpdate', listener: (channel: TextChannel) => void): this; + public once(event: 'invalidated', listener: () => void): this; + public once(event: 'shardDisconnected', listener: (event: CloseEvent, id: number) => void): this; + public once(event: 'shardError', listener: (error: Error, id: number) => void): this; + public once(event: 'shardReconnecting', listener: (id: number) => void): this; + public once(event: 'shardReady', listener: (id: number) => void): this; + public once(event: 'shardResumed', listener: (id: number) => void): this; public once(event: string, listener: Function): this; } @@ -340,12 +347,13 @@ declare module 'discord.js' { } export class DiscordAPIError extends Error { - constructor(path: string, error: object, method: string); + constructor(path: string, error: object, method: string, httpStatus: number); private static flattenErrors(obj: object, key: string): string[]; public code: number; public method: string; public path: string; + public httpStatus: number; } export class DMChannel extends TextBasedChannel(Channel) { @@ -1270,27 +1278,80 @@ declare module 'discord.js' { export class WebSocketManager { constructor(client: Client); + private totalShards: number | string; + private shardQueue: Set; + private packetQueue: object[]; + private destroyed: boolean; + private reconnecting: boolean; + private sessionStartLimit?: { total: number; remaining: number; reset_after: number; }; + public readonly client: Client; - public gateway: string | undefined; - public readonly ping: number; + public gateway?: string; public shards: Collection; public status: Status; + public readonly ping: number; - public broadcast(packet: object): void; + private debug(message: string, shard?: WebSocketShard): void; + private connect(): Promise; + private createShards(): Promise; + private reconnect(): Promise; + private broadcast(packet: object): void; + private destroy(): void; + private _handleSessionLimit(remaining?: number, resetAfter?: number): Promise; + private handlePacket(packet?: object, shard?: WebSocketShard): Promise; + private checkReady(): boolean; + private triggerReady(): void; } export class WebSocketShard extends EventEmitter { constructor(manager: WebSocketManager, id: number); - public id: number; - public readonly ping: number; - public pings: number[]; - public status: Status; + private sequence: number; + private closeSequence: number; + private sessionID?: string; + private lastPingTimestamp: number; + private lastHeartbeatAcked: boolean; + private trace: string[]; + private ratelimit: { queue: object[]; total: number; remaining: number; time: 60e3; timer: NodeJS.Timeout | null; }; + private connection: WebSocket | null; + private helloTimeout: NodeJS.Timeout | null; + private eventsAttached: boolean; + public manager: WebSocketManager; + public id: number; + public status: Status; + public pings: [number, number, number]; + public readonly ping: number; - public send(packet: object): void; + private debug(message: string): void; + private connect(): Promise; + private onOpen(): void; + private onMessage(event: MessageEvent): void; + private onError(error: ErrorEvent): void; + private onClose(event: CloseEvent): void; + private onPacket(packet: object): void; + private setHelloTimeout(time?: number): void; + private setHeartbeatTimer(time: number): void; + private sendHeartbeat(): void; + private ackHeartbeat(): void; + private identify(): void; + private identifyNew(): void; + private identifyResume(): void; + private _send(data: object): void; + private processQueue(): void; + private destroy(closeCode: number): void; + public send(data: object): void; public on(event: 'ready', listener: () => void): this; + public on(event: 'resumed', listener: () => void): this; + public on(event: 'close', listener: (event: CloseEvent) => void): this; + public on(event: 'invalidSession', listener: () => void): this; + public on(event: string, listener: Function): this; + public once(event: 'ready', listener: () => void): this; + public once(event: 'resumed', listener: () => void): this; + public once(event: 'close', listener: (event: CloseEvent) => void): this; + public once(event: 'invalidSession', listener: () => void): this; + public once(event: string, listener: Function): this; } //#endregion @@ -1589,7 +1650,7 @@ declare module 'discord.js' { interface ClientOptions { shards?: number | number[]; - shardCount?: number; + shardCount?: number | 'auto'; totalShardCount?: number; messageCacheMaxSize?: number; messageCacheLifetime?: number; @@ -2149,5 +2210,9 @@ declare module 'discord.js' { | 'VOICE_SERVER_UPDATE' | 'WEBHOOKS_UPDATE'; + type MessageEvent = { data: WebSocket.Data; type: string; target: WebSocket; }; + type CloseEvent = { wasClean: boolean; code: number; reason: string; target: WebSocket; }; + type ErrorEvent = { error: any, message: string, type: string, target: WebSocket; }; + //#endregion }