This commit is contained in:
Erik 2024-03-31 13:46:28 +03:00
parent 6c4e307f27
commit e47210deb1
21 changed files with 6544 additions and 0 deletions

328
.eslintrc.json Normal file
View File

@ -0,0 +1,328 @@
{
"plugins": [
"@typescript-eslint"
],
"env": {
"es6": true,
"node": true
},
"overrides": [],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly",
"BigInt": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json"
},
"rules": {
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"accessor-pairs": "warn",
"array-callback-return": "warn",
"array-bracket-newline": [
"warn",
"consistent"
],
"array-bracket-spacing": [
"warn",
"always",
{
"objectsInArrays": false,
"arraysInArrays": false
}
],
"arrow-spacing": "warn",
"block-scoped-var": "warn",
"block-spacing": [
"warn",
"always"
],
"brace-style": [
"warn",
"allman"
],
"callback-return": "warn",
"camelcase": "warn",
"comma-dangle": [
"warn",
"only-multiline"
],
"comma-spacing": [
"warn",
{
"after": true,
"before": false
}
],
"comma-style": "warn",
"computed-property-spacing": [
"warn",
"never"
],
"consistent-this": "warn",
"dot-notation": [
"warn",
{
"allowKeywords": true
}
],
"dot-location": [
"error",
"property"
],
"eol-last": [
"warn",
"never"
],
"eqeqeq": "error",
"func-call-spacing": "warn",
"func-name-matching": "warn",
"func-names": "warn",
"func-style": "warn",
"function-paren-newline": "warn",
"generator-star-spacing": "warn",
"grouped-accessor-pairs": "warn",
"guard-for-in": "warn",
"handle-callback-err": "warn",
"id-blacklist": "warn",
"id-match": "warn",
"implicit-arrow-linebreak": "warn",
"indent": [
"warn",
4,
{
"SwitchCase": 1
}
],
"init-declarations": "warn",
"quotes": [
"error",
"single"
],
"jsx-quotes": [
"error",
"prefer-single"
],
"key-spacing": [
"warn",
{
"beforeColon": false,
"afterColon": true
}
],
"keyword-spacing": [
"warn",
{
"after": true,
"before": true
}
],
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "warn",
"lines-around-directive": "warn",
"max-classes-per-file": "warn",
"max-nested-callbacks": "warn",
"max-len": [
"warn",
{
"code": 140,
"ignoreComments": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"max-lines-per-function": [
"warn",
140
],
"max-depth": [
"warn",
3
],
"new-parens": "warn",
"no-alert": "warn",
"no-array-constructor": "warn",
"no-buffer-constructor": "warn",
"no-caller": "warn",
"no-console": "warn",
"no-constant-binary-expression": "error",
"no-div-regex": "warn",
"no-dupe-else-if": "warn",
"no-duplicate-imports": "warn",
"no-else-return": "warn",
"no-empty-function": "warn",
"no-eq-null": "error",
"no-eval": "warn",
"no-extend-native": "warn",
"no-extra-bind": "warn",
"no-extra-label": "warn",
"no-floating-decimal": "warn",
"no-implicit-coercion": "warn",
"no-implicit-globals": "warn",
"no-implied-eval": "error",
"no-import-assign": "warn",
"no-invalid-this": "warn",
"no-iterator": "warn",
"no-label-var": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "warn",
"no-loop-func": "warn",
"no-mixed-requires": "warn",
"no-multi-assign": "warn",
"no-multi-spaces": "warn",
"no-multi-str": "warn",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "warn",
"no-negated-in-lhs": "warn",
"no-negated-condition": "error",
"no-nested-ternary": "warn",
"no-new": "warn",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-require": "warn",
"no-new-wrappers": "warn",
"no-octal-escape": "warn",
"no-path-concat": "warn",
"no-process-exit": "warn",
"no-proto": "warn",
"no-restricted-globals": "warn",
"no-restricted-imports": "warn",
"no-restricted-modules": "warn",
"no-restricted-properties": "warn",
"no-restricted-syntax": "warn",
"no-return-assign": [
"warn",
"except-parens"
],
"no-return-await": "warn",
"no-script-url": "warn",
"no-self-compare": "warn",
"no-sequences": "warn",
"no-setter-return": "warn",
"no-spaced-func": "warn",
"@typescript-eslint/no-shadow": "error",
"no-tabs": "error",
"no-template-curly-in-string": "error",
"no-throw-literal": "warn",
"no-trailing-spaces": "warn",
"no-undef-init": "error",
"no-undefined": "error",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "error",
"no-unused-expressions": "warn",
"@typescript-eslint/no-use-before-define": "error",
"no-useless-call": "warn",
"no-useless-computed-key": "warn",
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-rename": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": [
"warn",
"below"
],
"object-curly-spacing": [
"warn",
"always"
],
"object-property-newline": [
"warn",
{
"allowAllPropertiesOnSameLine": true
}
],
"object-shorthand": "warn",
"one-var-declaration-per-line": "warn",
"operator-assignment": "warn",
"operator-linebreak": [
"warn",
"before"
],
"padding-line-between-statements": "warn",
"padded-blocks": [
"warn",
{
"switches": "never"
},
{
"allowSingleLineBlocks": true
}
],
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"prefer-destructuring": "warn",
"prefer-exponentiation-operator": "warn",
"prefer-numeric-literals": "warn",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "warn",
"prefer-regex-literals": "warn",
"prefer-rest-params": "warn",
"prefer-spread": "warn",
"require-jsdoc": "warn",
"require-unicode-regexp": "warn",
"rest-spread-spacing": "warn",
"semi": "error",
"semi-spacing": "warn",
"semi-style": [
"warn",
"last"
],
"space-before-blocks": "warn",
"space-before-function-paren": [
"error",
"always"
],
"space-in-parens": [
"warn",
"never"
],
"spaced-comment": [
"warn",
"always"
],
"strict": "warn",
"switch-colon-spacing": "warn",
"symbol-description": "warn",
"template-curly-spacing": [
"warn",
"never"
],
"template-tag-spacing": "warn",
"unicode-bom": [
"warn",
"never"
],
"vars-on-top": "warn",
"wrap-iife": "warn",
"wrap-regex": "error",
"yield-star-spacing": "warn",
"yoda": [
"warn",
"never"
],
"no-warning-comments": [
1,
{
"terms": [
"todo",
"fixme"
],
"location": "anywhere"
}
]
}
}

893
.yarn/releases/yarn-4.1.1.cjs vendored Normal file

File diff suppressed because one or more lines are too long

8
.yarnrc.yml Normal file
View File

@ -0,0 +1,8 @@
yarnPath: .yarn/releases/yarn-4.1.1.cjs
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
npmRegistryServer: "https://registry.corgi.wtf"

15
@types/Client.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import { LoggerClientOptions } from '@navy.gif/logger';
export type ClientOptions = {
rootDir: string,
logger: LoggerClientOptions,
version: string,
}
export type ComponentType = 'EDIT ME';
export type ComponentOptions = {
type: ComponentType,
name: string,
disabled?: boolean
};

28
@types/Controller.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
import { LoggerMasterOptions } from '@navy.gif/logger';
import { ClientOptions } from './Client.js';
export type ShardingOptions = {
// Auto only works if there is a way of programmatically determining the amount of shards,
// otherwise these will default to 1
shardList?: 'auto' | number[],
totalShards?: 'auto' | number,
// Whether the client should be respawned if it dies unexpectedly
respawn?: boolean,
// Args that are passed to the script
scriptArgs?: string[],
// Node arguments to be passed into the child process
execArgv?: string[],
// This is set internally to the value of clientPath, i.e. this is the path of the script ran by the child
path?: string,
clientOptions?: ClientOptions,
debug?: boolean
}
export type ControllerOptions = {
rootDir: string, // Defined by the startup script
clientPath: string, // Client (script that is ran in the child process) path relative to root dir
logger: LoggerMasterOptions,
shardOptions: ShardingOptions,
client: ClientOptions
}

12
@types/Shard.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
export type ShardOptions = {
file: string,
token?: string,
execArgv?: string[],
args?: string[];
respawn?: boolean,
clientOptions: ClientOptions
totalShards: number,
debug?: boolean
}
export type ShardMethod = 'eval' | 'fetchClientValue'

44
@types/Shared.d.ts vendored Normal file
View File

@ -0,0 +1,44 @@
export type PlainError = {
name: string,
message: string,
stack?: string,
};
export type EnvObject = {
[key: string]: unknown,
SHARDING_MANAGER: boolean,
SHARD_ID: number,
SHARD_COUNT: number,
}
export type IPCMessage = {
id?: string,
_start?: ClientOptions,
_ready?: boolean,
_disconnect?: boolean,
_reconnecting?: boolean,
_fetchProp?: string,
_sFetchProp?: string,
_sFetchPropShard?: number,
_sEval?: string,
_sEvalShard?: number,
_eval?: string,
_result?: unknown,
_error?: Error,
_sRespawnAll?: {
shardDelay: number,
respawnDelay: number,
timeout: number
},
_mEval?: boolean,
_mEvalResult?: boolean
_logger?: boolean,
_shutdown?: boolean,
_fatal?: boolean,
success?: boolean
script?: string,
debug?: boolean,
type?: string,
data?: unknown,
shards?: number[]
}

33
index.ts Normal file
View File

@ -0,0 +1,33 @@
// require('dotenv').config({ override: true });
import { config } from 'dotenv';
config({ override: true });
if (!process.env.NODE_ENV)
process.env.NODE_ENV = 'development';
// eslint-disable-next-line no-console
console.log(`Starting in ${process.env.NODE_ENV} mode`);
import Controller from './src/middleware/Controller.js';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { ControllerOptions } from './@types/Controller.js';
const optionsRaw = readFileSync('./options.jsonc', { encoding: 'utf-8' });
const lines = optionsRaw.split('\n');
const clean = [];
for (const line of lines)
{
if (line.startsWith('//'))
continue;
clean.push(line.replace(/\/\/.*/u, ''));
}
const options = JSON.parse(clean.join('\n')) as ControllerOptions;
const pkg = JSON.parse(readFileSync('./package.json', { encoding: 'utf-8' }));
const { version } = pkg;
const srcDir = path.join(process.cwd(), 'build', 'src');
options.rootDir = srcDir;
new Controller(options, version)
.build();

9
options.jsonc Normal file
View File

@ -0,0 +1,9 @@
{
"clientPath": "client/Client.js", // Client (script that is ran in the child process) path relative to root dir
"logger": {},
"shardOptions": {
"totalShards": "auto",
"":""
},
"client": {}
}

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "sharding-framework",
"version": "1.0.0",
"description": "Framework/template for quickly making a sharded program",
"main": "build/index.js",
"repository": "https://git.corgi.wtf/Navy.gif/sharding-framework.git",
"author": "Navy.gif <navydotgif@gmail.com>",
"license": "MIT",
"packageManager": "yarn@4.1.1",
"type": "module",
"dependencies": {
"@navy.gif/commandparser": "^1.6.8",
"@navy.gif/logger": "^2.5.4",
"@types/node": "^20.11.30",
"dotenv": "^16.4.5",
"typescript": "^5.4.3"
},
"devDependencies": {
"@babel/core": "^7.24.3",
"@babel/preset-env": "^7.24.3",
"@babel/preset-typescript": "^7.24.1",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"eslint": "^8.57.0"
}
}

147
src/client/Client.ts Normal file
View File

@ -0,0 +1,147 @@
import { IPCMessage } from '../../@types/Shared.js';
import { ClientOptions } from '../../@types/Client.js';
import Util from '../utilities/Util.js';
import Intercom from './components/Intercom.js';
import { LoggerClient, LoggerClientOptions } from '@navy.gif/logger';
import Registry from './components/Registry.js';
import { EventEmitter } from 'node:events';
class Client extends EventEmitter
{
#logger: LoggerClient;
#intercom: Intercom;
#registry: Registry;
#options: ClientOptions;
#built: boolean;
constructor (options: ClientOptions)
{
if (!options)
throw Util.fatal(new Error('Missing options'));
if (!options.logger)
throw Util.fatal('Missing logger options');
super();
this.#options = options;
this.#logger = new LoggerClient(this);
this.#intercom = new Intercom();
this.#registry = new Registry(this);
process.on('unhandledRejection', (err: Error) =>
{
this.#logger.error(`Unhandled rejection:\n${err?.stack || err}`);
});
process.on('message', this.#handleMessage.bind(this));
// Exits are handled in a custom fashion on the client, through the message event
process.on('SIGINT', () => this.#logger.info('Received SIGINT'));
process.on('SIGTERM', () => this.#logger.info('Received SIGTERM'));
this.#built = false;
}
async build ()
{
if (this.#built)
return;
this.#logger.info('Building client, loading components');
// await this.#registry.loadComponents('components/commands', Command);
// await this.#registry.loadComponents('components/inhibitors', Inhibitor);
// await this.#registry.loadComponents('components/observers', Observer);
// await this.#registry.loadComponents('components/downloaders', Downloader);
this.#logger.info('Initialising events');
// await this.#eventHooker.init();
// this.#logger.info('Logging in');
const ready = this.#ready();
// await super.login();
await ready;
this.#built = true;
this.emit('built');
return this;
}
async shutdown (code = 0)
{
this.#logger.status('Shutdown order received, closing down');
this.intercom.send('shutdown');
// await super.destroy();
this.removeAllListeners();
// eslint-disable-next-line no-process-exit
process.exit(code);
}
#handleMessage (message: IPCMessage)
{
if (message._shutdown)
this.shutdown();
}
// Wait until the client is actually ready, i.e. all structures from discord are created
#ready ()
{
return new Promise<void>((resolve) =>
{
if (this.#built)
return resolve();
this.once('ready', () =>
{
this.intercom.send('ready');
// this.#createWrappers();
resolve();
});
});
}
// Helper function to pass options to the logger in a unified way
createLogger (comp: object, options: LoggerClientOptions = {})
{
return new LoggerClient({ name: comp.constructor.name, ...this.#options.logger, ...options });
}
get developmentMode ()
{
return process.env.NODE_ENV === 'development';
}
get intercom ()
{
return this.#intercom;
}
get rootDir ()
{
return this.#options.rootDir;
}
get registry ()
{
return this.#registry;
}
get ready ()
{
return this.#built;
}
}
process.once('message', (msg: IPCMessage) =>
{
if (msg._start)
{
const client = new Client(msg._start);
client.build();
}
});
export default Client;

View File

@ -0,0 +1,16 @@
class Intercom
{
send (type: string, message = {})
{
if (typeof message !== 'object')
throw new Error('Invalid message object');
if (!process.send)
return; // Nowhere to send, the client was not spawned as a shard
return process.send({
[`_${type}`]: true,
...message
});
}
}
export default Intercom;

View File

@ -0,0 +1,137 @@
import path from 'node:path';
import { LoggerClient } from '@navy.gif/logger';
import Client from '../Client.js';
import { ExtendedMap } from '../../utilities/ExtendedMap.js';
import Component from '../interfaces/Component.js';
import Util from '../../utilities/Util.js';
import { isInitialisable } from '../interfaces/Initialisable.js';
class Registry
{
#client: Client;
#components: ExtendedMap<string, Component>;
#logger: LoggerClient;
constructor (client: Client)
{
this.#client = client;
this.#components = new ExtendedMap();
this.#logger = client.createLogger(this);
}
/**
* Loads components from directory. All loaded components are added to the components Collection
*
* @param dir The directory within 'src/client' where to look for components
* @param classToHandle The parent class from which all the components inherit, ex. Setting
* @return The loaded components
* @memberof Registry
*/
async loadComponents (dir: string, classToHandle: typeof Component)
{
const directory = path.join(this.#client.rootDir, 'client', dir); // Finds directory of component folder relative to current working directory.
const files = Util.readdirRecursive(directory); // Loops through all folders in the directory and returns the files.
const loaded: Component[] = [];
for (const filePath of files)
{
const mod = await import(`file://${filePath}`);
const func = mod.default;
if (typeof func !== 'function')
{
this.#logger.warn(`Attempted to index an invalid function as a component: ${filePath}`);
continue;
}
const component = new func(this.#client); // Instantiates the component class.
if (classToHandle && !(component instanceof classToHandle))
{
this.#logger.warn('Attempted to load an invalid class.');
continue;
}
loaded.push(await this.loadComponent(component, filePath));
}
return loaded;
}
/**
* Assigns the component module and makes it available for the client
*
* @param component Component to load
* @param directory The directory in which the component resides
* @param [silent=false] Whether to emit a component update event
* @memberof Registry
*/
async loadComponent<T extends Component> (component: T, directory?: string, silent = false): Promise<T>
{
if (!(component instanceof Component))
throw new Error('Attempted to load an invalid component.');
if (this.#components.has(component.resolveable))
throw new Error(`Attempted to load an existing component: ${component.resolveable}`);
if (directory)
component.directory = directory;
if (isInitialisable(component))
await component.initialise();
this.#components.set(component.resolveable, component);
if (!silent)
this.#client.emit('componentUpdate', { component, type: 'LOAD' });
return component;
}
async unloadComponent (component: Component)
{
this.#components.delete(component.resolveable);
}
filter<T extends Component> (f: (c: T) => boolean): ExtendedMap<string, T>
{
const coll = new ExtendedMap<string, T>();
for (const component of this.#components.values())
{
if (f(component as T))
coll.set(component.resolveable, component as T);
}
return coll;
}
get<T extends Component = Component> (name: string)
{
return this.components.get(name) as T;
}
// get commands ()
// {
// return this.#components.filter((comp) => comp.type === 'command') as Collection<string, Command>;
// }
// get observers ()
// {
// return this.#components.filter(comp => comp.type === 'observer') as Collection<string, Observer>;
// }
// get inhibitors ()
// {
// return this.#components.filter(comp => comp.type === 'inhibitor') as Collection<string, Inhibitor>;
// }
// get downloaders ()
// {
// return this.#components.filter(comp => comp.type === 'downloader') as Collection<string, Downloader>;
// }
get components ()
{
return this.#components.clone();
}
}
export default Registry;

View File

@ -0,0 +1,147 @@
import { ComponentOptions, ComponentType } from '../../../@types/Client.js';
import Client from '../Client.js';
/**
* Superclass for all components used by the client, all commands, endpoints, etc should inherit from this
* @date 3/31/2024 - 1:39:51 PM
*
* @abstract
* @class Component
* @typedef {Component}
*/
abstract class Component
{
#client: Client;
#name: string;
#type: ComponentType;
#directory: string | null;
// #guarded: boolean;
#disabled: boolean;
/**
* Creates an instance of Component.
* @param {Client} client
* @param {Object} [options={}]
* @memberof Component
*/
constructor (client: Client, options: ComponentOptions)
{
if (!options)
throw new Error('Missing options');
/**
* @type {Client}
*/
this.#client = client;
if (!options.name || !options.type)
throw new Error('Missing component name or type');
this.#name = options.name; // Name of the component
this.#type = options.type; // Type of component (command, observer, etc.)
// if (!options.moduleName)
// throw new Error('Missing module name');
this.#directory = null; // File directory to component.
// this.#guarded = Boolean(options.guarded); // Can be modified (unload/load/enable/disable)
this.#disabled = Boolean(options.disabled); // Disables usage.
}
get client ()
{
return this.#client;
}
get name ()
{
return this.#name;
}
get type ()
{
return this.#type;
}
get directory (): string | null
{
return this.#directory;
}
set directory (dir: string)
{
this.#directory = dir;
}
// get guarded ()
// {
// return this.#guarded;
// }
get disabled ()
{
return this.#disabled;
}
unload ()
{
// if (this.#guarded)
// return { error: true, code: 'GUARDED' };
if (!this.#directory)
return { error: true, code: 'MISSING_DIRECTORY' };
this.client.registry.unloadComponent(this);
// delete require.cache[this.#directory];
this.client.emit('componentUpdate', {
component: this,
type: 'UNLOAD'
});
return { error: false };
}
reload () // bypass = false
{
// if (this.#guarded && !bypass)
// return { error: true, code: 'GUARDED' };
if (!this.#directory || !require.cache[this.#directory])
return { error: true, code: 'MISSING_DIRECTORY' };
let cached = null,
newModule = null;
try
{
cached = require.cache[this.#directory];
delete require.cache[this.#directory];
newModule = require(this.#directory);
if (typeof newModule === 'function')
{
newModule = new newModule(this.client);
}
this.client.registry.unloadComponent(this);
this.client.registry.loadComponent(newModule, this.#directory, true);
this.client.emit('componentUpdate', { component: this, type: 'RELOAD' });
}
catch (error)
{
if (cached)
require.cache[this.#directory] = cached;
return { error: true, code: 'MISSING_MODULE' };
}
return { error: false };
}
get resolveable ()
{
return `${this.#type}:${this.#name}`;
}
}
export default Component;

View File

@ -0,0 +1,14 @@
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isInitialisable = (obj: any): obj is Initialisable =>
{
return typeof obj.initialise === 'function' && typeof obj.stop === 'function';
};
interface Initialisable {
ready: boolean;
initialise(): Promise<void> | void;
stop(): Promise<void> | void;
}
export default Initialisable;
export { isInitialisable };

View File

@ -0,0 +1,270 @@
import path from 'node:path';
import fs from 'node:fs';
import { inspect } from 'node:util';
import { MasterLogger } from '@navy.gif/logger';
import { ControllerOptions, ShardingOptions } from '../../@types/Controller.js';
import { ShardMethod } from '../../@types/Shard.js';
import Shard from './Shard.js';
import Util from '../utilities/Util.js';
import { ExtendedMap } from '../utilities/ExtendedMap.js';
class Controller
{
#ready: boolean;
#shards: ExtendedMap<number, Shard>;
#logger: MasterLogger;
#version: string;
#shardingOptions: ShardingOptions;
#exiting: boolean;
constructor (options: ControllerOptions, version: string)
{
const clientPath = path.join(options.rootDir, 'client/DiscordClient.js');
if (!fs.existsSync(clientPath))
throw new Error(`Client path does not seem to exist: ${clientPath}`);
this.#logger = new MasterLogger({ ...options.logger, webhook: { url: process.env.ERROR_WEBHOOK_URL } });
this.#shards = new ExtendedMap();
// this.#options = options;
const { shardList, totalShards, execArgv, respawn, debug } = Controller.parseShardOptions(options.shardOptions);
options.client.rootDir = options.rootDir;
options.client.logger = options.logger;
options.client.version = version;
this.#shardingOptions = {
path: clientPath,
totalShards,
shardList,
respawn,
scriptArgs: [],
execArgv,
clientOptions: options.client,
debug
};
this.#version = version;
this.#ready = false;
this.#exiting = false;
process.on('SIGINT', () => this.shutdown('SIGINT'));
process.on('SIGTERM', () => this.shutdown('SIGTERM'));
}
async build ()
{
const start = Date.now();
this.#logger.status('Starting bot shards');
const { totalShards } = this.#shardingOptions;
let shardCount = 0;
if (totalShards === 'auto')
{
shardCount = 1;
}
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 retry: Shard[] = [];
for (let i = 0; i < shardCount; i++)
{
const shard = this.createShard(shardCount);
try
{
await shard.spawn(90_000);
}
catch (err)
{
this.#logger.error(err as Error);
this.#logger.info('Retrying shard', i);
retry.push(shard);
}
// promises.push();
}
for (const shard of retry)
{
try
{
await shard.spawn(120_000);
}
catch
{
this.#logger.error(`Shard ${shard.id} failed to spawn in time`);
}
}
this.#logger.status(`Shards spawned, spawned ${this.#shards.size} shards. Took ${Date.now() - start} ms`);
this.#ready = 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, respawn, execArgv, scriptArgs: 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,
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`))
.on('fatal', ({ error }) => this.#logger.warn(`Shard ${shard.id} has died fatally: ${inspect(error) ?? ''}`))
.on('shutdown', () => this.#logger.info(`Shard ${shard.id} is shutting down gracefully`))
.on('ready', () => this.#logger.info(`Shard ${shard.id} is ready`))
.on('disconnect', () => this.#logger.warn(`Shard ${shard.id} has disconnected`))
.on('processDisconnect', () => this.#logger.warn(`Process for ${shard.id} has disconnected`))
.on('spawn', () => this.#logger.info(`Shard ${shard.id} spawned`))
.on('error', (err) => this.#logger.error(`Shard ${shard.id} ran into an error:\n${err.stack}`))
.on('warn', (msg) => this.#logger.warn(`Warning from shard ${shard.id}: ${msg}`, { broadcast: true }))
.on('debug', msg => this.#logger.debug(msg));
// shard.on('message', (msg) => this.#handleMessage(shard, msg));
}
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.');
}
let { execArgv } = options;
if (!execArgv)
execArgv = [];
const respawn = (process.env.NODE_ENV !== 'development' || options.respawn) ?? false;
return { shardList, totalShards, execArgv, respawn, debug: options.debug ?? false };
}
async shutdown (type: string)
{
if (this.#exiting)
return;
this.#exiting = true;
this.#logger.info(`Received ${type}, shutting down`);
setTimeout(process.exit, 90_000);
const promises = this.#shards
.filter(shard => shard.ready)
.map(shard =>
{
return shard.kill();
// return shard.awaitShutdown()
// .then(() => shard.removeAllListeners());
});
if (promises.length)
await Promise.all(promises);
this.#logger.status('Shutdown complete, goodbye');
this.#logger.close();
// eslint-disable-next-line no-process-exit
process.exit();
}
get ready ()
{
return this.#ready;
}
get version ()
{
return this.#version;
}
}
export default Controller;

472
src/middleware/Shard.ts Normal file
View File

@ -0,0 +1,472 @@
import EventEmitter from 'node:events';
import path from 'node:path';
import childProcess, { ChildProcess } from 'node:child_process';
import { EnvObject, IPCMessage, PlainError } from '../../@types/Shared.js';
import Controller from './Controller.js';
import { ShardOptions } from '../../@types/Shard.js';
import { ClientOptions } from '../../@types/Client.js';
import Util from '../utilities/Util.js';
const KillTO = 90 * 1000;
class Shard extends EventEmitter
{
[key: string]: unknown;
#debug: boolean;
#id: number;
#controller: Controller;
#env: EnvObject; // { [key: string]: string | boolean | number };
#ready: boolean;
#clientOptions: ClientOptions;
#args: string[];
#execArgv: string[];
#file: string;
#respawn: boolean;
#spawnedAt: number;
#process: ChildProcess | null;
#evals: Map<string, Promise<unknown>>;
#fetches: Map<string, Promise<unknown>>;
#awaitingShutdown: (() => void) | null;
#awaitingResponse: Map<string, (msg: IPCMessage) => void>;
#crashes: number[];
#fatal: boolean;
constructor (controller: Controller, id: number, options: ShardOptions)
{
super();
this.#controller = controller;
this.#id = id;
this.#debug = options.debug ?? false;
this.#args = options.args ?? [];
this.#execArgv = options.execArgv ?? [];
if (!options.file)
throw new Error('Missing file');
this.#file = options.file;
this.#respawn = options.respawn ?? true;
if (!options.token)
throw new Error('Missing token');
this.#env = {
...process.env,
SHARDING_MANAGER: true, // IMPORTANT, SHARD IPC WILL BREAK IF MISSING
SHARDING_MANAGER_MODE: 'process', // IMPORTANT, SHARD IPC WILL BREAK IF MISSING
SHARDS: this.#id,
SHARD_ID: this.#id,
SHARD_COUNT: options.totalShards,
DISCORD_TOKEN: options.token
};
if (!options.clientOptions?.libraryOptions)
throw new Error('Missing library options, must provide intents');
this.#clientOptions = options.clientOptions || {};
this.#ready = false;
this.#process = null;
this.#spawnedAt = -1;
this.#awaitingShutdown = null;
this.#evals = new Map();
this.#fetches = new Map();
this.#awaitingResponse = new Map();
this.#crashes = [];
this.#fatal = false;
}
get ready ()
{
return this.#ready;
}
get id ()
{
return this.#id;
}
get spawnedAt ()
{
return this.#spawnedAt;
}
get fatal ()
{
return this.#fatal;
}
spawn (timeout = 30000): Promise<void>
{
if (this.process)
throw new Error(`[shard${this.id}] Sharding process already exists.`);
if (this.worker)
throw new Error(`[shard${this.id}] Sharding worker already exists.`);
this.#process = childProcess
.fork(path.resolve(this.#file), this.#args, {
env: this.#env as NodeJS.ProcessEnv,
execArgv: this.#execArgv
})
.on('message', this.#handleMessage.bind(this))
.on('exit', this.#handleExit.bind(this))
.on('disconnect', this.#handleDisconnect);
this.#evals.clear();
this.#fetches.clear();
this.#process.once('spawn', () =>
{
this.emit('spawn');
this.#process!.send({ _start: this.#clientOptions });
this.#spawnedAt = Date.now();
});
if (timeout === -1 || timeout === Infinity)
return Promise.resolve();
return new Promise((resolve, reject) =>
{
const cleanup = (kill?: boolean) =>
{
if (kill)
this.#process?.kill();
clearTimeout(spawnTimeoutTimer); // eslint-disable-line @typescript-eslint/no-use-before-define
this.off('ready', onReady); // eslint-disable-line @typescript-eslint/no-use-before-define
this.off('disconnect', onDisconnect); // eslint-disable-line @typescript-eslint/no-use-before-define
this.off('death', onDeath); // eslint-disable-line @typescript-eslint/no-use-before-define
};
const onReady = () =>
{
cleanup();
resolve();
};
const onDisconnect = () =>
{
cleanup();
reject(new Error(`[SHARD-${this.id}] Shard disconnected while readying.`));
};
const onDeath = () =>
{
cleanup();
reject(new Error(`[SHARD-${this.id}] Shard died while readying.`));
};
const onTimeout = () =>
{
cleanup(true);
reject(new Error(`[SHARD-${this.id}] Shard timed out while readying.`));
};
const spawnTimeoutTimer = setTimeout(onTimeout, timeout);
this.once('ready', onReady);
this.once('disconnect', onDisconnect);
this.once('death', onDeath);
});
}
// When killing the process, give it an opportunity to gracefully shut down (i.e. clean up DB connections etc)
// It simply has to respond with a shutdown message to the shutdown event
kill ()
{
if (this.#process)
{
return new Promise<void>((resolve) =>
{
this.debug('Shard.kill');
if (!this.#process)
return resolve();
this.#process.removeAllListeners('exit');
const timeout = setTimeout(() =>
{
this.debug('Shard.kill timeout');
if (!this.#process)
return resolve();
this.#process.kill();
resolve();
}, KillTO);
this.#process.once('exit', (code, signal) =>
{
this.debug('Shard.kill exit listener');
clearTimeout(timeout);
this.#handleExit(code, signal, false);
resolve();
});
this.once('shutdown', () =>
{
this.debug('Shard.kill shutdown listener');
clearTimeout(timeout);
});
this.send({ _shutdown: true });
});
}
return Promise.resolve();
}
awaitShutdown ()
{
this.#respawn = false;
return new Promise<void>((resolve) =>
{
this.#awaitingShutdown = resolve;
});
}
async respawn ({ delay = 500, timeout = 30000 } = {})
{
this.kill();
if (delay > 0)
await Util.delayFor(delay);
return this.spawn(timeout);
}
send (message: IPCMessage, expectResponse?: boolean)
{
if (!this.#process)
return Promise.reject(new Error('Shard is not running'));
return new Promise((resolve, reject) =>
{
if (expectResponse)
{
message.id = Util.createUUID();
const timeout = setTimeout(reject, 10_000, [ new Error('Message timeout') ]);
this.#awaitingResponse.set(message.id, (msg: IPCMessage) =>
{
clearTimeout(timeout);
resolve(msg);
});
}
if (this.#process)
{
this.#process.send(message, (error) =>
{
if (error)
reject(error);
else if (!expectResponse)
resolve(this);
});
}
});
}
fetchClientValue (prop: string): Promise<unknown>
{
if (this.#fetches.has(prop))
return Promise.resolve(this.#fetches.get(prop));
const promise = new Promise((resolve, reject) =>
{
const child = this.#process;
if (!child)
return reject(new Error(`[shard${this.id}] Shard process missing.`));
const listener = (message: { _fetchProp: string, _result: unknown}) =>
{
if (message?._fetchProp !== prop)
return;
child.removeListener('message', listener);
this.#fetches.delete(prop);
resolve(message._result);
};
child.on('message', listener);
this.send({ _fetchProp: prop }).catch((error) =>
{
child.removeListener('message', listener);
this.#fetches.delete(prop);
reject(error);
});
});
this.#fetches.set(prop, promise);
return promise;
}
// eslint-disable-next-line @typescript-eslint/ban-types
eval (script: string | Function, context?: object): Promise<unknown>
{
// Stringify the script if it's a Function
const _eval = typeof script === 'function' ? `(${script})(this, ${JSON.stringify(context)})` : script;
// Cached promise from previous call
if (this.#evals.has(_eval))
return Promise.resolve(this.#evals.get(_eval));
const promise = new Promise((resolve, reject) =>
{
const child = this.#process;
// Shard is dead (maybe respawning), don't cache anything and error immediately
if (!child)
return reject(new Error(`[shard${this.id}] Shard process missing.`));
const listener = (message: {_eval: string, _error: PlainError, _result: unknown}) =>
{
if (message?._eval !== _eval)
return;
child.removeListener('message', listener);
this.#evals.delete(_eval);
if (message._error)
reject(Util.makeError(message._error));
else
resolve(message._result);
};
child.on('message', listener);
this.send({ _eval }).catch((err) =>
{
child.removeListener('message', listener);
this.#evals.delete(_eval);
reject(err);
});
});
this.#evals.set(_eval, promise);
return promise;
}
#handleDisconnect ()
{
this.emit('processDisconnect');
}
#handleMessage (message: IPCMessage)
{
if (message)
{
if (message._ready)
{
this.#ready = true;
this.emit('ready');
return;
}
if (message._disconnect)
{
this.#ready = false;
this.emit('disconnect');
return;
}
if (message._reconnecting)
{
this.#ready = false;
this.emit('reconnecting');
return;
}
if (message._sFetchProp)
{
const resp = { _sFetchProp: message._sFetchProp, _sFetchPropShard: message._sFetchPropShard };
this.#controller.fetchClientValues(message._sFetchProp, message._sFetchPropShard).then(
(results: unknown) => this.send({ ...resp, _result: results }),
(error: Error) => this.send({ ...resp, _error: Util.makePlainError(error) })
);
return;
}
if (message._sEval)
{
const resp = { _sEval: message._sEval, _sEvalShard: message._sEvalShard };
this.#controller._performOnShards('eval', [ message._sEval ], message._sEvalShard).then(
(results: unknown) => this.send({ ...resp, _result: results }),
(error: Error) => this.send({ ...resp, _error: Util.makePlainError(error) })
);
return;
}
if (message._sRespawnAll)
{
const { shardDelay, respawnDelay, timeout } = message._sRespawnAll;
this.#controller.respawnAll({ shardDelay, respawnDelay, timeout }).catch(() =>
{
//
});
return;
}
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;
this.#fatal = true;
this.#handleExit(null, null, false);
this.emit('fatal', message);
return;
}
}
this.emit('message', message);
}
#handleExit (code: number | null, _signal: string | null, respawn = this.#respawn)
{
this.#process?.removeAllListeners();
this.emit('death');
if (this.#awaitingShutdown)
this.#awaitingShutdown();
if (code !== 0)
{
this.#crashes.push(Date.now() - this.spawnedAt);
this.emit('warn', 'Shard exited with non-zero exit code: ' + code);
}
this.#ready = false;
this.#process = null;
this.#evals.clear();
this.#fetches.clear();
const len = this.#crashes.length;
if (len > 2)
{
const last3 = this.#crashes.slice(len - 3);
const sum = last3.reduce((s, val) =>
{
s += val; return s;
}, 0);
const avg = sum / 3;
if (avg < 15 * 60 * 1000)
{
this.emit('warn', `Crash loop detected, average run time for the last 3 spawns: ${avg}, no longer respawning`);
respawn = false;
}
}
if (respawn)
this.spawn().catch((error: Error) => this.emit('error', error));
}
private debug (msg: string)
{
if (this.#debug && this.listenerCount('debug'))
this.emit('debug', `[SHARD-${this.id}] ${msg}`);
}
}
export default Shard;

View File

@ -0,0 +1,260 @@
/* eslint-disable no-undefined */
export interface MapConstructor {
new (): ExtendedMap<unknown, unknown>;
new <Key, Value>(entries?: readonly (readonly [Key, Value])[] | null): ExtendedMap<Key, Value>;
new <Key, Value>(iterable: Iterable<readonly [Key, Value]>): ExtendedMap<Key, Value>;
readonly prototype: ExtendedMap<unknown, unknown>;
readonly [Symbol.species]: MapConstructor;
}
export interface ExtendedMap<Key, Value> extends Map<Key, Value> {
constructor: MapConstructor;
}
type Comparator<Key, Value> = (firstValue: Value, secondValue: Value, firstKey: Key, secondKey: Key) => number;
/**
* A Map with additional utility methods. This is used throughout discord.js rather than Arrays for anything that has
* an ID, for significantly improved performance and ease-of-use.
*
* @typeParam Key - The key type this map holds
* @typeParam Value - The value type this map holds
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class ExtendedMap<Key, Value> extends Map<Key, Value>
{
public first(): Value | undefined;
public first(amount: number): Value[];
public first (amount?: number): Value | Value[] | undefined
{
if (amount === undefined)
return this.values().next().value;
if (amount < 0)
return this.last(amount * -1);
amount = Math.min(this.size, amount);
const iter = this.values();
return Array.from({ length: amount }, (): Value => iter.next().value);
}
public last(): Value | undefined;
public last(amount: number): Value[];
public last (amount?: number): Value | Value[] | undefined
{
const arr = [ ...this.values() ];
if (amount === undefined)
return arr[arr.length - 1];
if (amount < 0)
return this.first(amount * -1);
if (!amount)
return [];
return arr.slice(-amount);
}
public random(): Value | undefined;
public random(amount: number): Value[];
public random (amount?: number): Value | Value[] | undefined
{
const arr = [ ...this.values() ];
if (amount === undefined)
return arr[Math.floor(Math.random() * arr.length)];
if (!arr.length || !amount)
return [];
return Array.from(
{ length: Math.min(amount, arr.length) },
(): Value => arr.splice(Math.floor(Math.random() * arr.length), 1)[0]!,
);
}
public find<NewValue extends Value>(
fn: (value: Value, key: Key, map: this) => value is NewValue,
): NewValue | undefined;
public find(fn: (value: Value, key: Key, map: this) => unknown): Value | undefined;
public find<This, NewValue extends Value>(
fn: (this: This, value: Value, key: Key, map: this) => value is NewValue,
thisArg: This,
): NewValue | undefined;
public find<This>(
fn: (this: This, value: Value, key: Key, map: this) => unknown,
thisArg: This,
): Value | undefined;
public find (fn: (value: Value, key: Key, map: this) => unknown, thisArg?: unknown): Value | undefined
{
if (typeof fn !== 'function')
throw new TypeError(`${fn} is not a function`);
if (thisArg !== undefined)
fn = fn.bind(thisArg);
for (const [ key, val ] of this)
{
if (fn(val, key, this))
return val;
}
return undefined;
}
public filter<NewKey extends Key>(
fn: (value: Value, key: Key, map: this) => key is NewKey,
): ExtendedMap<NewKey, Value>;
public filter<NewValue extends Value>(
fn: (value: Value, key: Key, map: this) => value is NewValue,
): ExtendedMap<Key, NewValue>;
public filter(fn: (value: Value, key: Key, map: this) => unknown): ExtendedMap<Key, Value>;
public filter<This, NewKey extends Key>(
fn: (this: This, value: Value, key: Key, map: this) => key is NewKey,
thisArg: This,
): ExtendedMap<NewKey, Value>;
public filter<This, NewValue extends Value>(
fn: (this: This, value: Value, key: Key, map: this) => value is NewValue,
thisArg: This,
): ExtendedMap<Key, NewValue>;
public filter<This>(
fn: (this: This, value: Value, key: Key, map: this) => unknown,
thisArg: This,
): ExtendedMap<Key, Value>;
public filter (fn: (value: Value, key: Key, map: this) => unknown, thisArg?: unknown): ExtendedMap<Key, Value>
{
if (typeof fn !== 'function')
throw new TypeError(`${fn} is not a function`);
if (thisArg !== undefined)
fn = fn.bind(thisArg);
const results = new this.constructor[Symbol.species]<Key, Value>();
for (const [ key, val ] of this)
{
if (fn(val, key, this))
results.set(key, val);
}
return results;
}
public map<NewValue>(fn: (value: Value, key: Key, map: this) => NewValue): NewValue[];
public map<This, NewValue>(
fn: (this: This, value: Value, key: Key, map: this) => NewValue,
thisArg: This,
): NewValue[];
public map<NewValue> (fn: (value: Value, key: Key, map: this) => NewValue, thisArg?: unknown): NewValue[]
{
if (typeof fn !== 'function')
throw new TypeError(`${fn} is not a function`);
if (thisArg !== undefined)
fn = fn.bind(thisArg);
const iter = this.entries();
return Array.from({ length: this.size }, (): NewValue =>
{
const [ key, value ] = iter.next().value;
return fn(value, key, this);
});
}
public some(fn: (value: Value, key: Key, map: this) => unknown): boolean;
public some<This>(fn: (this: This, value: Value, key: Key, map: this) => unknown, thisArg: This): boolean;
public some (fn: (value: Value, key: Key, map: this) => unknown, thisArg?: unknown): boolean
{
if (typeof fn !== 'function')
throw new TypeError(`${fn} is not a function`);
if (thisArg !== undefined)
fn = fn.bind(thisArg);
for (const [ key, val ] of this)
{
if (fn(val, key, this))
return true;
}
return false;
}
public every<NewKey extends Key>(
fn: (value: Value, key: Key, map: this) => key is NewKey,
): this is ExtendedMap<NewKey, Value>;
public every<NewValue extends Value>(
fn: (value: Value, key: Key, map: this) => value is NewValue,
): this is ExtendedMap<Key, NewValue>;
public every(fn: (value: Value, key: Key, map: this) => unknown): boolean;
public every<This, NewKey extends Key>(
fn: (this: This, value: Value, key: Key, map: this) => key is NewKey,
thisArg: This,
): this is ExtendedMap<NewKey, Value>;
public every<This, NewValue extends Value>(
fn: (this: This, value: Value, key: Key, map: this) => value is NewValue,
thisArg: This,
): this is ExtendedMap<Key, NewValue>;
public every<This>(fn: (this: This, value: Value, key: Key, map: this) => unknown, thisArg: This): boolean;
public every (fn: (value: Value, key: Key, map: this) => unknown, thisArg?: unknown): boolean
{
if (typeof fn !== 'function')
throw new TypeError(`${fn} is not a function`);
if (thisArg !== undefined)
fn = fn.bind(thisArg);
for (const [ key, val ] of this)
{
if (!fn(val, key, this))
return false;
}
return true;
}
public reduce<InitialValue = Value> (
fn: (accumulator: InitialValue, value: Value, key: Key, map: this) => InitialValue,
initialValue?: InitialValue,
): InitialValue
{
if (typeof fn !== 'function')
throw new TypeError(`${fn} is not a function`);
// eslint-disable-next-line init-declarations
let accumulator!: InitialValue;
const iterator = this.entries();
if (initialValue === undefined)
{
if (this.size === 0)
throw new TypeError('Reduce of empty map with no initial value');
[ , accumulator ] = iterator.next().value;
}
else
{
accumulator = initialValue;
}
for (const [ key, value ] of iterator)
{
accumulator = fn(accumulator, value, key, this);
}
return accumulator;
}
public clone (): ExtendedMap<Key, Value>
{
return new this.constructor[Symbol.species](this);
}
public sort (compareFunction: Comparator<Key, Value> = ExtendedMap.defaultSort)
{
const entries = [ ...this.entries() ];
entries.sort((a, b): number => compareFunction(a[1], b[1], a[0], b[0]));
// Perform clean-up
super.clear();
// Set the new entries
for (const [ key, value ] of entries)
{
super.set(key, value);
}
return this;
}
public toJSON ()
{
// toJSON is called recursively by JSON.stringify.
return [ ...this.entries() ];
}
private static defaultSort<Value> (firstValue: Value, secondValue: Value): number
{
return Number(firstValue > secondValue) || Number(firstValue === secondValue) - 1;
}
}

415
src/utilities/Util.ts Normal file
View File

@ -0,0 +1,415 @@
import path from 'node:path';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { PlainError } from '../../@types/Shared.js';
export type StringIndexable<T = unknown> = { [key: string]: T };
const Constants: {
QuotePairs: StringIndexable<string>
} = {
QuotePairs: {
'"': '"', // regular double
'\'': '\'', // regular single
'': '', // smart single
'“': '”' // smart double
}
};
const StartQuotes = Object.keys(Constants.QuotePairs);
const QuoteMarks = StartQuotes.concat(Object.values(Constants.QuotePairs));
class Util
{
constructor ()
{
throw new Error('Class may not be instantiated.');
}
static has (o: unknown, k: string)
{
return Object.prototype.hasOwnProperty.call(o, k);
}
/**
* Capitalise the first letter of the string
*/
static capitalise (str: string)
{
const first = str[0].toUpperCase();
return `${first}${str.substring(1)}`;
}
static pascalConverter (item: string)
{
return item.split('_').map((x) => Util.capitalise(x.toLowerCase())).join('');
}
static createUUID ()
{
return randomUUID();
}
/**
* Returns a random integer in [min, max]
* If max is omitted, min will shifted to max and min set to 0
*
* @static
* @param min
* @param max
* @memberof Util
*/
static random (min: number, max?: number)
{
if (typeof max === 'undefined')
{
max = min;
min = 0;
}
return Math.floor(Math.random() * (max - min + 1) + min);
}
/**
* Fatally throw an error. Sends a signal to the parent process not to restart the process
*
* @static
* @param {Error} error Error to throw
* @throws {Error}
* @memberof Util
*/
static fatal (error: Error | string): never
{
if (typeof error === 'string')
error = new Error(error);
if (process.send)
process.send({ _fatal: true, error: Util.makePlainError(error) });
throw error;
}
static paginate (items: Array<unknown>, page = 1, pageLength = 10)
{
const maxPage = Math.ceil(items.length / pageLength);
if (page < 1)
page = 1;
if (page > maxPage)
page = maxPage;
const startIndex = (page - 1) * pageLength;
return {
items: items.length > pageLength ? items.slice(startIndex, startIndex + pageLength) : items,
page,
maxPage,
pageLength
};
}
static downloadAsBuffer (source: string): Promise<ArrayBuffer>
{
return new Promise((resolve, reject) =>
{
fetch(source).then((res) =>
{
if (res.ok)
resolve(res.arrayBuffer());
else
reject(res.statusText);
});
});
}
/**
* Read directory recursively and return all file paths
* @static
* @param {string} directory Full path to target directory
* @param {boolean} [ignoreDotfiles=true]
* @return {string[]} Array with the paths to the files within the directory
* @memberof Util
*/
static readdirRecursive (dir: string, ignoreDotfiles: boolean = true): string[]
{
const result = [];
(function read (directory: string)
{
const files = fs.readdirSync(directory);
for (const file of files)
{
if (file.startsWith('.') && ignoreDotfiles)
continue;
const filePath = path.join(directory, file);
if (fs.statSync(filePath).isDirectory())
{
read(filePath);
}
else
{
result.push(filePath);
}
}
}(dir));
return result;
}
static wait (ms: number)
{
return this.delayFor(ms);
}
static delayFor (ms: number)
{
return new Promise((resolve) =>
{
setTimeout(resolve, ms);
});
}
/**
* Markdown formatting characters
*/
static get formattingPatterns ()
{
return [
[ '\\|{2}(.*)\\|{2}', '$1' ],
[ '\\*{1,3}([^*]*)\\*{1,3}', '$1' ],
[ '_{1,3}([^_]*)_{1,3}', '$1' ],
[ '`{1,3}([^`]*)`{1,3}', '$1' ],
[ '~~([^~])~~', '$1' ]
];
}
/**
* Strips markdown from given text
* @static
* @param {string} content
* @returns {string}
*/
static removeMarkdown (content: string): string
{
if (!content)
throw new Error('Missing content');
this.formattingPatterns.forEach(([ pattern, replacer ]) =>
{
const reg = new RegExp(pattern, 'gu');
while (reg.test(content))
content = content.replace(reg, replacer);
});
return content.trim();
}
/**
* Sanitise user given regex; escapes unauthorised characters
* @static
* @param {string} input
* @param {string[]} [allowed=['?', '\\', '(', ')', '|']]
* @return {string} The sanitised expression
* @memberof Util
*/
static sanitiseRegex (input: string, allowed: string[] = [ '?', '\\', '(', ')', '|', '.', '\\[', '\\]', '-' ]): string
{
if (!input)
throw new Error('Missing input');
const reg = new RegExp(`[${this.regChars.filter((char) => !allowed.includes(char)).join('')}]`, 'gu');
return input.replace(reg, '\\$&');
}
/**
* RegEx characters
*/
static get regChars ()
{
return [ '.', '+', '*', '?', '\\[', '\\]', '^', '$', '(', ')', '{', '}', '|', '\\\\', '-' ];
}
/**
* Escape RegEx characters, prefix them with a backslash
* @param {string} string String representation of the regular expression
* @returns {string} Sanitised RegEx string
*/
static escapeRegex (string: string): string
{
if (typeof string !== 'string')
{
throw new Error('Invalid type sent to escapeRegex.');
}
return string
.replace(/[|\\{}()[\]^$+*?.]/gu, '\\$&')
.replace(/-/gu, '\\x2d');
}
static parseQuotes (string: string): [word: string, quoted: boolean][]
{
if (!string)
return [];
let quoted = false,
wordStart = true,
startQuote = '',
endQuote: boolean | string = false,
isQuote = false,
word = '';
const words: [string, boolean][] = [],
chars = string.split('');
chars.forEach((char: string) =>
{
if ((/\s/u).test(char))
{
if (endQuote)
{
quoted = false;
endQuote = false;
isQuote = true;
}
if (quoted)
{
word += char;
}
else if (word !== '')
{
words.push([ word, isQuote ]);
isQuote = false;
startQuote = '';
word = '';
wordStart = true;
}
}
else if (QuoteMarks.includes(char))
{
if (endQuote)
{
word += endQuote;
endQuote = false;
}
if (quoted)
{
if (char === Constants.QuotePairs[startQuote])
{
endQuote = char;
}
else
{
word += char;
}
}
else if (wordStart && StartQuotes.includes(char))
{
quoted = true;
startQuote = char;
}
else
{
word += char;
}
}
else
{
if (endQuote)
{
word += endQuote;
endQuote = false;
}
word += char;
wordStart = false;
}
});
if (endQuote)
{
words.push([ word, true ]);
}
else
{
word.split(/\s/u).forEach((subWord, i) =>
{
if (i === 0)
{
words.push([ startQuote + subWord, false ]);
}
else
{
words.push([ subWord, false ]);
}
});
}
return words;
}
static plural (amt: number, word: string)
{
if (amt === 1)
return word;
return `${word}s`;
}
static makePlainError (err: Error): PlainError
{
return {
name: err.name,
message: err.message,
stack: err.stack
};
}
static makeError (obj: PlainError)
{
const err = new Error(obj.message);
err.name = obj.name;
err.stack = obj.stack;
return err;
}
static mergeDefault (def: StringIndexable, given: StringIndexable)
{
if (!given)
return def;
for (const key in def)
{
if (!Util.has(given, key) || typeof given[key] === 'undefined')
{
given[key] = def[key];
}
else if (given[key] === Object(given[key]))
{
given[key] = Util.mergeDefault(def[key] as StringIndexable, given[key] as StringIndexable);
}
}
return given;
}
static getEnumValue<T = number> (obj: object, key: string): T | null
{
const entries = Object.entries(obj);
for (const entry of entries)
if (entry[0] === key)
return entry[1];
return null;
}
/**
* Shuffles array in place
* @date 3/25/2024 - 11:25:09 AM
*
* @static
* @template T
* @param {T[]} array
* @returns {T[]}
*/
static shuffle<T> (array: T[]): T[]
{
let current = array.length;
let random = 0;
while (current > 0)
{
random = Math.floor(Math.random() * current);
current--;
[ array[current], array[random] ] = [ array[random], array[current] ];
}
return array;
}
}
export default Util;

118
tsconfig.json Normal file
View File

@ -0,0 +1,118 @@
{
"include": [
"src/**/*", "@types/*", "index.ts"
],
"compilerOptions": {
// "watch": true,
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ES2022"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "module": "AMD",
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": [
"./node_modules/@types",
"./@types/**"
], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": false, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
"inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./build/out-esm.js", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
"removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
"stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
"strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"compileOnSave": true
}

3152
yarn.lock Normal file

File diff suppressed because it is too large Load Diff