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.
@@ -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
+ }