Proper shutdown sequence, upgrade logger
This commit is contained in:
parent
9d88266f41
commit
e2d8e31588
@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^1.5.1",
|
||||
"@discordjs/rest": "^1.7.1",
|
||||
"@navy.gif/logger": "^2.5.2",
|
||||
"@navy.gif/logger": "^2.5.3",
|
||||
"@navy.gif/timestring": "^6.0.6",
|
||||
"@types/node": "^18.15.11",
|
||||
"chalk": "^5.3.0",
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -65,7 +65,7 @@ class EventHooker
|
||||
this.#logger.debug(`Setting up handler for ${eventName}`);
|
||||
this.#target.on(eventName, async (...args) =>
|
||||
{
|
||||
if (!this.#target.ready && !this.#safeEvents.includes(eventName))
|
||||
if (!this.#target.ready && !this.#safeEvents.includes(eventName))
|
||||
{
|
||||
this.#logger.warn(`Client not ready to handle events, event: ${eventName}`);
|
||||
return;
|
||||
|
@ -12,12 +12,12 @@ class Intercom
|
||||
{
|
||||
this.#client.eventHooker.hook('built', () =>
|
||||
{
|
||||
this._transportCommands();
|
||||
this.#transportCommands();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
send (type: string, message = {})
|
||||
send (type: string, message = {})
|
||||
{
|
||||
if (typeof message !== 'object')
|
||||
throw new Error('Invalid message object');
|
||||
@ -29,7 +29,7 @@ class Intercom
|
||||
});
|
||||
}
|
||||
|
||||
_transportCommands ()
|
||||
#transportCommands ()
|
||||
{
|
||||
if (!this.#client.application)
|
||||
throw new Error('Missing client application');
|
||||
|
@ -358,7 +358,7 @@ class MuteSetting extends Setting
|
||||
}
|
||||
catch (err)
|
||||
{
|
||||
this.client.logger.error(err);
|
||||
this.client.logger.error(err as Error);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -71,7 +71,7 @@ class GuildWrapper
|
||||
throw new Error('Already a wrapper');
|
||||
|
||||
this.#client = client;
|
||||
this.#logger = client.createLogger({ name: `Guild: ${guild.id}` });
|
||||
this.#logger = client.createLogger(this, { name: `Guild: ${guild.id}` });
|
||||
this.#guild = guild;
|
||||
this.#webhooks = new Collection();
|
||||
this.#memberWrappers = new Collection();
|
||||
|
@ -50,11 +50,11 @@ class StorageManager
|
||||
return this;
|
||||
}
|
||||
|
||||
async destroy ()
|
||||
async close ()
|
||||
{
|
||||
const keys = Object.keys(this.#providers);
|
||||
for (const provider of keys)
|
||||
await this.#providers[provider].destroy();
|
||||
await this.#providers[provider].close();
|
||||
}
|
||||
|
||||
_getName (instance: Provider | Table)
|
||||
|
@ -115,7 +115,7 @@ abstract class Provider implements Initialisable
|
||||
|
||||
}
|
||||
|
||||
abstract destroy(): Promise<void>;
|
||||
abstract close(): Promise<void>;
|
||||
|
||||
get name ()
|
||||
{
|
||||
|
@ -126,7 +126,7 @@ class MariaDBProvider extends Provider
|
||||
|
||||
}
|
||||
|
||||
async destroy ()
|
||||
async close ()
|
||||
{
|
||||
this.logger.status('Shutting down database connections');
|
||||
if (!this.ready)
|
||||
|
@ -61,12 +61,13 @@ class MongoDBProvider extends Provider
|
||||
this.logger.info('DB connected');
|
||||
}
|
||||
|
||||
async destroy ()
|
||||
async close ()
|
||||
{
|
||||
if (!this.initialised)
|
||||
return this.logger.warn('Database already closed');
|
||||
this.logger.status('Closing DB connection');
|
||||
await this.#client?.close();
|
||||
this.#client?.removeAllListeners();
|
||||
this._initialised = false;
|
||||
this.#db = null;
|
||||
this.logger.status('Database closed');
|
||||
|
@ -1,375 +1,391 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { inspect } from 'node:util';
|
||||
import path from 'node:path';
|
||||
|
||||
import { CommandsDef, IPCMessage } from '../../@types/Shared.js';
|
||||
import { BroadcastEvalOptions, ShardMethod, ShardingOptions } from '../../@types/Shard.js';
|
||||
import { ControllerOptions } from '../../@types/Controller.js';
|
||||
|
||||
import { MasterLogger } from '@navy.gif/logger';
|
||||
import { Collection } from 'discord.js';
|
||||
|
||||
// Available for evals
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import ClientUtils from './ClientUtils.js';
|
||||
import Metrics from './Metrics.js';
|
||||
// import ApiClientUtil from './ApiClientUtil.js';
|
||||
import SlashCommandManager from './rest/SlashCommandManager.js';
|
||||
import { Shard } from './shard/index.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import Util from '../utilities/Util.js';
|
||||
|
||||
// Placeholder
|
||||
type GalacticAPI = {
|
||||
init: () => Promise<void>
|
||||
}
|
||||
|
||||
class Controller extends EventEmitter
|
||||
{
|
||||
// #shardingManager: ShardingManager;
|
||||
#slashCommandManager: SlashCommandManager;
|
||||
#logger: MasterLogger;
|
||||
#metrics: Metrics;
|
||||
#options: ControllerOptions;
|
||||
#shardingOptions: ShardingOptions;
|
||||
// #apiClientUtil: ApiClientUtil;
|
||||
|
||||
#shards: Collection<number, Shard>;
|
||||
|
||||
#version: string;
|
||||
#readyAt: number | null;
|
||||
#built: boolean;
|
||||
|
||||
#api?: GalacticAPI;
|
||||
|
||||
constructor (options: ControllerOptions, version: string)
|
||||
{
|
||||
super();
|
||||
|
||||
// Sharding
|
||||
const respawn = process.env.NODE_ENV !== 'development';
|
||||
const clientPath = path.join(options.rootDir, 'client/DiscordClient.js');
|
||||
if (!existsSync(clientPath))
|
||||
throw new Error(`Client path does not seem to exist: ${clientPath}`);
|
||||
|
||||
this.#options = options;
|
||||
const { shardList, totalShards } = Controller.parseShardOptions(options.shardOptions);
|
||||
|
||||
options.discord.rootDir = options.rootDir;
|
||||
options.discord.logger = options.logger;
|
||||
options.discord.storage = options.storage;
|
||||
options.discord.version = version;
|
||||
this.#shardingOptions = {
|
||||
path: clientPath,
|
||||
totalShards,
|
||||
shardList,
|
||||
respawn,
|
||||
shardArgs: [],
|
||||
execArgv: [],
|
||||
token: process.env.DISCORD_TOKEN,
|
||||
clientOptions: options.discord,
|
||||
};
|
||||
|
||||
// Other
|
||||
this.#slashCommandManager = new SlashCommandManager(this);
|
||||
|
||||
this.#logger = new MasterLogger(options.logger);
|
||||
this.#metrics = new Metrics(this);
|
||||
// this.#apiClientUtil = new ApiClientUtil(this);
|
||||
|
||||
this.#version = version;
|
||||
this.#readyAt = null;
|
||||
this.#built = false;
|
||||
|
||||
this.#shards = new Collection();
|
||||
// this.#shardingManager.on('message', this._handleMessage.bind(this));
|
||||
}
|
||||
|
||||
get version ()
|
||||
{
|
||||
return this.#version;
|
||||
}
|
||||
|
||||
get ready ()
|
||||
{
|
||||
return this.#built;
|
||||
}
|
||||
|
||||
get readyAt ()
|
||||
{
|
||||
return this.#readyAt || -1;
|
||||
}
|
||||
|
||||
get totalShards ()
|
||||
{
|
||||
return this.#shardingOptions.totalShards as number;
|
||||
}
|
||||
|
||||
get developerGuilds ()
|
||||
{
|
||||
return this.#options.discord.slashCommands?.developerGuilds;
|
||||
}
|
||||
|
||||
get logger ()
|
||||
{
|
||||
return this.#logger;
|
||||
}
|
||||
|
||||
get api ()
|
||||
{
|
||||
return this.#api;
|
||||
}
|
||||
|
||||
get shards ()
|
||||
{
|
||||
return this.#shards.clone();
|
||||
}
|
||||
|
||||
async build ()
|
||||
{
|
||||
const start = Date.now();
|
||||
// const API = this._options.api.load ? await import('/Documents/My programs/GBot/api/index.js')
|
||||
// .catch(() => this.logger.warn(`Error importing API files, continuing without`)) : null;
|
||||
|
||||
// let API = null;
|
||||
// if (this.#options.api.load)
|
||||
// API = await import('../../api/index.js').catch(() => this.#logger.warn(`Error importing API files, continuing without`));
|
||||
// if (API) {
|
||||
// // TODO: this needs to be fixed up
|
||||
// this.#logger.info('Booting up API');
|
||||
// const { default: APIManager } = API;
|
||||
// this.#api = new APIManager(this, this.#options.api) as GalacticAPI;
|
||||
// await this.#api.init();
|
||||
// const now = Date.now();
|
||||
// this.#logger.info(`API ready. Took ${now - start} ms`);
|
||||
// start = now;
|
||||
// }
|
||||
|
||||
this.#logger.status('Starting bot shards');
|
||||
// await this.shardingManager.spawn().catch((error) => {
|
||||
// this.#logger.error(`Fatal error during shard spawning:\n${error.stack || inspect(error)}`);
|
||||
// // eslint-disable-next-line no-process-exit
|
||||
// process.exit(); // Prevent a boot loop when shards die due to an error in the client
|
||||
// });
|
||||
|
||||
const { totalShards, token } = this.#shardingOptions;
|
||||
let shardCount = 0;
|
||||
if (totalShards === 'auto')
|
||||
{
|
||||
if (!token)
|
||||
throw new Error('Missing token');
|
||||
shardCount = await Util.fetchRecommendedShards(token);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (typeof shardCount !== 'number' || isNaN(shardCount))
|
||||
throw new TypeError('Amount of shards must be a number.');
|
||||
if (shardCount < 1)
|
||||
throw new RangeError('Amount of shards must be at least one.');
|
||||
if (!Number.isInteger(shardCount))
|
||||
throw new TypeError('Amount of shards must be an integer.');
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < shardCount; i++)
|
||||
{
|
||||
const shard = this.createShard(shardCount);
|
||||
promises.push(shard.spawn());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`);
|
||||
|
||||
this.#built = true;
|
||||
this.#readyAt = Date.now();
|
||||
}
|
||||
|
||||
createShard (totalShards: number)
|
||||
{
|
||||
const ids = this.#shards.map(s => s.id);
|
||||
const id = ids.length ? Math.max(...ids) + 1 : 0;
|
||||
|
||||
const { path: file, token, respawn, execArgv, shardArgs: args, clientOptions: discordOptions } = this.#shardingOptions;
|
||||
if (!file)
|
||||
throw new Error('File seems to be missing');
|
||||
if (!discordOptions)
|
||||
throw new Error('Missing discord options');
|
||||
const shard = new Shard(this, id, {
|
||||
file,
|
||||
token,
|
||||
respawn,
|
||||
args,
|
||||
execArgv,
|
||||
totalShards,
|
||||
clientOptions: discordOptions
|
||||
});
|
||||
this.#shards.set(shard.id, shard);
|
||||
this.#logger.attach(shard);
|
||||
this.#setListeners(shard);
|
||||
return shard;
|
||||
}
|
||||
|
||||
#setListeners (shard: Shard)
|
||||
{
|
||||
shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`));
|
||||
shard.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`));
|
||||
shard.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`));
|
||||
shard.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`));
|
||||
shard.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`));
|
||||
shard.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`));
|
||||
shard.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`));
|
||||
shard.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`));
|
||||
shard.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true }));
|
||||
|
||||
shard.on('message', (msg) => this.#handleMessage(shard, msg));
|
||||
}
|
||||
|
||||
#handleMessage (shard: Shard, message: IPCMessage)
|
||||
{
|
||||
if (message._logger)
|
||||
return;
|
||||
// this.logger.debug(`New message from ${shard ? `${message._api ? 'api-' : ''}shard ${shard.id}`: 'manager'}: ${inspect(message)}`);
|
||||
|
||||
if (message._mEval)
|
||||
return this.eval(shard, { script: message._mEval, debug: message.debug || false });
|
||||
if (message._commands)
|
||||
return this.#slashCommandManager._handleMessage(message as CommandsDef);
|
||||
if (message._api)
|
||||
return this.apiRequest(shard, message);
|
||||
}
|
||||
|
||||
apiRequest (shard: Shard, message: IPCMessage)
|
||||
{
|
||||
const { type } = message;
|
||||
switch (type)
|
||||
{
|
||||
case 'stats':
|
||||
this.#metrics.aggregateStatistics(shard, message);
|
||||
break;
|
||||
default:
|
||||
// this.#apiClientUtil.handleMessage(shard, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} shard The shard from which the eval came and to which it will be returned
|
||||
* @param {*} script The script to be executed
|
||||
* @memberof Manager
|
||||
* @private
|
||||
*/
|
||||
async eval (shard: Shard, { script, debug }: {script: string, debug: boolean})
|
||||
{
|
||||
this.#logger.info(`Incoming manager eval from shard ${shard.id}:\n${script}`);
|
||||
let result = null,
|
||||
error = null;
|
||||
|
||||
const response: IPCMessage = {
|
||||
script, _mEvalResult: true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// eslint-disable-next-line no-eval
|
||||
result = await eval(script);
|
||||
response._result = result;
|
||||
// if(typeof result !== 'string') result = inspect(result);
|
||||
if (debug)
|
||||
this.#logger.debug(`Eval result: ${inspect(result)}`);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
const err = e as Error;
|
||||
error = Util.makePlainError(err);
|
||||
response._error = error;
|
||||
}
|
||||
return shard.send(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
broadcastEval (script: string | Function, options: BroadcastEvalOptions = {})
|
||||
{
|
||||
if (typeof script !== 'function')
|
||||
return Promise.reject(new TypeError('[shardmanager] Provided eval must be a function.'));
|
||||
return this._performOnShards('eval', [ `(${script})(this, ${JSON.stringify(options.context)})` ], options.shard);
|
||||
}
|
||||
|
||||
fetchClientValues (prop: string, shard?: number)
|
||||
{
|
||||
return this._performOnShards('fetchClientValue', [ prop ], shard);
|
||||
}
|
||||
|
||||
_performOnShards (method: ShardMethod, args: [string, object?], shard?: number): Promise<unknown>
|
||||
{
|
||||
if (this.#shards.size === 0)
|
||||
return Promise.reject(new Error('No shards available.'));
|
||||
|
||||
if (!this.ready)
|
||||
return Promise.reject(new Error('Controller not ready'));
|
||||
|
||||
if (typeof shard === 'number')
|
||||
{
|
||||
if (!this.#shards.has(shard))
|
||||
Promise.reject(new Error('Shard not found.'));
|
||||
|
||||
const s = this.#shards.get(shard) as Shard;
|
||||
if (method === 'eval')
|
||||
return s.eval(...args);
|
||||
else if (method === 'fetchClientValue')
|
||||
return s.eval(args[0]);
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const sh of this.#shards.values())
|
||||
{
|
||||
if (method === 'eval')
|
||||
promises.push(sh.eval(...args));
|
||||
else if (method === 'fetchClientValue')
|
||||
promises.push(sh.eval(args[0]));
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
async respawnAll ({ shardDelay = 5000, respawnDelay = 500, timeout = 30000 } = {})
|
||||
{
|
||||
let s = 0;
|
||||
for (const shard of this.#shards.values())
|
||||
{
|
||||
const promises: Promise<unknown>[] = [ shard.respawn({ delay: respawnDelay, timeout }) ];
|
||||
if (++s < this.#shards.size && shardDelay > 0)
|
||||
promises.push(Util.delayFor(shardDelay));
|
||||
await Promise.all(promises); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
return this.#shards;
|
||||
}
|
||||
|
||||
static parseShardOptions (options: ShardingOptions)
|
||||
{
|
||||
let shardList = options.shardList ?? 'auto';
|
||||
if (shardList !== 'auto')
|
||||
{
|
||||
if (!Array.isArray(shardList))
|
||||
throw new Error('ShardList must be an array.');
|
||||
shardList = [ ...new Set(shardList) ];
|
||||
if (shardList.length < 1)
|
||||
throw new Error('ShardList must have at least one ID.');
|
||||
if (shardList.some((shardId) => typeof shardId !== 'number' || isNaN(shardId) || !Number.isInteger(shardId) || shardId < 0))
|
||||
throw new Error('ShardList must be an array of positive integers.');
|
||||
}
|
||||
|
||||
const totalShards = options.totalShards || 'auto';
|
||||
if (totalShards !== 'auto')
|
||||
{
|
||||
if (typeof totalShards !== 'number' || isNaN(totalShards))
|
||||
throw new Error('TotalShards must be an integer.');
|
||||
if (totalShards < 1)
|
||||
throw new Error('TotalShards must be at least one.');
|
||||
if (!Number.isInteger(totalShards))
|
||||
throw new Error('TotalShards must be an integer.');
|
||||
}
|
||||
return { shardList, totalShards };
|
||||
}
|
||||
}
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { inspect } from 'node:util';
|
||||
import path from 'node:path';
|
||||
|
||||
import { CommandsDef, IPCMessage } from '../../@types/Shared.js';
|
||||
import { BroadcastEvalOptions, ShardMethod, ShardingOptions } from '../../@types/Shard.js';
|
||||
import { ControllerOptions } from '../../@types/Controller.js';
|
||||
|
||||
import { MasterLogger } from '@navy.gif/logger';
|
||||
import { Collection } from 'discord.js';
|
||||
|
||||
// Available for evals
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import ClientUtils from './ClientUtils.js';
|
||||
import Metrics from './Metrics.js';
|
||||
// import ApiClientUtil from './ApiClientUtil.js';
|
||||
import SlashCommandManager from './rest/SlashCommandManager.js';
|
||||
import { Shard } from './shard/index.js';
|
||||
import { existsSync } from 'node:fs';
|
||||
import Util from '../utilities/Util.js';
|
||||
|
||||
// Placeholder
|
||||
type GalacticAPI = {
|
||||
init: () => Promise<void>
|
||||
}
|
||||
|
||||
class Controller extends EventEmitter
|
||||
{
|
||||
// #shardingManager: ShardingManager;
|
||||
#slashCommandManager: SlashCommandManager;
|
||||
#logger: MasterLogger;
|
||||
#metrics: Metrics;
|
||||
#options: ControllerOptions;
|
||||
#shardingOptions: ShardingOptions;
|
||||
// #apiClientUtil: ApiClientUtil;
|
||||
|
||||
#shards: Collection<number, Shard>;
|
||||
|
||||
#version: string;
|
||||
#readyAt: number | null;
|
||||
#built: boolean;
|
||||
|
||||
#api?: GalacticAPI;
|
||||
|
||||
constructor (options: ControllerOptions, version: string)
|
||||
{
|
||||
super();
|
||||
|
||||
// Sharding
|
||||
const respawn = process.env.NODE_ENV !== 'development';
|
||||
const clientPath = path.join(options.rootDir, 'client/DiscordClient.js');
|
||||
if (!existsSync(clientPath))
|
||||
throw new Error(`Client path does not seem to exist: ${clientPath}`);
|
||||
|
||||
this.#options = options;
|
||||
const { shardList, totalShards } = Controller.parseShardOptions(options.shardOptions);
|
||||
|
||||
options.discord.rootDir = options.rootDir;
|
||||
options.discord.logger = options.logger;
|
||||
options.discord.storage = options.storage;
|
||||
options.discord.version = version;
|
||||
this.#shardingOptions = {
|
||||
path: clientPath,
|
||||
totalShards,
|
||||
shardList,
|
||||
respawn,
|
||||
shardArgs: [],
|
||||
execArgv: [],
|
||||
token: process.env.DISCORD_TOKEN,
|
||||
clientOptions: options.discord,
|
||||
};
|
||||
|
||||
// Other
|
||||
this.#slashCommandManager = new SlashCommandManager(this);
|
||||
|
||||
this.#logger = new MasterLogger(options.logger);
|
||||
this.#metrics = new Metrics(this);
|
||||
// this.#apiClientUtil = new ApiClientUtil(this);
|
||||
|
||||
this.#version = version;
|
||||
this.#readyAt = null;
|
||||
this.#built = false;
|
||||
|
||||
this.#shards = new Collection();
|
||||
// this.#shardingManager.on('message', this._handleMessage.bind(this));
|
||||
|
||||
process.on('SIGINT', this.shutdown.bind(this));
|
||||
}
|
||||
|
||||
async build ()
|
||||
{
|
||||
const start = Date.now();
|
||||
// const API = this._options.api.load ? await import('/Documents/My programs/GBot/api/index.js')
|
||||
// .catch(() => this.logger.warn(`Error importing API files, continuing without`)) : null;
|
||||
|
||||
// let API = null;
|
||||
// if (this.#options.api.load)
|
||||
// API = await import('../../api/index.js').catch(() => this.#logger.warn(`Error importing API files, continuing without`));
|
||||
// if (API) {
|
||||
// // TODO: this needs to be fixed up
|
||||
// this.#logger.info('Booting up API');
|
||||
// const { default: APIManager } = API;
|
||||
// this.#api = new APIManager(this, this.#options.api) as GalacticAPI;
|
||||
// await this.#api.init();
|
||||
// const now = Date.now();
|
||||
// this.#logger.info(`API ready. Took ${now - start} ms`);
|
||||
// start = now;
|
||||
// }
|
||||
|
||||
this.#logger.status('Starting bot shards');
|
||||
// await this.shardingManager.spawn().catch((error) => {
|
||||
// this.#logger.error(`Fatal error during shard spawning:\n${error.stack || inspect(error)}`);
|
||||
// // eslint-disable-next-line no-process-exit
|
||||
// process.exit(); // Prevent a boot loop when shards die due to an error in the client
|
||||
// });
|
||||
|
||||
const { totalShards, token } = this.#shardingOptions;
|
||||
let shardCount = 0;
|
||||
if (totalShards === 'auto')
|
||||
{
|
||||
if (!token)
|
||||
throw new Error('Missing token');
|
||||
shardCount = await Util.fetchRecommendedShards(token);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (typeof shardCount !== 'number' || isNaN(shardCount))
|
||||
throw new TypeError('Amount of shards must be a number.');
|
||||
if (shardCount < 1)
|
||||
throw new RangeError('Amount of shards must be at least one.');
|
||||
if (!Number.isInteger(shardCount))
|
||||
throw new TypeError('Amount of shards must be an integer.');
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (let i = 0; i < shardCount; i++)
|
||||
{
|
||||
const shard = this.createShard(shardCount);
|
||||
promises.push(shard.spawn());
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`);
|
||||
|
||||
this.#built = true;
|
||||
this.#readyAt = Date.now();
|
||||
}
|
||||
|
||||
async shutdown ()
|
||||
{
|
||||
this.logger.info('Received SIGINT, shutting down');
|
||||
setTimeout(process.exit, 90_000);
|
||||
const promises = this.shards
|
||||
.filter(shard => shard.ready)
|
||||
.map(shard => shard.awaitShutdown()
|
||||
.then(() => shard.removeAllListeners()));
|
||||
if (promises.length)
|
||||
await Promise.all(promises);
|
||||
this.logger.status('Shutdown complete, goodbye');
|
||||
this.logger.close();
|
||||
}
|
||||
|
||||
createShard (totalShards: number)
|
||||
{
|
||||
const ids = this.#shards.map(s => s.id);
|
||||
const id = ids.length ? Math.max(...ids) + 1 : 0;
|
||||
|
||||
const { path: file, token, respawn, execArgv, shardArgs: args, clientOptions: discordOptions } = this.#shardingOptions;
|
||||
if (!file)
|
||||
throw new Error('File seems to be missing');
|
||||
if (!discordOptions)
|
||||
throw new Error('Missing discord options');
|
||||
const shard = new Shard(this, id, {
|
||||
file,
|
||||
token,
|
||||
respawn,
|
||||
args,
|
||||
execArgv,
|
||||
totalShards,
|
||||
clientOptions: discordOptions
|
||||
});
|
||||
this.#shards.set(shard.id, shard);
|
||||
this.#logger.attach(shard);
|
||||
this.#setListeners(shard);
|
||||
return shard;
|
||||
}
|
||||
|
||||
#setListeners (shard: Shard)
|
||||
{
|
||||
shard.on('death', () => this.#logger.info(`Shard ${shard.id} has died`));
|
||||
shard.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`));
|
||||
shard.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`));
|
||||
shard.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`));
|
||||
shard.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`));
|
||||
shard.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`));
|
||||
shard.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`));
|
||||
shard.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`));
|
||||
shard.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true }));
|
||||
|
||||
shard.on('message', (msg) => this.#handleMessage(shard, msg));
|
||||
}
|
||||
|
||||
#handleMessage (shard: Shard, message: IPCMessage)
|
||||
{
|
||||
if (message._logger)
|
||||
return;
|
||||
// this.logger.debug(`New message from ${shard ? `${message._api ? 'api-' : ''}shard ${shard.id}`: 'manager'}: ${inspect(message)}`);
|
||||
|
||||
if (message._mEval)
|
||||
return this.eval(shard, { script: message._mEval, debug: message.debug || false });
|
||||
if (message._commands)
|
||||
return this.#slashCommandManager._handleMessage(message as CommandsDef);
|
||||
if (message._api)
|
||||
return this.apiRequest(shard, message);
|
||||
}
|
||||
|
||||
apiRequest (shard: Shard, message: IPCMessage)
|
||||
{
|
||||
const { type } = message;
|
||||
switch (type)
|
||||
{
|
||||
case 'stats':
|
||||
this.#metrics.aggregateStatistics(shard, message);
|
||||
break;
|
||||
default:
|
||||
// this.#apiClientUtil.handleMessage(shard, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} shard The shard from which the eval came and to which it will be returned
|
||||
* @param {*} script The script to be executed
|
||||
* @memberof Manager
|
||||
* @private
|
||||
*/
|
||||
async eval (shard: Shard, { script, debug }: {script: string, debug: boolean})
|
||||
{
|
||||
this.#logger.info(`Incoming manager eval from shard ${shard.id}:\n${script}`);
|
||||
let result = null,
|
||||
error = null;
|
||||
|
||||
const response: IPCMessage = {
|
||||
script, _mEvalResult: true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// eslint-disable-next-line no-eval
|
||||
result = await eval(script);
|
||||
response._result = result;
|
||||
// if(typeof result !== 'string') result = inspect(result);
|
||||
if (debug)
|
||||
this.#logger.debug(`Eval result: ${inspect(result)}`);
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
const err = e as Error;
|
||||
error = Util.makePlainError(err);
|
||||
response._error = error;
|
||||
}
|
||||
return shard.send(response);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
broadcastEval (script: string | Function, options: BroadcastEvalOptions = {})
|
||||
{
|
||||
if (typeof script !== 'function')
|
||||
return Promise.reject(new TypeError('[shardmanager] Provided eval must be a function.'));
|
||||
return this._performOnShards('eval', [ `(${script})(this, ${JSON.stringify(options.context)})` ], options.shard);
|
||||
}
|
||||
|
||||
fetchClientValues (prop: string, shard?: number)
|
||||
{
|
||||
return this._performOnShards('fetchClientValue', [ prop ], shard);
|
||||
}
|
||||
|
||||
_performOnShards (method: ShardMethod, args: [string, object?], shard?: number): Promise<unknown>
|
||||
{
|
||||
if (this.#shards.size === 0)
|
||||
return Promise.reject(new Error('No shards available.'));
|
||||
|
||||
if (!this.ready)
|
||||
return Promise.reject(new Error('Controller not ready'));
|
||||
|
||||
if (typeof shard === 'number')
|
||||
{
|
||||
if (!this.#shards.has(shard))
|
||||
Promise.reject(new Error('Shard not found.'));
|
||||
|
||||
const s = this.#shards.get(shard) as Shard;
|
||||
if (method === 'eval')
|
||||
return s.eval(...args);
|
||||
else if (method === 'fetchClientValue')
|
||||
return s.eval(args[0]);
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for (const sh of this.#shards.values())
|
||||
{
|
||||
if (method === 'eval')
|
||||
promises.push(sh.eval(...args));
|
||||
else if (method === 'fetchClientValue')
|
||||
promises.push(sh.eval(args[0]));
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
async respawnAll ({ shardDelay = 5000, respawnDelay = 500, timeout = 30000 } = {})
|
||||
{
|
||||
let s = 0;
|
||||
for (const shard of this.#shards.values())
|
||||
{
|
||||
const promises: Promise<unknown>[] = [ shard.respawn({ delay: respawnDelay, timeout }) ];
|
||||
if (++s < this.#shards.size && shardDelay > 0)
|
||||
promises.push(Util.delayFor(shardDelay));
|
||||
await Promise.all(promises); // eslint-disable-line no-await-in-loop
|
||||
}
|
||||
return this.#shards;
|
||||
}
|
||||
|
||||
static parseShardOptions (options: ShardingOptions)
|
||||
{
|
||||
let shardList = options.shardList ?? 'auto';
|
||||
if (shardList !== 'auto')
|
||||
{
|
||||
if (!Array.isArray(shardList))
|
||||
throw new Error('ShardList must be an array.');
|
||||
shardList = [ ...new Set(shardList) ];
|
||||
if (shardList.length < 1)
|
||||
throw new Error('ShardList must have at least one ID.');
|
||||
if (shardList.some((shardId) => typeof shardId !== 'number' || isNaN(shardId) || !Number.isInteger(shardId) || shardId < 0))
|
||||
throw new Error('ShardList must be an array of positive integers.');
|
||||
}
|
||||
|
||||
const totalShards = options.totalShards || 'auto';
|
||||
if (totalShards !== 'auto')
|
||||
{
|
||||
if (typeof totalShards !== 'number' || isNaN(totalShards))
|
||||
throw new Error('TotalShards must be an integer.');
|
||||
if (totalShards < 1)
|
||||
throw new Error('TotalShards must be at least one.');
|
||||
if (!Number.isInteger(totalShards))
|
||||
throw new Error('TotalShards must be an integer.');
|
||||
}
|
||||
return { shardList, totalShards };
|
||||
}
|
||||
|
||||
get version ()
|
||||
{
|
||||
return this.#version;
|
||||
}
|
||||
|
||||
get ready ()
|
||||
{
|
||||
return this.#built;
|
||||
}
|
||||
|
||||
get readyAt ()
|
||||
{
|
||||
return this.#readyAt || -1;
|
||||
}
|
||||
|
||||
get totalShards ()
|
||||
{
|
||||
return this.#shardingOptions.totalShards as number;
|
||||
}
|
||||
|
||||
get developerGuilds ()
|
||||
{
|
||||
return this.#options.discord.slashCommands?.developerGuilds;
|
||||
}
|
||||
|
||||
get logger ()
|
||||
{
|
||||
return this.#logger;
|
||||
}
|
||||
|
||||
get api ()
|
||||
{
|
||||
return this.#api;
|
||||
}
|
||||
|
||||
get shards ()
|
||||
{
|
||||
return this.#shards.clone();
|
||||
}
|
||||
}
|
||||
|
||||
export default Controller;
|
@ -342,28 +342,28 @@ class Shard extends EventEmitter
|
||||
{
|
||||
if (message)
|
||||
{
|
||||
if (message._ready)
|
||||
if (message._ready)
|
||||
{
|
||||
this.#ready = true;
|
||||
this.emit('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._disconnect)
|
||||
if (message._disconnect)
|
||||
{
|
||||
this.#ready = false;
|
||||
this.emit('disconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._reconnecting)
|
||||
if (message._reconnecting)
|
||||
{
|
||||
this.#ready = false;
|
||||
this.emit('reconnecting');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._sFetchProp)
|
||||
if (message._sFetchProp)
|
||||
{
|
||||
const resp = { _sFetchProp: message._sFetchProp, _sFetchPropShard: message._sFetchPropShard };
|
||||
this.#manager.fetchClientValues(message._sFetchProp, message._sFetchPropShard).then(
|
||||
@ -373,7 +373,7 @@ class Shard extends EventEmitter
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._sEval)
|
||||
if (message._sEval)
|
||||
{
|
||||
const resp = { _sEval: message._sEval, _sEvalShard: message._sEvalShard };
|
||||
this.#manager._performOnShards('eval', [ message._sEval ], message._sEvalShard).then(
|
||||
@ -383,7 +383,7 @@ class Shard extends EventEmitter
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._sRespawnAll)
|
||||
if (message._sRespawnAll)
|
||||
{
|
||||
const { shardDelay, respawnDelay, timeout } = message._sRespawnAll;
|
||||
this.#manager.respawnAll({ shardDelay, respawnDelay, timeout }).catch(() =>
|
||||
@ -393,7 +393,16 @@ class Shard extends EventEmitter
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._fatal)
|
||||
if (message._shutdown)
|
||||
{
|
||||
const TO = setTimeout(() => this.#process?.kill('SIGKILL'), KillTO);
|
||||
this.#process?.once('exit', () => clearTimeout(TO));
|
||||
this.#ready = false;
|
||||
this.emit('shutdown');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message._fatal)
|
||||
{
|
||||
this.#process?.removeAllListeners();
|
||||
this.#ready = false;
|
||||
|
10
yarn.lock
10
yarn.lock
@ -940,14 +940,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@navy.gif/logger@npm:^2.5.2":
|
||||
version: 2.5.2
|
||||
resolution: "@navy.gif/logger@npm:2.5.2"
|
||||
"@navy.gif/logger@npm:^2.5.3":
|
||||
version: 2.5.3
|
||||
resolution: "@navy.gif/logger@npm:2.5.3"
|
||||
dependencies:
|
||||
"@navy.gif/discord-webhook": ^1.0.0
|
||||
chalk: ^4.1.2
|
||||
moment: ^2.29.4
|
||||
checksum: b0cede9024333016a525d0a82b9c45ebad2ba1c9484fb2c1cf0743c2f5d8b83812b4d1f603837984c0a2b2742b8ae62a60787e90b242d8545282a60899eaa2ba
|
||||
checksum: 0936998e000f55dbe573db27b25f82fc02c4f906abb6f933634622f99e11035bf6f5f37047421d55db19a54e67a6f8f59962c60353862954f629c9dd79ff846d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -4087,7 +4087,7 @@ __metadata:
|
||||
dependencies:
|
||||
"@discordjs/collection": ^1.5.1
|
||||
"@discordjs/rest": ^1.7.1
|
||||
"@navy.gif/logger": ^2.5.2
|
||||
"@navy.gif/logger": ^2.5.3
|
||||
"@navy.gif/timestring": ^6.0.6
|
||||
"@types/common-tags": ^1.8.1
|
||||
"@types/humanize-duration": ^3.27.1
|
||||
|
Loading…
Reference in New Issue
Block a user