Files
This commit is contained in:
parent
6c4e307f27
commit
e47210deb1
328
.eslintrc.json
Normal file
328
.eslintrc.json
Normal 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
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
8
.yarnrc.yml
Normal 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
15
@types/Client.d.ts
vendored
Normal 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
28
@types/Controller.d.ts
vendored
Normal 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
12
@types/Shard.d.ts
vendored
Normal 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
44
@types/Shared.d.ts
vendored
Normal 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
33
index.ts
Normal 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
9
options.jsonc
Normal 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
26
package.json
Normal 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
147
src/client/Client.ts
Normal 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;
|
16
src/client/components/Intercom.ts
Normal file
16
src/client/components/Intercom.ts
Normal 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;
|
137
src/client/components/Registry.ts
Normal file
137
src/client/components/Registry.ts
Normal 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;
|
147
src/client/interfaces/Component.ts
Normal file
147
src/client/interfaces/Component.ts
Normal 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;
|
14
src/client/interfaces/Initialisable.ts
Normal file
14
src/client/interfaces/Initialisable.ts
Normal 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 };
|
270
src/middleware/Controller.ts
Normal file
270
src/middleware/Controller.ts
Normal 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
472
src/middleware/Shard.ts
Normal 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;
|
260
src/utilities/ExtendedMap.ts
Normal file
260
src/utilities/ExtendedMap.ts
Normal 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
415
src/utilities/Util.ts
Normal 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
118
tsconfig.json
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user