ShardingManager.js (10975B)
1 'use strict'; 2 3 const EventEmitter = require('events'); 4 const fs = require('fs'); 5 const path = require('path'); 6 const Shard = require('./Shard'); 7 const { Error, TypeError, RangeError } = require('../errors'); 8 const Collection = require('../util/Collection'); 9 const Util = require('../util/Util'); 10 11 /** 12 * This is a utility class that makes multi-process sharding of a bot an easy and painless experience. 13 * It works by spawning a self-contained {@link ChildProcess} or {@link Worker} for each individual shard, each 14 * containing its own instance of your bot's {@link Client}. They all have a line of communication with the master 15 * process, and there are several useful methods that utilise it in order to simplify tasks that are normally difficult 16 * with sharding. It can spawn a specific number of shards or the amount that Discord suggests for the bot, and takes a 17 * path to your main bot script to launch for each one. 18 * @extends {EventEmitter} 19 */ 20 class ShardingManager extends EventEmitter { 21 /** 22 * The mode to spawn shards with for a {@link ShardingManager}: either "process" to use child processes, or 23 * "worker" to use workers. The "worker" mode relies on the experimental 24 * [Worker threads](https://nodejs.org/api/worker_threads.html) functionality that is present in Node v10.5.0 or 25 * newer. Node must be started with the `--experimental-worker` flag to expose it. 26 * @typedef {Object} ShardingManagerMode 27 */ 28 29 /** 30 * @param {string} file Path to your shard script file 31 * @param {Object} [options] Options for the sharding manager 32 * @param {string|number} [options.totalShards='auto'] Number of total shards of all shard managers or "auto" 33 * @param {string|number[]} [options.shardList='auto'] List of shards to spawn or "auto" 34 * @param {ShardingManagerMode} [options.mode='process'] Which mode to use for shards 35 * @param {boolean} [options.respawn=true] Whether shards should automatically respawn upon exiting 36 * @param {string[]} [options.shardArgs=[]] Arguments to pass to the shard script when spawning 37 * (only available when using the `process` mode) 38 * @param {string[]} [options.execArgv=[]] Arguments to pass to the shard script executable when spawning 39 * (only available when using the `process` mode) 40 * @param {string} [options.token] Token to use for automatic shard count and passing to shards 41 */ 42 constructor(file, options = {}) { 43 super(); 44 options = Util.mergeDefault( 45 { 46 totalShards: 'auto', 47 mode: 'process', 48 respawn: true, 49 shardArgs: [], 50 execArgv: [], 51 token: process.env.DISCORD_TOKEN, 52 }, 53 options, 54 ); 55 56 /** 57 * Path to the shard script file 58 * @type {string} 59 */ 60 this.file = file; 61 if (!file) throw new Error('CLIENT_INVALID_OPTION', 'File', 'specified.'); 62 if (!path.isAbsolute(file)) this.file = path.resolve(process.cwd(), file); 63 const stats = fs.statSync(this.file); 64 if (!stats.isFile()) throw new Error('CLIENT_INVALID_OPTION', 'File', 'a file'); 65 66 /** 67 * List of shards this sharding manager spawns 68 * @type {string|number[]} 69 */ 70 this.shardList = options.shardList || 'auto'; 71 if (this.shardList !== 'auto') { 72 if (!Array.isArray(this.shardList)) { 73 throw new TypeError('CLIENT_INVALID_OPTION', 'shardList', 'an array.'); 74 } 75 this.shardList = [...new Set(this.shardList)]; 76 if (this.shardList.length < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'shardList', 'at least 1 ID.'); 77 if ( 78 this.shardList.some( 79 shardID => typeof shardID !== 'number' || isNaN(shardID) || !Number.isInteger(shardID) || shardID < 0, 80 ) 81 ) { 82 throw new TypeError('CLIENT_INVALID_OPTION', 'shardList', 'an array of positive integers.'); 83 } 84 } 85 86 /** 87 * Amount of shards that all sharding managers spawn in total 88 * @type {number} 89 */ 90 this.totalShards = options.totalShards || 'auto'; 91 if (this.totalShards !== 'auto') { 92 if (typeof this.totalShards !== 'number' || isNaN(this.totalShards)) { 93 throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'a number.'); 94 } 95 if (this.totalShards < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'at least 1.'); 96 if (!Number.isInteger(this.totalShards)) { 97 throw new RangeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'an integer.'); 98 } 99 } 100 101 /** 102 * Mode for shards to spawn with 103 * @type {ShardingManagerMode} 104 */ 105 this.mode = options.mode; 106 if (this.mode !== 'process' && this.mode !== 'worker') { 107 throw new RangeError('CLIENT_INVALID_OPTION', 'Sharding mode', '"process" or "worker"'); 108 } 109 110 /** 111 * Whether shards should automatically respawn upon exiting 112 * @type {boolean} 113 */ 114 this.respawn = options.respawn; 115 116 /** 117 * An array of arguments to pass to shards (only when {@link ShardingManager#mode} is `process`) 118 * @type {string[]} 119 */ 120 this.shardArgs = options.shardArgs; 121 122 /** 123 * An array of arguments to pass to the executable (only when {@link ShardingManager#mode} is `process`) 124 * @type {string[]} 125 */ 126 this.execArgv = options.execArgv; 127 128 /** 129 * Token to use for obtaining the automatic shard count, and passing to shards 130 * @type {?string} 131 */ 132 this.token = options.token ? options.token.replace(/^Bot\s*/i, '') : null; 133 134 /** 135 * A collection of shards that this manager has spawned 136 * @type {Collection<number, Shard>} 137 */ 138 this.shards = new Collection(); 139 140 process.env.SHARDING_MANAGER = true; 141 process.env.SHARDING_MANAGER_MODE = this.mode; 142 process.env.DISCORD_TOKEN = this.token; 143 } 144 145 /** 146 * Creates a single shard. 147 * <warn>Using this method is usually not necessary if you use the spawn method.</warn> 148 * @param {number} [id=this.shards.size] ID of the shard to create 149 * <info>This is usually not necessary to manually specify.</info> 150 * @returns {Shard} Note that the created shard needs to be explicitly spawned using its spawn method. 151 */ 152 createShard(id = this.shards.size) { 153 const shard = new Shard(this, id); 154 this.shards.set(id, shard); 155 /** 156 * Emitted upon creating a shard. 157 * @event ShardingManager#shardCreate 158 * @param {Shard} shard Shard that was created 159 */ 160 this.emit('shardCreate', shard); 161 return shard; 162 } 163 164 /** 165 * Spawns multiple shards. 166 * @param {number|string} [amount=this.totalShards] Number of shards to spawn 167 * @param {number} [delay=5500] How long to wait in between spawning each shard (in milliseconds) 168 * @param {number} [spawnTimeout=30000] The amount in milliseconds to wait until the {@link Client} has become ready 169 * before resolving. (-1 or Infinity for no wait) 170 * @returns {Promise<Collection<number, Shard>>} 171 */ 172 async spawn(amount = this.totalShards, delay = 5500, spawnTimeout) { 173 // Obtain/verify the number of shards to spawn 174 if (amount === 'auto') { 175 amount = await Util.fetchRecommendedShards(this.token); 176 } else { 177 if (typeof amount !== 'number' || isNaN(amount)) { 178 throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'a number.'); 179 } 180 if (amount < 1) throw new RangeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'at least 1.'); 181 if (!Number.isInteger(amount)) { 182 throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'an integer.'); 183 } 184 } 185 186 // Make sure this many shards haven't already been spawned 187 if (this.shards.size >= amount) throw new Error('SHARDING_ALREADY_SPAWNED', this.shards.size); 188 if (this.shardList === 'auto' || this.totalShards === 'auto' || this.totalShards !== amount) { 189 this.shardList = [...Array(amount).keys()]; 190 } 191 if (this.totalShards === 'auto' || this.totalShards !== amount) { 192 this.totalShards = amount; 193 } 194 195 if (this.shardList.some(shardID => shardID >= amount)) { 196 throw new RangeError( 197 'CLIENT_INVALID_OPTION', 198 'Amount of shards', 199 'bigger than the highest shardID in the shardList option.', 200 ); 201 } 202 203 // Spawn the shards 204 for (const shardID of this.shardList) { 205 const promises = []; 206 const shard = this.createShard(shardID); 207 promises.push(shard.spawn(spawnTimeout)); 208 if (delay > 0 && this.shards.size !== this.shardList.length) promises.push(Util.delayFor(delay)); 209 await Promise.all(promises); // eslint-disable-line no-await-in-loop 210 } 211 212 return this.shards; 213 } 214 215 /** 216 * Sends a message to all shards. 217 * @param {*} message Message to be sent to the shards 218 * @returns {Promise<Shard[]>} 219 */ 220 broadcast(message) { 221 const promises = []; 222 for (const shard of this.shards.values()) promises.push(shard.send(message)); 223 return Promise.all(promises); 224 } 225 226 /** 227 * Evaluates a script on all shards, in the context of the {@link Client}s. 228 * @param {string} script JavaScript to run on each shard 229 * @returns {Promise<Array<*>>} Results of the script execution 230 */ 231 broadcastEval(script) { 232 const promises = []; 233 for (const shard of this.shards.values()) promises.push(shard.eval(script)); 234 return Promise.all(promises); 235 } 236 237 /** 238 * Fetches a client property value of each shard. 239 * @param {string} prop Name of the client property to get, using periods for nesting 240 * @returns {Promise<Array<*>>} 241 * @example 242 * manager.fetchClientValues('guilds.cache.size') 243 * .then(results => console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`)) 244 * .catch(console.error); 245 */ 246 fetchClientValues(prop) { 247 if (this.shards.size === 0) return Promise.reject(new Error('SHARDING_NO_SHARDS')); 248 if (this.shards.size !== this.shardList.length) return Promise.reject(new Error('SHARDING_IN_PROCESS')); 249 const promises = []; 250 for (const shard of this.shards.values()) promises.push(shard.fetchClientValue(prop)); 251 return Promise.all(promises); 252 } 253 254 /** 255 * Kills all running shards and respawns them. 256 * @param {number} [shardDelay=5000] How long to wait between shards (in milliseconds) 257 * @param {number} [respawnDelay=500] How long to wait between killing a shard's process and restarting it 258 * (in milliseconds) 259 * @param {number} [spawnTimeout=30000] The amount in milliseconds to wait for a shard to become ready before 260 * continuing to another. (-1 or Infinity for no wait) 261 * @returns {Promise<Collection<string, Shard>>} 262 */ 263 async respawnAll(shardDelay = 5000, respawnDelay = 500, spawnTimeout) { 264 let s = 0; 265 for (const shard of this.shards.values()) { 266 const promises = [shard.respawn(respawnDelay, spawnTimeout)]; 267 if (++s < this.shards.size && shardDelay > 0) promises.push(Util.delayFor(shardDelay)); 268 await Promise.all(promises); // eslint-disable-line no-await-in-loop 269 } 270 return this.shards; 271 } 272 } 273 274 module.exports = ShardingManager;