buddy

node MVC discord bot
Log | Files | Refs | README

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;