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,382 @@
1
+ import {
2
+ arraysAreTheSame, ClusterClient, ClusterManager, DjsDiscordClient, fetchRecommendedShards,
3
+ messageType
4
+ } from "../index.js";
5
+
6
+ export interface AutoResharderSendData {
7
+ clusterId: number;
8
+ shardData: {
9
+ shardId: number;
10
+ guildCount: number;
11
+ }[];
12
+ }
13
+
14
+ interface sendDataMessage {
15
+ data: AutoResharderSendData;
16
+ _type: messageType;
17
+ }
18
+
19
+ interface AutoResharderClusterClientOptions {
20
+ /**
21
+ * How often to send the data (the faster the bot grows, the more often you should send the data)
22
+ * @default 60e3
23
+ */
24
+ sendDataIntervalMS: number;
25
+ /**
26
+ * Function to send the required Data for the AUTORESHARDING
27
+ * @param cluster
28
+ * @returns sendData can be either sync or async
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * sendDataFunction: (cluster:ClusterClient<DjsDiscordClient>) => {
33
+ * return {
34
+ * clusterId: cluster.id,
35
+ * shardData: cluster.info.SHARD_LIST.map(shardId => ({ shardId, guildCount: cluster.client.guilds.cache.filter(g => g.shardId === shardId).size }))
36
+ * }
37
+ * }
38
+ * ```
39
+ */
40
+ sendDataFunction: (
41
+ cluster: ClusterClient<DjsDiscordClient>,
42
+ ) => Promise<AutoResharderSendData> | AutoResharderSendData;
43
+ debug?: boolean;
44
+ }
45
+
46
+ interface AutoResharderManagerOptions {
47
+ /**
48
+ * How many shards to be set per cluster
49
+ */
50
+ ShardsPerCluster: number | 'useManagerOption';
51
+ /**
52
+ * This Number declares how many new shards should spawn.
53
+ * if set to 1500 it aims to create as many shards that on avg. per shard 1500 guilds are set
54
+ * If set to "auto" it uses the recommendation amount of discord-gateway.
55
+ * If set to "auto" then MaxGuildsPerShard must be at least 2000
56
+ * @default 1500
57
+ */
58
+ MinGuildsPerShard: 'auto' | number;
59
+ /** If this number is reached, autoresharding starts */
60
+ MaxGuildsPerShard: number;
61
+ /** Restart Options for reclustering */
62
+ restartOptions?: {
63
+ /** The restartMode of the clusterManager, gracefulSwitch = waits until all new clusters have spawned with maintenance mode, rolling = Once the Cluster is Ready, the old cluster will be killed */
64
+ restartMode?: 'gracefulSwitch' | 'rolling';
65
+ /** The delay to wait between each cluster spawn */
66
+ delay?: number;
67
+ /** The readyTimeout to wait until the cluster spawn promise is rejected */
68
+ timeout?: number;
69
+ };
70
+ debug?: boolean;
71
+ }
72
+
73
+ export class AutoResharderClusterClient {
74
+ private clusterClient: ClusterClient<DjsDiscordClient>;
75
+ /** The Options of the AutoResharderClusterClient */
76
+ private options: AutoResharderClusterClientOptions = {
77
+ sendDataIntervalMS: 60e3,
78
+ debug: false,
79
+ sendDataFunction: (cluster: ClusterClient<DjsDiscordClient>) => {
80
+ return {
81
+ clusterId: cluster.id,
82
+ shardData: cluster.info.SHARD_LIST.map(shardId => ({
83
+ shardId,
84
+ guildCount: cluster.client.guilds.cache.filter((g:{ shardId: number }) => g.shardId === shardId).size,
85
+ })),
86
+ };
87
+ },
88
+ };
89
+ /** The Stored Interval */
90
+ private interval: any | null = null;
91
+ /** Wether it is running or not */
92
+ private started = false;
93
+
94
+ /**
95
+ * The Cluster client and what it shold contain
96
+ * @param {ClusterClient<DjsDiscordClient>} clusterClient
97
+ * @param {Partial<AutoResharderClusterClientOptions>} [options] the Optional options
98
+ */
99
+ constructor(clusterClient: ClusterClient<DjsDiscordClient>, options?: Partial<AutoResharderClusterClientOptions>) {
100
+ this.clusterClient = clusterClient;
101
+ this.options = {
102
+ ...this.options,
103
+ ...options,
104
+ };
105
+ this.validate();
106
+ this.initialize();
107
+ }
108
+ private validate() {
109
+ if (
110
+ typeof this.clusterClient !== 'object' ||
111
+ typeof this.clusterClient.id !== 'number' ||
112
+ typeof this.clusterClient.info !== 'object' ||
113
+ !Array.isArray(this.clusterClient.info.SHARD_LIST) ||
114
+ typeof this.clusterClient.send !== 'function'
115
+ )
116
+ throw new SyntaxError(
117
+ 'clusterClient must be provided with a valid clusterId, send function and info.SHARD_LISt',
118
+ );
119
+ if (typeof this.options.sendDataIntervalMS !== 'number' || this.options.sendDataIntervalMS < 1000)
120
+ throw new SyntaxError('CLIENT_AutoResharderOptions.sendDataIntervalMS must be a number >= 1000');
121
+ if (typeof this.options.sendDataFunction !== 'function')
122
+ throw new SyntaxError(
123
+ 'CLIENT_AutoResharderOptions.sendDataFunction must be a function to return the sendData: { clusterId: number, shardData: { shardId: number; guildCount; number }[] }',
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Stops the Function and interval
129
+ * @returns
130
+ */
131
+ public stop() {
132
+ // clear the interval just to be sure
133
+ if (this.interval) {
134
+ clearInterval(this.interval);
135
+ this.interval = null;
136
+ }
137
+ if (this.started === false) throw new Error('Not running!');
138
+ return true;
139
+ }
140
+
141
+ /**
142
+ * Start it manually after you stopped it (on initialization it automatically starts the function)
143
+ * @param newOptions
144
+ * @param executeSendData Wether it should send the data immediately or as normal: after the interval is reached.
145
+ * @returns
146
+ */
147
+ public start(newOptions?: Partial<AutoResharderClusterClientOptions>, executeSendData = false) {
148
+ if (this.started === true) throw new Error('Already started');
149
+
150
+ // overide the options
151
+ this.options = {
152
+ ...this.options,
153
+ ...newOptions,
154
+ };
155
+
156
+ return this.initialize(executeSendData);
157
+ }
158
+
159
+ /**
160
+ * Restart the function and interval, if needed
161
+ * @param newOptions Optinally change the options to your new options
162
+ * @param executeSendData Wether it should send the data immediately or as normal: after the interval is reached.
163
+ * @returns
164
+ */
165
+ public reStart(newOptions?: Partial<AutoResharderClusterClientOptions>, executeSendData = false) {
166
+ // clear the interval just to be sure
167
+ if (this.interval) {
168
+ clearInterval(this.interval);
169
+ this.interval = null;
170
+ }
171
+
172
+ // overide the options
173
+ this.options = {
174
+ ...this.options,
175
+ ...newOptions,
176
+ };
177
+
178
+ return this.initialize(executeSendData);
179
+ }
180
+ /**
181
+ * Initializes the interval
182
+ * @param executeSendData Wether it should send the data immediately or as normal: after the interval is reached.
183
+ * @returns
184
+ */
185
+ private async initialize(executeSendData = false) {
186
+ // if interval exists
187
+ if (this.interval) clearInterval(this.interval);
188
+
189
+ if (executeSendData === true) {
190
+ await this.sendData();
191
+ }
192
+
193
+ this.interval = setInterval(() => this.sendData(), this.options.sendDataIntervalMS);
194
+
195
+ return true;
196
+ }
197
+
198
+ private async sendData() {
199
+ this.validate();
200
+
201
+ const sendData = await this.options.sendDataFunction(this.clusterClient);
202
+
203
+ if (
204
+ typeof sendData !== 'object' ||
205
+ typeof sendData.clusterId !== 'number' ||
206
+ sendData.clusterId < 0 ||
207
+ !Array.isArray(sendData.shardData) ||
208
+ sendData.shardData.some(
209
+ v =>
210
+ typeof v.guildCount !== 'number' ||
211
+ v.guildCount < 0 ||
212
+ typeof v.shardId !== 'number' ||
213
+ v.shardId < 0,
214
+ )
215
+ )
216
+ throw new SyntaxError(
217
+ 'Invalid sendData, must be like this: { clusterId: number, shardData: { shardId: number; guildCount; number }[] }',
218
+ );
219
+
220
+ if (this.options.debug === true)
221
+ console.debug(` CLIENT-AUTORESHARDER :: Sending Data for Cluster #${sendData.clusterId}`);
222
+
223
+ return await this.clusterClient.send({
224
+ _type: messageType.CLIENT_AUTORESHARDER_SENDDATA,
225
+ data: sendData,
226
+ } as sendDataMessage);
227
+ }
228
+ }
229
+
230
+ export class AutoResharderManager {
231
+ public name: string = 'autoresharder';
232
+ private manager?: ClusterManager;
233
+ // private clustersListening = new Set<number>();
234
+ public clusterDatas: AutoResharderSendData[] = [];
235
+ public options: AutoResharderManagerOptions = {
236
+ ShardsPerCluster: 'useManagerOption',
237
+ MinGuildsPerShard: 1500,
238
+ MaxGuildsPerShard: 2400,
239
+ restartOptions: {
240
+ restartMode: 'gracefulSwitch',
241
+ delay: 7e3,
242
+ timeout: -1,
243
+ },
244
+ debug: false,
245
+ };
246
+ public isReClustering = false;
247
+
248
+ constructor(options?: Partial<AutoResharderManagerOptions>) {
249
+ this.name = 'autoresharder';
250
+ this.options = {
251
+ ...this.options,
252
+ ...options ,
253
+ restartOptions: {
254
+ ...this.options.restartOptions,
255
+ ...options?.restartOptions,
256
+ },
257
+ };
258
+ }
259
+ build(manager: ClusterManager) {
260
+ // @ts-ignore
261
+ manager[this.name] = this;
262
+ this.manager = manager;
263
+
264
+ this.validate();
265
+ this.initialize();
266
+
267
+ return this;
268
+ }
269
+ public async checkReCluster() {
270
+ if (!this.manager) throw new Error('Manager is missing on AutoResharderManager');
271
+ // check for cross-hosting max cluster amount
272
+ if (this.clusterDatas.length <= this.manager.clusterList.length) {
273
+ if (this.options.debug === true)
274
+ console.debug('MANAGER-AUTORESHARDER :: Not all cluster data(s) reached yet');
275
+ return;
276
+ }
277
+
278
+ if (this.isReClustering === true) {
279
+ if (this.options.debug === true) console.debug('MANAGER-AUTORESHARDER :: Already re-sharding');
280
+ }
281
+
282
+ if (!arraysAreTheSame(Array.from(Array(this.manager.totalShards).keys()), this.manager.shardList)) {
283
+ throw new RangeError(
284
+ "It seems that you are using 'discord-cross-hosting' or a custom shardList specification. With either of those you can't run this plugin (yet)",
285
+ );
286
+ }
287
+
288
+ const reachedCluster = this.clusterDatas.find(v =>
289
+ v?.shardData.some(x => x && x.guildCount >= this.options.MaxGuildsPerShard),
290
+ );
291
+ if (reachedCluster) {
292
+ if (typeof this.manager.recluster === 'undefined')
293
+ throw new RangeError('ClusterManager must be extended with the ReCluster Plugin!');
294
+ this.isReClustering = true;
295
+
296
+ const newShardsCount =
297
+ this.options.MinGuildsPerShard === 'auto'
298
+ ? await fetchRecommendedShards(this.manager.token as string)
299
+ : Math.ceil(
300
+ this.clusterDatas
301
+ .flatMap(v => v?.shardData)
302
+ .filter(d => d !== undefined)
303
+ .reduce((a, b) => (!isNaN(b?.guildCount) ? b?.guildCount : 0) + (a || 0), 0) /
304
+ this.options.MinGuildsPerShard,
305
+ );
306
+ const realShardCount =
307
+ newShardsCount > this.manager.totalShards ? newShardsCount : Math.ceil(this.manager.totalShards * 1.2);
308
+ if (this.options.debug)
309
+ console.debug(
310
+ `MANAGER-AUTORESHARDER :: Reclustering from [${
311
+ this.manager.totalShards
312
+ } Shards] to [${realShardCount} Shards]`,
313
+ );
314
+
315
+ const finalShardsPerCluster =
316
+ this.options.ShardsPerCluster === 'useManagerOption'
317
+ ? this.manager.shardsPerClusters ||
318
+ Math.ceil(this.manager.shardList.length / this.manager.totalClusters)
319
+ : this.options.ShardsPerCluster;
320
+
321
+ const data = await this.manager.recluster.start({
322
+ ...this.options.restartOptions,
323
+ shardsPerClusters: finalShardsPerCluster,
324
+ totalShards: realShardCount,
325
+ totalClusters: Math.ceil(realShardCount / finalShardsPerCluster),
326
+ shardList: Array.from(Array(realShardCount).keys()),
327
+ });
328
+
329
+ this.isReClustering = false;
330
+ if (this.options.debug === true)
331
+ console.debug(
332
+ `MANAGER-AUTORESHARDER :: Finished Autoresharding`,
333
+ data,
334
+ );
335
+ }
336
+ }
337
+ public getData(clusterData: AutoResharderSendData) {
338
+ const index = this.clusterDatas.findIndex(v => v && v.clusterId === clusterData.clusterId);
339
+ if (index < 0) this.clusterDatas.push(clusterData);
340
+ else this.clusterDatas[index] = clusterData;
341
+
342
+ if (this.options.debug === true)
343
+ console.debug(
344
+ `MANAGER-AUTORESHARDER :: Reached sendData of Cluster #${clusterData.clusterId}`,
345
+ );
346
+ this.checkReCluster();
347
+ return;
348
+ }
349
+ private initialize() {
350
+ if (!this.manager) throw new Error('Manager is missing on AutoResharderManager');
351
+ }
352
+ private validate() {
353
+ if (!this.manager) throw new Error('Manager is missing on AutoResharderManager');
354
+ if (typeof this.options.ShardsPerCluster === 'string' && this.options.ShardsPerCluster !== 'useManagerOption')
355
+ throw new SyntaxError("AutoResharderManagerOptions.ShardsPerCluster must be 'useManagerOption' or a number >= 1",);
356
+ else if (this.options.ShardsPerCluster !== 'useManagerOption' && (typeof this.options.ShardsPerCluster !== 'number' || this.options.ShardsPerCluster < 1))
357
+ throw new SyntaxError("AutoResharderManagerOptions.ShardsPerCluster must be 'useManagerOption' or a number >= 1",);
358
+ if (typeof this.options.MinGuildsPerShard === 'string' && this.options.MinGuildsPerShard !== 'auto')
359
+ throw new SyntaxError("AutoResharderManagerOptions.MinGuildsPerShard must be 'auto' or a number >= 500");
360
+ else if (this.options.MinGuildsPerShard !== 'auto' && (typeof this.options.MinGuildsPerShard !== 'number' || this.options.MinGuildsPerShard < 500))
361
+ throw new SyntaxError("AutoResharderManagerOptions.MinGuildsPerShard must be 'auto' or a number >= 500");
362
+ if (
363
+ typeof this.options.MaxGuildsPerShard !== 'number' ||
364
+ (typeof this.options.MinGuildsPerShard === 'number' &&
365
+ this.options.MaxGuildsPerShard <= this.options.MinGuildsPerShard) ||
366
+ this.options.MaxGuildsPerShard > 2500
367
+ )
368
+ throw new SyntaxError(
369
+ 'AutoResharderManagerOptions.MinGuildsPerShard must be higher (if not auto) than AutoResharderManagerOptions.MaxGuildsPerShard and lower than 2500',
370
+ );
371
+ if (typeof this.manager.recluster === 'undefined')
372
+ throw new RangeError('ClusterManager must be extended with the ReCluster Plugin!');
373
+ if (
374
+ typeof this.options.MinGuildsPerShard === 'string' &&
375
+ this.options.MinGuildsPerShard === 'auto' &&
376
+ this.options.MaxGuildsPerShard <= 2000
377
+ )
378
+ throw new RangeError(
379
+ "If AutoResharderManagerOptions.MinGuildsPerShard is set to 'auto' than AutoResharderManagerOptions.MaxGuildsPerShard must be a number > 2000",
380
+ );
381
+ }
382
+ }
@@ -0,0 +1,56 @@
1
+ import { ClusterManager } from '../Core/ClusterManager.js';
2
+ import { RedisClient } from '../Util/RedisClient.js';
3
+
4
+ export interface HeartbeatOptions {
5
+ interval?: number;
6
+ timeout?: number;
7
+ }
8
+
9
+ export class HeartbeatManager {
10
+ public manager?: ClusterManager;
11
+ public redis: RedisClient;
12
+ public options: Required<HeartbeatOptions>;
13
+ private checkInterval?: any;
14
+
15
+ constructor(options: HeartbeatOptions = {}) {
16
+ this.options = {
17
+ interval: options.interval || 10000,
18
+ timeout: options.timeout || 30000
19
+ };
20
+ this.redis = new RedisClient();
21
+ }
22
+
23
+ public build(manager: ClusterManager) {
24
+ this.manager = manager;
25
+ // @ts-ignore
26
+ this.manager.heartbeat = this;
27
+ this.start();
28
+ }
29
+
30
+ public async start() {
31
+ await this.redis.connect().catch(() => {});
32
+ this.checkInterval = setInterval(() => this.checkClusters(), this.options.interval);
33
+ this.manager?._debug("[Heartbeat] Redis-backed monitoring started");
34
+ }
35
+
36
+ private async checkClusters() {
37
+ if (!this.manager) return;
38
+ try {
39
+ for (const [id, cluster] of this.manager.clusters) {
40
+ if (!cluster.thread) continue;
41
+
42
+ const lastHeartbeat = await this.redis.get(`hb:cluster:${id}`);
43
+ if (!lastHeartbeat && cluster.ready) {
44
+ this.manager._debug(`[Heartbeat] Cluster ${id} missed heartbeat, respawning...`);
45
+ cluster.respawn();
46
+ }
47
+ }
48
+ } catch (error) {
49
+ this.manager._debug(`[Heartbeat] Error checking clusters: ${(error as Error).message}`);
50
+ }
51
+ }
52
+
53
+ public stop() {
54
+ if (this.checkInterval) clearInterval(this.checkInterval);
55
+ }
56
+ }
@@ -0,0 +1,49 @@
1
+ import { ClusterManager } from "../Core/ClusterManager.js";
2
+ import { Plugin } from "../types/shared.js";
3
+
4
+ export interface QueueManagerOptions {
5
+ maxParallelSpawns?: number;
6
+ spawnDelay?: number;
7
+ }
8
+
9
+ export class QueueManager implements Plugin {
10
+ public manager?: ClusterManager;
11
+ public options: QueueManagerOptions;
12
+ private queue: any[] = [];
13
+ private processing = false;
14
+
15
+ constructor(options: QueueManagerOptions = {}) {
16
+ this.options = options;
17
+ }
18
+
19
+ public build(manager: ClusterManager) {
20
+ this.manager = manager;
21
+ // @ts-ignore
22
+ this.manager.queueManager = this;
23
+ this.manager._debug("[QueueManager] Plugin initialized");
24
+ }
25
+
26
+ public add(task: () => Promise<any>) {
27
+ this.queue.push(task);
28
+ this.process();
29
+ }
30
+
31
+ private async process() {
32
+ if (this.processing || this.queue.length === 0) return;
33
+ this.processing = true;
34
+
35
+ while (this.queue.length > 0) {
36
+ const task = this.queue.shift();
37
+ try {
38
+ await task();
39
+ } catch (err) {
40
+ this.manager?._debug(`[QueueManager] Task failed: ${err}`);
41
+ }
42
+ if (this.options.spawnDelay) {
43
+ await new Promise(resolve => setTimeout(resolve, this.options.spawnDelay));
44
+ }
45
+ }
46
+
47
+ this.processing = false;
48
+ }
49
+ }
@@ -0,0 +1,101 @@
1
+ import { Cluster } from "../Core/Cluster.js";
2
+ import { ClusterManager } from "../Core/ClusterManager.js";
3
+ import { chunkArray, fetchRecommendedShards } from "../Util/Util.js";
4
+
5
+ export type ReClusterRestartMode = 'rolling';
6
+
7
+ export interface ReClusterOptions {
8
+ delay?: number;
9
+ timeout?: number;
10
+ totalShards?: number | 'auto';
11
+ totalClusters?: number;
12
+ shardsPerClusters?: number;
13
+ shardList?: number[];
14
+ shardClusterList?: number[][];
15
+ }
16
+
17
+ export class ReClusterManager {
18
+ options: ReClusterOptions;
19
+ onProgress: boolean = false;
20
+ manager?: ClusterManager;
21
+
22
+ constructor(options: ReClusterOptions = {}) {
23
+ this.options = options;
24
+ }
25
+
26
+ build(manager: ClusterManager) {
27
+ // @ts-ignore
28
+ manager.recluster = this;
29
+ this.manager = manager;
30
+ return this;
31
+ }
32
+
33
+ public async start(options?: ReClusterOptions) {
34
+ const {
35
+ delay = 7000,
36
+ timeout = 300000,
37
+ totalClusters,
38
+ shardsPerClusters,
39
+ shardClusterList,
40
+ shardList = this.manager?.shardList,
41
+ } = options || {};
42
+ let totalShards = options?.totalShards;
43
+
44
+ if (this.onProgress) throw new Error('Zero Downtime Reclustering is already in progress');
45
+ if (!this.manager) throw new Error('Manager is missing on ReClusterManager');
46
+
47
+ if (totalShards) {
48
+ if (totalShards === 'auto' || totalShards === -1) {
49
+ if (!this.manager?.token) throw new Error('Token must be defined on manager for auto totalShards');
50
+ totalShards = await fetchRecommendedShards(this.manager.token);
51
+ }
52
+ this.manager.totalShards = totalShards as number;
53
+ }
54
+
55
+ if (totalClusters) this.manager.totalClusters = totalClusters;
56
+ if (shardsPerClusters) {
57
+ this.manager.shardsPerClusters = shardsPerClusters;
58
+ this.manager.totalClusters = Math.ceil(this.manager.totalShards / this.manager.shardsPerClusters);
59
+ }
60
+
61
+ if (shardList) this.manager.shardList = shardList;
62
+ if (shardClusterList) this.manager.shardClusterList = shardClusterList;
63
+ else {
64
+ this.manager.shardClusterList = chunkArray(
65
+ this.manager.shardList,
66
+ Math.ceil(this.manager.shardList.length / this.manager.totalClusters),
67
+ );
68
+ }
69
+
70
+ this.onProgress = true;
71
+ this.manager._debug(`[ReCluster] Starting zero-downtime rolling restart...`);
72
+
73
+ try {
74
+ for (let i = 0; i < this.manager.totalClusters; i++) {
75
+ const clusterId = this.manager.clusterList[i] || i;
76
+ const newShards = this.manager.shardClusterList[i] as number[];
77
+
78
+ this.manager._debug(`[ReCluster] Spawning replacement for cluster ${clusterId}...`);
79
+ const newCluster = this.manager.createCluster(clusterId, newShards, this.manager.totalShards, true);
80
+
81
+ await newCluster.spawn(timeout);
82
+
83
+ const oldCluster = this.manager.clusters.get(clusterId);
84
+ if (oldCluster) {
85
+ oldCluster.kill({ force: true, reason: 'rolling restart replacement' });
86
+ }
87
+
88
+ this.manager.clusters.set(clusterId, newCluster);
89
+ this.manager._debug(`[ReCluster] Cluster ${clusterId} replaced successfully`);
90
+
91
+ if (delay > 0 && i < this.manager.totalClusters - 1) {
92
+ await new Promise(r => globalThis.setTimeout(r, delay));
93
+ }
94
+ }
95
+ } finally {
96
+ this.onProgress = false;
97
+ }
98
+
99
+ return { success: true };
100
+ }
101
+ }
@@ -0,0 +1,109 @@
1
+ export interface ChildProcessOptions {
2
+ clusterData: Record<string, any> | undefined;
3
+ args?: string[] | undefined;
4
+ env?: Record<string, string> | undefined;
5
+ cwd?: string;
6
+ }
7
+
8
+ export class Child {
9
+ file: string;
10
+ process: import("bun").Subprocess | null;
11
+ options: ChildProcessOptions;
12
+ args: string[];
13
+
14
+ constructor(file: string, options: ChildProcessOptions) {
15
+ this.file = file;
16
+ this.process = null;
17
+ this.options = options;
18
+ this.args = options.args || [];
19
+ }
20
+
21
+ public spawn() {
22
+ const env = {
23
+ ...process.env,
24
+ ...this.options.env,
25
+ ...(this.options.clusterData as any)
26
+ };
27
+
28
+ // Convert all env values to string for Bun.spawn
29
+ const stringEnv: Record<string, string> = {};
30
+ for (const [key, value] of Object.entries(env)) {
31
+ stringEnv[key] = String(value);
32
+ }
33
+
34
+ this.process = Bun.spawn({
35
+ cmd: ["bun", "run", this.file, ...this.args],
36
+ env: stringEnv,
37
+ cwd: this.options.cwd,
38
+ stdout: "inherit",
39
+ stderr: "inherit",
40
+ ipc: (message) => {
41
+ this._onMessage(message);
42
+ },
43
+ onExit: (proc, exitCode, signalCode) => {
44
+ this._onExit(exitCode ?? 0);
45
+ }
46
+ });
47
+
48
+ return this;
49
+ }
50
+
51
+ private _messageListeners: ((message: any) => void)[] = [];
52
+ private _exitListeners: ((exitCode: number) => void)[] = [];
53
+ private _errorListeners: ((error: Error) => void)[] = [];
54
+
55
+ public on(event: 'message' | 'exit' | 'error', listener: (...args: any[]) => void) {
56
+ if (event === 'message') this._messageListeners.push(listener);
57
+ if (event === 'exit') this._exitListeners.push(listener);
58
+ if (event === 'error') this._errorListeners.push(listener);
59
+ return this;
60
+ }
61
+
62
+ private _onMessage(message: any) {
63
+ for (const listener of this._messageListeners) listener(message);
64
+ }
65
+
66
+ private _onExit(exitCode: number) {
67
+ for (const listener of this._exitListeners) listener(exitCode);
68
+ }
69
+
70
+ public kill() {
71
+ this.process?.kill();
72
+ this.process = null;
73
+ this._messageListeners = [];
74
+ this._exitListeners = [];
75
+ this._errorListeners = [];
76
+ }
77
+
78
+ public send(message: any) {
79
+ return new Promise<void>((resolve, reject) => {
80
+ try {
81
+ this.process?.send(message);
82
+ resolve();
83
+ } catch (err) {
84
+ reject(err);
85
+ }
86
+ });
87
+ }
88
+
89
+ public get killed() {
90
+ return !this.process || this.process.killed;
91
+ }
92
+ }
93
+
94
+ export class ChildClient {
95
+ public send(message: any) {
96
+ return new Promise<void>((resolve, reject) => {
97
+ try {
98
+ process.send?.(message);
99
+ resolve();
100
+ } catch (err) {
101
+ reject(err);
102
+ }
103
+ });
104
+ }
105
+
106
+ public getData() {
107
+ return process.env;
108
+ }
109
+ }