buncord-hybrid-sharding 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +2 -0
- package/LICENSE +28 -0
- package/README.md +91 -0
- package/bun.lock +88 -0
- package/package.json +39 -0
- package/src/Core/Cluster.ts +414 -0
- package/src/Core/ClusterClient.ts +329 -0
- package/src/Core/ClusterManager.ts +545 -0
- package/src/Core/DashboardServer.ts +84 -0
- package/src/Plugins/AutoResharderSystem.ts +382 -0
- package/src/Plugins/HeartbeatSystem.ts +56 -0
- package/src/Plugins/QueueManager.ts +49 -0
- package/src/Plugins/ReCluster.ts +101 -0
- package/src/Structures/Child.ts +109 -0
- package/src/Structures/Data.ts +33 -0
- package/src/Structures/IPCHandler.ts +154 -0
- package/src/Structures/IPCMessage.ts +101 -0
- package/src/Structures/ManagerHooks.ts +9 -0
- package/src/Structures/PromiseHandler.ts +63 -0
- package/src/Structures/Queue.ts +84 -0
- package/src/Util/RedisClient.ts +77 -0
- package/src/Util/Util.ts +62 -0
- package/src/index.ts +17 -0
- package/src/types/shared.ts +164 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { AutoResharderManager } from "../Plugins/AutoResharderSystem.js";
|
|
7
|
+
import { HeartbeatManager } from "../Plugins/HeartbeatSystem.js";
|
|
8
|
+
import { ReClusterManager } from "../Plugins/ReCluster.js";
|
|
9
|
+
import { ChildProcessOptions } from "../Structures/Child.js";
|
|
10
|
+
import { BaseMessage } from "../Structures/IPCMessage.js";
|
|
11
|
+
import { ClusterManagerHooks } from "../Structures/ManagerHooks.js";
|
|
12
|
+
import { PromiseHandler } from "../Structures/PromiseHandler.js";
|
|
13
|
+
import { Queue } from "../Structures/Queue.js";
|
|
14
|
+
import {
|
|
15
|
+
Awaitable, ClusterManagerEvents, ClusterManagerOptions, ClusterManagerSpawnOptions,
|
|
16
|
+
ClusterRestartOptions, DjsDiscordClient, evalOptions, Plugin, QueueOptions, Serialized
|
|
17
|
+
} from "../types/shared.js";
|
|
18
|
+
import {
|
|
19
|
+
chunkArray, delayFor, fetchRecommendedShards, makePlainError, shardIdForGuildId
|
|
20
|
+
} from "../Util/Util.js";
|
|
21
|
+
import { Cluster } from "./Cluster.js";
|
|
22
|
+
|
|
23
|
+
export class ClusterManager extends EventEmitter {
|
|
24
|
+
/**
|
|
25
|
+
* Whether clusters should automatically respawn upon exiting
|
|
26
|
+
*/
|
|
27
|
+
respawn: boolean;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How many times a cluster can maximally restart in the given interval
|
|
31
|
+
*/
|
|
32
|
+
restarts: ClusterRestartOptions;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Data, which is passed to the processEnv
|
|
36
|
+
*/
|
|
37
|
+
clusterData: object;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options, which is passed when forking a child
|
|
41
|
+
*/
|
|
42
|
+
clusterOptions: ChildProcessOptions | {};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Path to the bot script file
|
|
46
|
+
*/
|
|
47
|
+
file: string;
|
|
48
|
+
/**
|
|
49
|
+
* Amount of internal shards in total
|
|
50
|
+
*/
|
|
51
|
+
totalShards: number | -1;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Amount of total clusters to spawn
|
|
55
|
+
*/
|
|
56
|
+
totalClusters: number | -1;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Amount of Shards per Clusters
|
|
60
|
+
*/
|
|
61
|
+
shardsPerClusters: number | undefined;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An array of arguments to pass to clusters
|
|
65
|
+
*/
|
|
66
|
+
shardArgs: string[];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An array of arguments to pass to the executable
|
|
70
|
+
*/
|
|
71
|
+
execArgv: string[];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* List of internal shard ids this cluster manager spawns
|
|
75
|
+
*/
|
|
76
|
+
shardList: number[];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Token to use for obtaining the automatic internal shards count, and passing to bot script
|
|
80
|
+
*/
|
|
81
|
+
token: string | null;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A collection of all clusters the manager spawned
|
|
85
|
+
*/
|
|
86
|
+
clusters: Map<number, Cluster>;
|
|
87
|
+
shardClusterList: number[][];
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* An Array of IDS[Number], which should be assigned to the spawned Clusters
|
|
91
|
+
*/
|
|
92
|
+
clusterList: number[];
|
|
93
|
+
spawnOptions: ClusterManagerSpawnOptions;
|
|
94
|
+
queue: Queue;
|
|
95
|
+
promise: PromiseHandler;
|
|
96
|
+
|
|
97
|
+
/** HeartbeatManager Plugin */
|
|
98
|
+
heartbeat?: HeartbeatManager;
|
|
99
|
+
/** Reclustering Plugin */
|
|
100
|
+
recluster?: ReClusterManager;
|
|
101
|
+
/** AutoResharder Plugin */
|
|
102
|
+
autoresharder?: AutoResharderManager;
|
|
103
|
+
/** Containing some useful hook funtions */
|
|
104
|
+
hooks: ClusterManagerHooks;
|
|
105
|
+
constructor(file: string, options: ClusterManagerOptions) {
|
|
106
|
+
super();
|
|
107
|
+
if (!options) options = {};
|
|
108
|
+
|
|
109
|
+
this.respawn = options.respawn ?? true;
|
|
110
|
+
|
|
111
|
+
this.restarts = options.restarts || { max: 3, interval: 60000 * 60, current: 0 };
|
|
112
|
+
|
|
113
|
+
this.clusterData = options.clusterData || {};
|
|
114
|
+
|
|
115
|
+
this.clusterOptions = options.clusterOptions || {};
|
|
116
|
+
|
|
117
|
+
this.file = file;
|
|
118
|
+
if (!file) throw new Error('CLIENT_INVALID_OPTION | No File specified.');
|
|
119
|
+
if (!path.isAbsolute(file)) this.file = path.resolve(process.cwd(), file);
|
|
120
|
+
const stats = fs.statSync(this.file);
|
|
121
|
+
if (!stats.isFile()) throw new Error('CLIENT_INVALID_OPTION | Provided is file is not type of file');
|
|
122
|
+
|
|
123
|
+
this.totalShards = options.totalShards === 'auto' ? -1 : (options.totalShards ?? -1);
|
|
124
|
+
if (this.totalShards !== -1) {
|
|
125
|
+
if (typeof this.totalShards !== 'number' || isNaN(this.totalShards)) {
|
|
126
|
+
throw new TypeError('CLIENT_INVALID_OPTION | Amount of internal shards must be a number.');
|
|
127
|
+
}
|
|
128
|
+
if (this.totalShards < 1)
|
|
129
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of internal shards must be at least 1.');
|
|
130
|
+
if (!Number.isInteger(this.totalShards)) {
|
|
131
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of internal shards must be an integer.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
this.totalClusters = options.totalClusters === 'auto' ? -1 : (options.totalClusters ?? -1);
|
|
136
|
+
if (this.totalClusters !== -1) {
|
|
137
|
+
if (typeof this.totalClusters !== 'number' || isNaN(this.totalClusters)) {
|
|
138
|
+
throw new TypeError('CLIENT_INVALID_OPTION | Amount of Clusters must be a number.');
|
|
139
|
+
}
|
|
140
|
+
if (this.totalClusters < 1)
|
|
141
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be at least 1.');
|
|
142
|
+
if (!Number.isInteger(this.totalClusters)) {
|
|
143
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be an integer.');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.shardsPerClusters = options.shardsPerClusters;
|
|
148
|
+
if (this.shardsPerClusters) {
|
|
149
|
+
if (typeof this.shardsPerClusters !== 'number' || isNaN(this.shardsPerClusters)) {
|
|
150
|
+
throw new TypeError('CLIENT_INVALID_OPTION | Amount of ShardsPerClusters must be a number.');
|
|
151
|
+
}
|
|
152
|
+
if (this.shardsPerClusters < 1)
|
|
153
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of shardsPerClusters must be at least 1.');
|
|
154
|
+
if (!Number.isInteger(this.shardsPerClusters)) {
|
|
155
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Shards Per Clusters must be an integer.');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.shardArgs = options.shardArgs ?? [];
|
|
160
|
+
|
|
161
|
+
this.execArgv = options.execArgv ?? [];
|
|
162
|
+
|
|
163
|
+
this.shardList = options.shardList ?? [];
|
|
164
|
+
if (this.shardList.length) {
|
|
165
|
+
if (!Array.isArray(this.shardList)) {
|
|
166
|
+
throw new TypeError('CLIENT_INVALID_OPTION | shardList must be an array.');
|
|
167
|
+
}
|
|
168
|
+
this.shardList = Array.from(new Set(this.shardList));
|
|
169
|
+
if (this.shardList.length < 1)
|
|
170
|
+
throw new RangeError('CLIENT_INVALID_OPTION | shardList must contain at least 1 ID.');
|
|
171
|
+
if (
|
|
172
|
+
this.shardList.some(
|
|
173
|
+
shardID =>
|
|
174
|
+
typeof shardID !== 'number' || isNaN(shardID) || !Number.isInteger(shardID) || shardID < 0,
|
|
175
|
+
)
|
|
176
|
+
) {
|
|
177
|
+
throw new TypeError('CLIENT_INVALID_OPTION | shardList has to contain an array of positive integers.');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!options.token) options.token = process.env.DISCORD_TOKEN;
|
|
182
|
+
|
|
183
|
+
this.token = options.token ? options.token.replace(/^Bot\s*/i, '') : null;
|
|
184
|
+
|
|
185
|
+
this.clusters = new Map();
|
|
186
|
+
this.shardClusterList = [];
|
|
187
|
+
process.env.SHARD_LIST = undefined;
|
|
188
|
+
process.env.TOTAL_SHARDS = this.totalShards as any;
|
|
189
|
+
process.env.CLUSTER = undefined;
|
|
190
|
+
process.env.CLUSTER_COUNT = this.totalClusters as any;
|
|
191
|
+
process.env.CLUSTER_MANAGER = 'true';
|
|
192
|
+
process.env.CLUSTER_MANAGER_MODE = 'process';
|
|
193
|
+
process.env.DISCORD_TOKEN = String(this.token);
|
|
194
|
+
process.env.MAINTENANCE = undefined;
|
|
195
|
+
|
|
196
|
+
if (options.queue?.auto) process.env.CLUSTER_QUEUE_MODE = 'auto';
|
|
197
|
+
else process.env.CLUSTER_QUEUE_MODE = 'manual';
|
|
198
|
+
|
|
199
|
+
this.clusterList = options.clusterList || [];
|
|
200
|
+
|
|
201
|
+
this.spawnOptions = options.spawnOptions || {};
|
|
202
|
+
if (!this.spawnOptions.delay) this.spawnOptions.delay = 7000;
|
|
203
|
+
if (!this.spawnOptions.amount) this.spawnOptions.amount = this.totalShards;
|
|
204
|
+
if (!this.spawnOptions.timeout) this.spawnOptions.timeout = -1;
|
|
205
|
+
|
|
206
|
+
if (!options.queue) options.queue = { auto: true };
|
|
207
|
+
if (!options.queue.timeout) options.queue.timeout = this.spawnOptions.delay;
|
|
208
|
+
this.queue = new Queue(options.queue as Required<QueueOptions>);
|
|
209
|
+
|
|
210
|
+
this._debug(`[START] Cluster Manager has been initialized (Bun-Native)`);
|
|
211
|
+
|
|
212
|
+
this.promise = new PromiseHandler();
|
|
213
|
+
|
|
214
|
+
this.hooks = new ClusterManagerHooks();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Spawns multiple internal shards.
|
|
219
|
+
*/
|
|
220
|
+
public async spawn({
|
|
221
|
+
amount = this.spawnOptions.amount = this.totalShards,
|
|
222
|
+
delay = this.spawnOptions.delay = 7000,
|
|
223
|
+
timeout = this.spawnOptions.timeout = -1
|
|
224
|
+
} = this.spawnOptions
|
|
225
|
+
) {
|
|
226
|
+
if (delay < 7000) {
|
|
227
|
+
process.emitWarning(
|
|
228
|
+
`Spawn Delay (delay: ${delay}) is smaller than 7s, this can cause global rate limits on /gateway/bot`,
|
|
229
|
+
{
|
|
230
|
+
code: 'CLUSTER_MANAGER',
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (amount === -1 || amount === 'auto') {
|
|
236
|
+
if (!this.token) throw new Error('A Token must be provided, when totalShards is set on auto.');
|
|
237
|
+
amount = await fetchRecommendedShards(this.token, 1000);
|
|
238
|
+
this.totalShards = amount as number;
|
|
239
|
+
this._debug(`Discord recommended a total shard count of ${amount}`);
|
|
240
|
+
} else {
|
|
241
|
+
if (typeof amount !== 'number' || isNaN(amount)) {
|
|
242
|
+
throw new TypeError('CLIENT_INVALID_OPTION | Amount of Internal Shards must be a number.');
|
|
243
|
+
}
|
|
244
|
+
if (amount < 1)
|
|
245
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Internal Shards must be at least 1.');
|
|
246
|
+
if (!Number.isInteger(amount)) {
|
|
247
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Internal Shards must be an integer.');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
let clusterAmount = this.totalClusters;
|
|
251
|
+
if (clusterAmount === -1) {
|
|
252
|
+
clusterAmount = os.cpus().length;
|
|
253
|
+
this.totalClusters = clusterAmount;
|
|
254
|
+
} else {
|
|
255
|
+
if (typeof clusterAmount !== 'number' || isNaN(clusterAmount)) {
|
|
256
|
+
throw new TypeError('CLIENT_INVALID_OPTION | Amount of Clusters must be a number.');
|
|
257
|
+
}
|
|
258
|
+
if (clusterAmount < 1)
|
|
259
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be at least 1.');
|
|
260
|
+
if (!Number.isInteger(clusterAmount)) {
|
|
261
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Amount of Clusters must be an integer.');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!this.shardList.length) this.shardList = Array.from(Array(amount).keys());
|
|
266
|
+
|
|
267
|
+
//Calculate Shards per Cluster:
|
|
268
|
+
if (this.shardsPerClusters) this.totalClusters = Math.ceil(this.shardList.length / this.shardsPerClusters);
|
|
269
|
+
|
|
270
|
+
this.shardClusterList = chunkArray(
|
|
271
|
+
this.shardList,
|
|
272
|
+
!isNaN(this.shardsPerClusters as any)
|
|
273
|
+
? (this.shardsPerClusters as number)
|
|
274
|
+
: Math.ceil(this.shardList.length / (this.totalClusters as number)),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
if (this.shardClusterList.length !== this.totalClusters) {
|
|
278
|
+
this.totalClusters = this.shardClusterList.length;
|
|
279
|
+
}
|
|
280
|
+
if (this.shardList.some(shardID => shardID >= Number(amount))) {
|
|
281
|
+
throw new RangeError('CLIENT_INVALID_OPTION | Shard IDs must be smaller than the amount of shards.');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Update spawn options
|
|
285
|
+
this.spawnOptions = { delay, timeout, amount };
|
|
286
|
+
|
|
287
|
+
this._debug(`[Spawning Clusters]
|
|
288
|
+
ClusterCount: ${this.totalClusters}
|
|
289
|
+
ShardCount: ${amount}
|
|
290
|
+
ShardList: ${this.shardClusterList.join(', ')}`);
|
|
291
|
+
for (let i = 0; i < this.totalClusters; i++) {
|
|
292
|
+
const clusterId = this.clusterList[i] || i;
|
|
293
|
+
if (this.shardClusterList[i]) {
|
|
294
|
+
const length = this.shardClusterList[i]?.length as number;
|
|
295
|
+
const readyTimeout = timeout !== -1 ? timeout + delay * length : timeout;
|
|
296
|
+
const spawnDelay = delay * length;
|
|
297
|
+
this.queue.add({
|
|
298
|
+
run: (...a) => {
|
|
299
|
+
const cluster = this.createCluster(
|
|
300
|
+
clusterId,
|
|
301
|
+
this.shardClusterList[i] as number[],
|
|
302
|
+
this.totalShards,
|
|
303
|
+
);
|
|
304
|
+
return cluster.spawn(...a);
|
|
305
|
+
},
|
|
306
|
+
args: [readyTimeout],
|
|
307
|
+
timeout: spawnDelay,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return this.queue.start();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Sends a message to all clusters.
|
|
316
|
+
*/
|
|
317
|
+
public broadcast(message: BaseMessage) {
|
|
318
|
+
const promises = [];
|
|
319
|
+
for (const cluster of Array.from(this.clusters.values())) promises.push(cluster.send(message));
|
|
320
|
+
return Promise.all(promises);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Creates a single cluster.
|
|
324
|
+
* <warn>Using this method is usually not necessary if you use the spawn method.</warn>
|
|
325
|
+
* <info>This is usually not necessary to manually specify.</info>
|
|
326
|
+
* @returns Note that the created cluster needs to be explicitly spawned using its spawn method.
|
|
327
|
+
*/
|
|
328
|
+
public createCluster(id: number, shardsToSpawn: number[], totalShards: number, recluster = false) {
|
|
329
|
+
const cluster = new Cluster(this, id, shardsToSpawn, totalShards);
|
|
330
|
+
if (!recluster) this.clusters.set(id, cluster);
|
|
331
|
+
this.emit('clusterCreate', cluster);
|
|
332
|
+
|
|
333
|
+
this._debug(`[CREATE] Created Cluster ${cluster.id}`);
|
|
334
|
+
return cluster;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Evaluates a script on all clusters, or a given cluster, in the context of the {@link DjsDiscordClient}s.
|
|
338
|
+
* @returns Results of the script execution
|
|
339
|
+
*/
|
|
340
|
+
public broadcastEval(script: string): Promise<any[]>;
|
|
341
|
+
public broadcastEval(script: string, options?: evalOptions): Promise<any>;
|
|
342
|
+
public broadcastEval<T>(fn: (client: DjsDiscordClient) => Awaitable<T>): Promise<Serialized<T>[]>;
|
|
343
|
+
public broadcastEval<T>(
|
|
344
|
+
fn: (client: DjsDiscordClient) => Awaitable<T>,
|
|
345
|
+
options?: { cluster?: number; timeout?: number },
|
|
346
|
+
): Promise<Serialized<T>>;
|
|
347
|
+
public broadcastEval<T, P>(
|
|
348
|
+
fn: (client: DjsDiscordClient, context: Serialized<P>) => Awaitable<T>,
|
|
349
|
+
options?: evalOptions<P>,
|
|
350
|
+
): Promise<Serialized<T>[]>;
|
|
351
|
+
public broadcastEval<T, P>(
|
|
352
|
+
fn: (client: DjsDiscordClient, context: Serialized<P>) => Awaitable<T>,
|
|
353
|
+
options?: evalOptions<P>,
|
|
354
|
+
): Promise<Serialized<T>>;
|
|
355
|
+
public async broadcastEval<T, P>(
|
|
356
|
+
script: string | ((client: DjsDiscordClient, context?: Serialized<P>) => Awaitable<T> | Promise<Serialized<T>>),
|
|
357
|
+
evalOptions?: evalOptions<| evalOptions<P>>,
|
|
358
|
+
) {
|
|
359
|
+
const options = (evalOptions as any) ?? {};
|
|
360
|
+
if (!script || (typeof script !== 'string' && typeof script !== 'function'))
|
|
361
|
+
return Promise.reject(new TypeError('ClUSTERING_INVALID_EVAL_BROADCAST'));
|
|
362
|
+
script = typeof script === 'function' ? `(${script})(this, ${JSON.stringify(options.context)})` : script;
|
|
363
|
+
|
|
364
|
+
if (Object.prototype.hasOwnProperty.call(options, 'cluster')) {
|
|
365
|
+
if (typeof options.cluster === 'number') {
|
|
366
|
+
if (options.cluster < 0) throw new RangeError('CLUSTER_ID_OUT_OF_RANGE');
|
|
367
|
+
}
|
|
368
|
+
if (Array.isArray(options.cluster)) {
|
|
369
|
+
if (options.cluster.length === 0) throw new RangeError('ARRAY_MUST_CONTAIN_ONE CLUSTER_ID');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (options.guildId) {
|
|
373
|
+
options.shard = shardIdForGuildId(options.guildId, this.totalShards);
|
|
374
|
+
}
|
|
375
|
+
if (options.shard !== undefined && options.shard !== null) {
|
|
376
|
+
if (typeof options.shard === 'number') {
|
|
377
|
+
if (options.shard < 0) throw new RangeError('SHARD_ID_OUT_OF_RANGE');
|
|
378
|
+
}
|
|
379
|
+
if (Array.isArray(options.shard)) {
|
|
380
|
+
if (options.shard.length === 0) throw new RangeError('ARRAY_MUST_CONTAIN_ONE SHARD_ID');
|
|
381
|
+
}
|
|
382
|
+
options.cluster = Array.from(this.clusters.values()).find(c =>
|
|
383
|
+
c.shardList.includes(options.shard as number),
|
|
384
|
+
)?.id;
|
|
385
|
+
}
|
|
386
|
+
return this._performOnClusters('eval', [script], options.cluster, options.timeout);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Fetches a client property value of each cluster, or a given cluster.
|
|
390
|
+
* @param prop Name of the client property to get, using periods for nesting
|
|
391
|
+
* @param cluster Cluster to fetch property from, all if undefined
|
|
392
|
+
*/
|
|
393
|
+
public fetchClientValues(prop: string, cluster?: number) {
|
|
394
|
+
return this.broadcastEval(`this.${prop}`, { cluster });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Runs a method with given arguments on all clusters, or a given cluster.
|
|
399
|
+
* @param method Method name to run on each cluster
|
|
400
|
+
* @param args Arguments to pass through to the method call
|
|
401
|
+
* @param cluster cluster to run on, all if undefined
|
|
402
|
+
* @param timeout the amount of time to wait until the promise will be rejected
|
|
403
|
+
* @returns Results of the method execution
|
|
404
|
+
* @private
|
|
405
|
+
*/
|
|
406
|
+
private _performOnClusters(method: 'eval', args: any[], cluster?: number | number[], timeout?: number) {
|
|
407
|
+
if (this.clusters.size === 0) return Promise.reject(new Error('CLUSTERING_NO_CLUSTERS'));
|
|
408
|
+
|
|
409
|
+
if (typeof cluster === 'number') {
|
|
410
|
+
if (this.clusters.has(cluster))
|
|
411
|
+
return (
|
|
412
|
+
this.clusters
|
|
413
|
+
.get(cluster)
|
|
414
|
+
// @ts-expect-error
|
|
415
|
+
?.[method](...args, undefined, timeout)
|
|
416
|
+
.then((e: any) => [e])
|
|
417
|
+
);
|
|
418
|
+
return Promise.reject(new Error('CLUSTERING_CLUSTER_NOT_FOUND FOR ClusterId: ' + cluster));
|
|
419
|
+
}
|
|
420
|
+
let clusters = Array.from(this.clusters.values());
|
|
421
|
+
if (cluster) clusters = clusters.filter(c => cluster.includes(c.id));
|
|
422
|
+
if (clusters.length === 0) return Promise.reject(new Error('CLUSTERING_NO_CLUSTERS_FOUND'));
|
|
423
|
+
|
|
424
|
+
const promises = [];
|
|
425
|
+
|
|
426
|
+
// @ts-expect-error
|
|
427
|
+
for (const cl of clusters) promises.push(cl[method](...args, undefined, timeout));
|
|
428
|
+
return Promise.all(promises);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Kills all running clusters and respawns them.
|
|
433
|
+
* @param options Options for respawning shards
|
|
434
|
+
*/
|
|
435
|
+
public async respawnAll({
|
|
436
|
+
clusterDelay = (this.spawnOptions.delay = 5500),
|
|
437
|
+
respawnDelay = (this.spawnOptions.delay = 5500),
|
|
438
|
+
timeout = -1,
|
|
439
|
+
} = {}) {
|
|
440
|
+
this.promise.nonce.clear();
|
|
441
|
+
let s = 0;
|
|
442
|
+
let i = 0;
|
|
443
|
+
this._debug('Respawning all Clusters');
|
|
444
|
+
for (const cluster of Array.from(this.clusters.values())) {
|
|
445
|
+
const promises: any[] = [cluster.respawn({ delay: respawnDelay, timeout })];
|
|
446
|
+
const length = this.shardClusterList[i]?.length || this.totalShards / this.totalClusters;
|
|
447
|
+
if (++s < this.clusters.size && clusterDelay > 0) promises.push(delayFor(length * clusterDelay));
|
|
448
|
+
i++;
|
|
449
|
+
await Promise.all(promises); // eslint-disable-line no-await-in-loop
|
|
450
|
+
}
|
|
451
|
+
return this.clusters;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Runs a method with given arguments on the Manager itself
|
|
456
|
+
*/
|
|
457
|
+
public async evalOnManager(script: string) {
|
|
458
|
+
script = typeof script === 'function' ? `(${script})(this)` : script;
|
|
459
|
+
let result;
|
|
460
|
+
let error;
|
|
461
|
+
try {
|
|
462
|
+
result = await eval(script);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
error = err;
|
|
465
|
+
}
|
|
466
|
+
return { _result: result, _error: error ? makePlainError(error) : null };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Runs a method with given arguments on the provided Cluster Client
|
|
471
|
+
* @returns Results of the script execution
|
|
472
|
+
* @private
|
|
473
|
+
*/
|
|
474
|
+
public evalOnCluster(script: string, options: evalOptions) {
|
|
475
|
+
return this.broadcastEval(script, options)?.then((r: any[]) => r[0]);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Adds a plugin to the cluster manager
|
|
480
|
+
*/
|
|
481
|
+
public extend(...plugins: Plugin[]) {
|
|
482
|
+
if (!plugins) throw new Error('NO_PLUGINS_PROVIDED');
|
|
483
|
+
if (!Array.isArray(plugins)) plugins = [plugins];
|
|
484
|
+
for (const plugin of plugins) {
|
|
485
|
+
if (!plugin) throw new Error('PLUGIN_NOT_PROVIDED');
|
|
486
|
+
if (typeof plugin !== 'object') throw new Error('PLUGIN_NOT_A_OBJECT');
|
|
487
|
+
plugin.build(this);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @param reason If maintenance should be enabled on all clusters with a given reason or disabled when nonce provided
|
|
493
|
+
*/
|
|
494
|
+
triggerMaintenance(reason: string) {
|
|
495
|
+
return Array.from(this.clusters.values()).forEach(cluster => cluster.triggerMaintenance(reason));
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Logs out the Debug Messages
|
|
499
|
+
*/
|
|
500
|
+
public _debug(message: string, cluster?: number) {
|
|
501
|
+
let log;
|
|
502
|
+
if (cluster === undefined) {
|
|
503
|
+
log = `[BM => Manager] ` + message;
|
|
504
|
+
} else {
|
|
505
|
+
log = `[BM => Cluster ${cluster}] ` + message;
|
|
506
|
+
}
|
|
507
|
+
this.emit('debug', log);
|
|
508
|
+
return log;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export interface ClusterManager {
|
|
513
|
+
emit: (<K extends keyof ClusterManagerEvents>(event: K, ...args: ClusterManagerEvents[K]) => boolean) &
|
|
514
|
+
(<S extends string | symbol>(event: Exclude<S, keyof ClusterManagerEvents>, ...args: any[]) => boolean);
|
|
515
|
+
|
|
516
|
+
off: (<K extends keyof ClusterManagerEvents>(
|
|
517
|
+
event: K,
|
|
518
|
+
listener: (...args: ClusterManagerEvents[K]) => void,
|
|
519
|
+
) => this) &
|
|
520
|
+
(<S extends string | symbol>(
|
|
521
|
+
event: Exclude<S, keyof ClusterManagerEvents>,
|
|
522
|
+
listener: (...args: any[]) => void,
|
|
523
|
+
) => this);
|
|
524
|
+
|
|
525
|
+
on: (<K extends keyof ClusterManagerEvents>(
|
|
526
|
+
event: K,
|
|
527
|
+
listener: (...args: ClusterManagerEvents[K]) => void,
|
|
528
|
+
) => this) &
|
|
529
|
+
(<S extends string | symbol>(
|
|
530
|
+
event: Exclude<S, keyof ClusterManagerEvents>,
|
|
531
|
+
listener: (...args: any[]) => void,
|
|
532
|
+
) => this);
|
|
533
|
+
|
|
534
|
+
once: (<K extends keyof ClusterManagerEvents>(
|
|
535
|
+
event: K,
|
|
536
|
+
listener: (...args: ClusterManagerEvents[K]) => void,
|
|
537
|
+
) => this) &
|
|
538
|
+
(<S extends string | symbol>(
|
|
539
|
+
event: Exclude<S, keyof ClusterManagerEvents>,
|
|
540
|
+
listener: (...args: any[]) => void,
|
|
541
|
+
) => this);
|
|
542
|
+
|
|
543
|
+
removeAllListeners: (<K extends keyof ClusterManagerEvents>(event?: K) => this) &
|
|
544
|
+
(<S extends string | symbol>(event?: Exclude<S, keyof ClusterManagerEvents>) => this);
|
|
545
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ClusterManager } from "./ClusterManager.js";
|
|
2
|
+
|
|
3
|
+
export interface DashboardOptions {
|
|
4
|
+
port?: number;
|
|
5
|
+
password?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class DashboardServer {
|
|
9
|
+
public manager: ClusterManager;
|
|
10
|
+
public port: number;
|
|
11
|
+
private password?: string;
|
|
12
|
+
|
|
13
|
+
constructor(manager: ClusterManager, options: DashboardOptions = {}) {
|
|
14
|
+
this.manager = manager;
|
|
15
|
+
this.port = options.port || 3001;
|
|
16
|
+
this.password = options.password;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public start() {
|
|
20
|
+
Bun.serve({
|
|
21
|
+
port: this.port,
|
|
22
|
+
fetch: async (req) => {
|
|
23
|
+
const url = new URL(req.url);
|
|
24
|
+
|
|
25
|
+
// Simple Auth
|
|
26
|
+
if (this.password && req.headers.get("Authorization") !== this.password) {
|
|
27
|
+
return new Response("Unauthorized", { status: 401 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Stats Endpoint
|
|
31
|
+
if (url.pathname === "/stats" && req.method === "GET") {
|
|
32
|
+
const stats = await this.getStats();
|
|
33
|
+
return Response.json(stats);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Restart Endpoint
|
|
37
|
+
if (url.pathname === "/restart" && req.method === "POST") {
|
|
38
|
+
const body = await req.json() as any;
|
|
39
|
+
const clusterId = body.clusterId;
|
|
40
|
+
|
|
41
|
+
if (clusterId !== undefined) {
|
|
42
|
+
const cluster = this.manager.clusters.get(clusterId);
|
|
43
|
+
if (!cluster) return new Response("Cluster not found", { status: 404 });
|
|
44
|
+
cluster.respawn();
|
|
45
|
+
return new Response(`Restarting Cluster ${clusterId}`);
|
|
46
|
+
} else {
|
|
47
|
+
this.manager.respawnAll();
|
|
48
|
+
return new Response("Restarting all clusters");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Maintenance Endpoint
|
|
53
|
+
if (url.pathname === "/maintenance" && req.method === "POST") {
|
|
54
|
+
const body = await req.json() as any;
|
|
55
|
+
const enable = body.enable;
|
|
56
|
+
const reason = body.reason || "Maintenance via API";
|
|
57
|
+
|
|
58
|
+
if (enable) {
|
|
59
|
+
this.manager.triggerMaintenance(reason);
|
|
60
|
+
} else {
|
|
61
|
+
// @ts-ignore
|
|
62
|
+
this.manager.triggerMaintenance();
|
|
63
|
+
}
|
|
64
|
+
return new Response(`Maintenance ${enable ? 'enabled' : 'disabled'}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return new Response("Not Found", { status: 404 });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
this.manager._debug(`[Dashboard] Server started on port ${this.port}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async getStats() {
|
|
75
|
+
const clusterStats = await this.manager.fetchClientValues('cluster.info');
|
|
76
|
+
// Simple aggregation for now
|
|
77
|
+
return {
|
|
78
|
+
totalClusters: this.manager.totalClusters,
|
|
79
|
+
activeClusters: this.manager.clusters.size,
|
|
80
|
+
memoryUsage: process.memoryUsage(),
|
|
81
|
+
clusters: clusterStats
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|