UNPKG

galactic.ts

Version:

galactic is a scalable clustering and sharding framework for Discord bots, enabling efficient multi-machine deployments with seamless discord.js integration.

1 lines 102 kB
{"version":3,"sources":["../src/bridge/BridgeClientCluster.ts","../src/general/EventManager.ts","../src/bridge/BridgeClientConnection.ts","../src/bridge/Bridge.ts","../src/bridge/ClusterCalculator.ts","../src/general/ShardingUtil.ts","../src/cluster/Cluster.ts","../src/cluster/ClusterProcess.ts","../src/instance/BotInstance.ts","../src/instance/ManagedInstance.ts","../src/instance/StandaloneInstance.ts"],"sourcesContent":["import {BridgeClientConnection} from \"./BridgeClientConnection\";\n\nexport enum BridgeClientClusterConnectionStatus {\n REQUESTING = 'requesting',\n STARTING = 'starting',\n CONNECTED = 'connected',\n RECLUSTERING = 'reclustering',\n DISCONNECTED = 'disconnected',\n}\n\nexport class BridgeClientCluster {\n public readonly clusterID: number;\n public readonly shardList: number[];\n public connectionStatus: BridgeClientClusterConnectionStatus = BridgeClientClusterConnectionStatus.DISCONNECTED;\n\n public connection?: BridgeClientConnection;\n\n public oldConnection?: BridgeClientConnection;\n\n public missedHeartbeats: number = 0;\n\n public heartbeatResponse?: HeartbeatResponse;\n\n public heartbeatPending = false;\n\n public startedAt?: number;\n\n constructor(clusterID: number, shardList: number[]) {\n this.clusterID = clusterID;\n this.shardList = shardList;\n }\n\n setConnection(connection?: BridgeClientConnection): void {\n if(connection == undefined){\n this.connectionStatus = BridgeClientClusterConnectionStatus.DISCONNECTED;\n this.connection = undefined;\n return;\n }\n\n if (this.connection) {\n throw new Error(`Connection already set for cluster ${this.clusterID}`);\n }\n\n this.connectionStatus = BridgeClientClusterConnectionStatus.REQUESTING;\n this.connection = connection;\n }\n\n setOldConnection(connection?: BridgeClientConnection): void {\n this.oldConnection = connection;\n }\n\n isUsed(): boolean {\n return this.connection != undefined && this.connectionStatus !== BridgeClientClusterConnectionStatus.DISCONNECTED;\n }\n\n reclustering(connection: BridgeClientConnection): void {\n this.connectionStatus = BridgeClientClusterConnectionStatus.RECLUSTERING;\n this.oldConnection = this.connection;\n this.connection = connection;\n }\n\n addMissedHeartbeat(): void {\n this.missedHeartbeats++;\n }\n\n removeMissedHeartbeat(): void {\n if (this.missedHeartbeats > 0) {\n this.missedHeartbeats--;\n }\n }\n\n resetMissedHeartbeats(): void {\n this.missedHeartbeats = 0;\n }\n}\n\nexport type HeartbeatResponse = {\n cpu: {\n raw: {\n user: number,\n system: number,\n }\n cpuPercent: string\n },\n memory: {\n raw: {\n rss: number,\n heapTotal: number,\n heapUsed: number,\n external: number,\n arrayBuffers: number,\n },\n memoryPercent: string\n usage: number\n },\n ping: number,\n shardPings: {\n id: number,\n ping: number,\n status: number,\n guilds: number,\n members: number\n }[]\n}","import {EventPayload} from \"./EventPayload\";\n\nexport class EventManager {\n\n private pendingPayloads = new Map<string, {\n resolve: (value: unknown) => void;\n reject: (error: unknown) => void;\n }>();\n\n // Track per-request timeout handles so we can clear them on resolve/reject\n private pendingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();\n\n private readonly _send: (payload: EventPayload) => Promise<void>;\n\n private readonly _on: (payload: unknown) => void;\n\n private readonly _request: (payload: unknown) => unknown;\n\n constructor(send: (payload: EventPayload) => Promise<void>, on: (message: unknown) => void, request: (message: unknown) => unknown) {\n this._send = send;\n this._on = on;\n this._request = request\n }\n\n async send(data: unknown) {\n return this._send({\n id: crypto.randomUUID(),\n type: 'message',\n data: data\n });\n }\n\n async request<T>(payload: unknown, timeout: number): Promise<T> {\n const id = crypto.randomUUID();\n\n return new Promise<T>((resolve, reject) => {\n this._send({\n id: id,\n type: 'request',\n data: payload\n });\n\n this.pendingPayloads.set(id, {\n resolve: resolve as (value: unknown) => void,\n reject\n });\n\n const t = setTimeout(() => {\n if (this.pendingPayloads.has(id)) {\n this.pendingPayloads.delete(id);\n this.pendingTimeouts.delete(id);\n reject({\n error: `Request with id ${id} timed out`,\n });\n }\n }, timeout);\n this.pendingTimeouts.set(id, t);\n })\n }\n\n receive(possiblePayload: unknown) {\n if (typeof possiblePayload !== 'object' || possiblePayload === null) {\n return;\n }\n\n const payload = possiblePayload as EventPayload;\n\n if (!payload.id || !payload.type) {\n return;\n }\n\n if (payload.type === 'message') {\n this._on(payload.data);\n return;\n }\n\n if (payload.type === 'response') {\n // Handle requests\n const resolve = this.pendingPayloads.get(payload.id)?.resolve;\n if (resolve) {\n resolve(payload.data);\n this.pendingPayloads.delete(payload.id);\n const to = this.pendingTimeouts.get(payload.id);\n if (to) clearTimeout(to);\n this.pendingTimeouts.delete(payload.id);\n }\n return;\n }\n\n if (payload.type === 'response_error') {\n // Handle requests\n const reject = this.pendingPayloads.get(payload.id)?.reject;\n if (reject) {\n reject(payload.data);\n this.pendingPayloads.delete(payload.id);\n const to = this.pendingTimeouts.get(payload.id);\n if (to) clearTimeout(to);\n this.pendingTimeouts.delete(payload.id);\n }\n return;\n }\n\n if (payload.type === 'request') {\n // Handle requests\n const data = this._request(payload.data);\n if(data instanceof Promise) {\n data.then((result) => {\n this._send({\n id: payload.id,\n type: 'response',\n data: result\n });\n }).catch((error) => {\n this._send({\n id: payload.id,\n type: 'response_error',\n data: error\n });\n });\n } else {\n this._send({\n id: payload.id,\n type: 'response',\n data: data\n });\n }\n return;\n }\n }\n\n // Reject and clear all pending requests to avoid memory leaks when a connection/process closes\n close(reason?: string) {\n if (this.pendingPayloads.size === 0 && this.pendingTimeouts.size === 0) return;\n const err = { error: reason || 'EventManager closed' };\n for (const [id, handlers] of this.pendingPayloads.entries()) {\n try { handlers.reject(err); } catch (_) { /* ignore */ }\n this.pendingPayloads.delete(id);\n const to = this.pendingTimeouts.get(id);\n if (to) clearTimeout(to);\n this.pendingTimeouts.delete(id);\n }\n // In case there are any stray timeouts with no pending payload\n for (const to of this.pendingTimeouts.values()) {\n clearTimeout(to);\n }\n this.pendingTimeouts.clear();\n }\n}\n\n","import {EventManager} from \"../general/EventManager\";\nimport {Connection} from \"net-ipc\";\n\nexport enum BridgeClientConnectionStatus {\n READY = 'ready',\n PENDING_STOP = 'pending_stop',\n}\nexport class BridgeClientConnection {\n public readonly instanceID: number;\n public readonly eventManager: EventManager;\n public readonly connection: Connection;\n public readonly data: unknown;\n public connectionStatus: BridgeClientConnectionStatus = BridgeClientConnectionStatus.READY;\n public readonly dev: boolean = false;\n\n private _onMessage?: (message: unknown) => void;\n private _onRequest?: (message: unknown) => unknown;\n\n constructor(instanceID: number, connection: Connection, data: unknown, dev: boolean) {\n this.instanceID = instanceID;\n this.connection = connection;\n this.data = data;\n this.dev = dev || false;\n this.eventManager = new EventManager((message) => {\n if(!this.connection?.connection?.closed){\n return this.connection.send(message);\n }\n return Promise.reject(new Error('Connection is closed, cannot send message'));\n }, (message) => {\n if (this._onMessage) {\n this._onMessage(message);\n }\n }, (message) => {\n if (this._onRequest) {\n return this._onRequest(message);\n }\n return undefined;\n })\n }\n\n messageReceive(message: any) {\n this.eventManager.receive(message);\n }\n\n onRequest(callback: (message: unknown) => unknown) {\n this._onRequest = callback;\n }\n\n onMessage(callback: (message: unknown) => void) {\n this._onMessage = callback;\n }\n}","import {Server} from 'net-ipc';\nimport {BridgeClientConnection, BridgeClientConnectionStatus} from \"./BridgeClientConnection\";\nimport {GatewayIntentsString} from \"discord.js\";\nimport {ClusterCalculator} from \"./ClusterCalculator\";\nimport {BridgeClientCluster, BridgeClientClusterConnectionStatus, HeartbeatResponse} from \"./BridgeClientCluster\";\nimport {ShardingUtil} from \"../general/ShardingUtil\";\nimport * as cluster from \"node:cluster\";\n\nexport class Bridge {\n public readonly port: number;\n public readonly server: Server;\n public readonly connectedClients: Map<string, BridgeClientConnection> = new Map();\n private readonly token: string;\n private readonly intents: GatewayIntentsString[];\n private readonly shardsPerCluster: number = 1;\n private readonly clusterToStart: number = 1\n\n private readonly clusterCalculator: ClusterCalculator;\n\n private readonly eventMap: BridgeEventListeners = {\n CLUSTER_READY: undefined, CLUSTER_HEARTBEAT_FAILED: undefined,\n CLUSTER_STOPPED: undefined, CLIENT_CONNECTED: undefined, CLIENT_DISCONNECTED: undefined,\n CLUSTER_SPAWNED: undefined, CLUSTER_RECLUSTER: undefined, ERROR: undefined,\n CLIENT_STOP: undefined\n }\n\n constructor(port: number, token: string, intents: GatewayIntentsString[], shardsPerCluster: number, clusterToStart: number) {\n this.port = port;\n this.token = token;\n this.intents = intents;\n this.clusterToStart = clusterToStart;\n this.shardsPerCluster = shardsPerCluster;\n\n this.clusterCalculator = new ClusterCalculator(this.clusterToStart, this.shardsPerCluster);\n\n this.server = new Server({\n port: this.port,\n })\n }\n\n public start(): void {\n this.server.start().then(() => {\n this.startListening();\n })\n\n this.interval();\n }\n\n private interval(): void {\n setInterval(() => {\n this.checkCreate();\n this.checkRecluster();\n this.heartbeat();\n }, 5000)\n }\n\n private checkRecluster(): void {\n // check if all clusters are used\n const up = this.clusterCalculator.checkAllClustersConnected()\n if (!up) {\n return;\n }\n\n const connectedClients: BridgeClientConnection[] = this.connectedClients.values().filter(c => c.connectionStatus == BridgeClientConnectionStatus.READY && !c.dev).toArray();\n const {most, least} = this.clusterCalculator.findMostAndLeastClustersForConnections(connectedClients);\n if (most) {\n const clusterToSteal = this.clusterCalculator.getClusterForConnection(most)[0] || undefined;\n if (least && clusterToSteal) {\n clusterToSteal.reclustering(least);\n\n if(this.eventMap.CLUSTER_RECLUSTER) this.eventMap.CLUSTER_RECLUSTER(clusterToSteal, least, clusterToSteal.oldConnection!);\n this.createCluster(least, clusterToSteal, true);\n\n return;\n }\n }\n }\n\n private heartbeat(): void {\n const clusters = this.clusterCalculator.clusterList;\n\n clusters.forEach((cluster) => {\n if(cluster.connection && cluster.connectionStatus == BridgeClientClusterConnectionStatus.CONNECTED && !cluster.heartbeatPending) {\n cluster.heartbeatPending = true;\n cluster.connection.eventManager.request<HeartbeatResponse>({\n type: 'CLUSTER_HEARTBEAT',\n data: {\n clusterID: cluster.clusterID\n }\n }, 20000).then((r) => {\n cluster.removeMissedHeartbeat();\n cluster.heartbeatResponse = r;\n }).catch((err) => {\n if(this.eventMap.CLUSTER_HEARTBEAT_FAILED) this.eventMap.CLUSTER_HEARTBEAT_FAILED(cluster, err)\n cluster.addMissedHeartbeat()\n\n if(cluster.missedHeartbeats > 7 && !cluster.connection?.dev){\n cluster.connection?.eventManager.send({\n type: 'CLUSTER_STOP',\n data: {\n id: cluster.clusterID\n }\n });\n cluster.connectionStatus = BridgeClientClusterConnectionStatus.DISCONNECTED;\n cluster.resetMissedHeartbeats()\n }\n }).finally(() => {\n cluster.heartbeatPending = false;\n })\n }\n });\n }\n\n private checkCreate(): void {\n const optionalCluster = this.clusterCalculator.getNextCluster();\n\n if (!optionalCluster) {\n return;\n }\n\n const lowestLoadClient = this.clusterCalculator.getClusterWithLowestLoad(this.connectedClients);\n if (!lowestLoadClient) {\n return;\n }\n\n this.createCluster(lowestLoadClient, optionalCluster)\n }\n\n private createCluster(connection: BridgeClientConnection, cluster: BridgeClientCluster, recluster = false) {\n cluster.resetMissedHeartbeats()\n cluster.heartbeatResponse = undefined;\n if (!recluster) {\n cluster.setConnection(connection)\n } else {\n cluster.oldConnection?.eventManager.send({\n type: 'CLUSTER_RECLUSTER',\n data: {\n clusterID: cluster.clusterID\n }\n })\n }\n if(this.eventMap.CLUSTER_SPAWNED) this.eventMap.CLUSTER_SPAWNED(cluster, connection)\n connection.eventManager.send({\n type: 'CLUSTER_CREATE',\n data: {\n clusterID: cluster.clusterID,\n instanceID: connection.instanceID,\n totalShards: this.getTotalShards(),\n shardList: cluster.shardList,\n token: this.token,\n intents: this.intents\n }\n });\n }\n\n public startListening(): void {\n this.server.on('connect', (connection, payload) => {\n const id = payload?.id;\n const data = payload.data as unknown;\n const dev = payload?.dev || false;\n if (!id) {\n connection.close('Invalid payload', false);\n return;\n }\n\n if (this.connectedClients.values().some(client => client.instanceID === id)) {\n connection.close('Already connected', false);\n return;\n }\n\n const bridgeConnection = new BridgeClientConnection(payload.id, connection, data, dev);\n if(this.eventMap.CLIENT_CONNECTED) this.eventMap.CLIENT_CONNECTED(bridgeConnection);\n\n bridgeConnection.onMessage((m: any) => {\n if (m.type == 'CLUSTER_SPAWNED') {\n const cluster = this.clusterCalculator.getClusterForConnection(bridgeConnection).find(c => c.clusterID === m.data.id);\n if (cluster) {\n cluster.connectionStatus = BridgeClientClusterConnectionStatus.STARTING;\n }\n return;\n }\n\n if (m.type == 'CLUSTER_READY') {\n const cluster = this.clusterCalculator.getClusterForConnection(bridgeConnection).find(c => c.clusterID === m.data.id);\n if (cluster) {\n cluster.startedAt = Date.now();\n if(this.eventMap.CLUSTER_READY) this.eventMap.CLUSTER_READY(cluster, m.data.guilds || 0, m.data.members || 0);\n cluster.connectionStatus = BridgeClientClusterConnectionStatus.CONNECTED;\n if (cluster.oldConnection) {\n cluster.oldConnection.eventManager.send({\n type: 'CLUSTER_STOP',\n data: {\n id: cluster.clusterID\n }\n });\n cluster.oldConnection = undefined;\n }\n }\n return;\n }\n\n if (m.type == 'CLUSTER_STOPPED') {\n const cluster = this.clusterCalculator.getClusterForConnection(bridgeConnection).find(c => c.clusterID === m.data.id);\n if (cluster) {\n cluster.startedAt = undefined;\n if(this.eventMap.CLUSTER_STOPPED) this.eventMap.CLUSTER_STOPPED(cluster);\n cluster.setConnection(undefined);\n }\n return;\n }\n\n if(m.type == \"INSTANCE_STOP\") {\n this.stopInstance(bridgeConnection);\n }\n\n return;\n })\n\n bridgeConnection.onRequest((m: any) => {\n if(m.type == 'REDIRECT_REQUEST_TO_GUILD'){\n const guildID = m.guildID;\n const shardID = ShardingUtil.getShardIDForGuild(guildID, this.getTotalShards());\n const cluster = this.clusterCalculator.getClusterOfShard(shardID);\n if(!cluster){\n return Promise.reject(new Error(\"cluster not found\"))\n }\n if(cluster.connectionStatus != BridgeClientClusterConnectionStatus.CONNECTED){\n return Promise.reject(new Error(\"cluster not connected.\"))\n }\n\n if(!cluster.connection?.eventManager){\n return Promise.reject(new Error(\"no connection defined.\"))\n }\n\n return cluster.connection.eventManager.request({\n type: 'REDIRECT_REQUEST_TO_GUILD',\n clusterID: cluster.clusterID,\n guildID: guildID,\n data: m.data\n }, 5000)\n }\n\n if(m.type == 'BROADCAST_EVAL') {\n const responses = Promise.all(\n this.connectedClients.values().map(c => {\n return c.eventManager.request<unknown[]>({\n type: 'BROADCAST_EVAL',\n data: m.data,\n }, 5000);\n })\n )\n return new Promise<unknown[]>((resolve, reject) => {\n responses.then((r) => {\n resolve(r.flatMap(f => f))\n }).catch(reject);\n })\n }\n\n if(m.type == 'SELF_CHECK') {\n return {\n clusterList: [\n ...this.clusterCalculator.getClusterForConnection(bridgeConnection).map(c => c.clusterID),\n ...this.clusterCalculator.getOldClusterForConnection(bridgeConnection).map(c => c.clusterID)\n ]\n }\n }\n\n return Promise.reject(new Error(\"unknown type\"))\n })\n\n this.connectedClients.set(connection.id, bridgeConnection)\n });\n\n this.server.on('disconnect', (connection, reason) => {\n const closedConnection = this.connectedClients.get(connection.id);\n if (!closedConnection) {\n return;\n }\n\n const clusters = this.clusterCalculator.getClusterForConnection(closedConnection);\n for (const cluster of clusters) {\n this.clusterCalculator.clearClusterConnection(cluster.clusterID);\n }\n\n this.connectedClients.delete(connection.id);\n if(this.eventMap.CLIENT_DISCONNECTED) this.eventMap.CLIENT_DISCONNECTED(closedConnection, reason);\n });\n\n this.server.on(\"message\", (message, connection) => {\n this.sendMessageToClient(connection.id, message);\n })\n }\n\n sendMessageToClient(clientId: string, message: unknown): void {\n if (!this.connectedClients.has(clientId)) {\n return;\n }\n\n const client = this.connectedClients.get(clientId);\n if (client) {\n client.messageReceive(message);\n }\n }\n\n private getTotalShards() {\n return this.shardsPerCluster * this.clusterToStart;\n }\n\n\n public on<K extends keyof BridgeEventListeners>(event: K, listener: BridgeEventListeners[K]): void {\n this.eventMap[event] = listener;\n }\n\n public getClusters() {\n return this.clusterCalculator.clusterList;\n }\n\n async stopAllInstances() {\n const instances = Array.from(this.connectedClients.values());\n for (const instance of instances) {\n instance.connectionStatus = BridgeClientConnectionStatus.PENDING_STOP;\n }\n\n for (const instance of instances) {\n await this.stopInstance(instance, false);\n }\n }\n\n async stopAllInstancesWithRestart() {\n const instances = Array.from(this.connectedClients.values());\n\n for (const instance of instances) {\n await this.stopInstance(instance);\n await new Promise<void>((resolve) => {\n setTimeout(async () => {\n resolve();\n }, 1000 * 10);\n })\n }\n }\n\n async moveCluster(instance: BridgeClientConnection, cluster: BridgeClientCluster) {\n cluster.reclustering(instance);\n\n this.createCluster(instance, cluster, true);\n }\n\n async stopInstance(instance: BridgeClientConnection, recluster = true) {\n if(this.eventMap.CLIENT_STOP) this.eventMap.CLIENT_STOP(instance);\n instance.connectionStatus = BridgeClientConnectionStatus.PENDING_STOP;\n\n let clusterToSteal: BridgeClientCluster | undefined;\n\n await instance.eventManager.send({\n type: 'INSTANCE_STOP'\n });\n\n if(recluster) {\n while ((clusterToSteal = this.clusterCalculator.getClusterForConnection(instance).filter(c =>\n c.connectionStatus === BridgeClientClusterConnectionStatus.CONNECTED ||\n c.connectionStatus == BridgeClientClusterConnectionStatus.STARTING ||\n c.connectionStatus == BridgeClientClusterConnectionStatus.RECLUSTERING)[0]) !== undefined) {\n // skip if the cluster is not connected\n if(clusterToSteal.connectionStatus != BridgeClientClusterConnectionStatus.CONNECTED) break;\n\n const least = this.clusterCalculator.getClusterWithLowestLoad(this.connectedClients);\n if (!least) {\n if (this.eventMap.ERROR) {\n this.eventMap.ERROR(\"Reclustering failed: No least cluster found.\");\n }\n await instance.eventManager.send({\n type: 'CLUSTER_STOP',\n data: {\n id: clusterToSteal.clusterID\n }\n });\n clusterToSteal.connection = undefined;\n clusterToSteal.connectionStatus = BridgeClientClusterConnectionStatus.DISCONNECTED;\n continue;\n }\n\n clusterToSteal.reclustering(least);\n\n if (this.eventMap.CLUSTER_RECLUSTER) {\n this.eventMap.CLUSTER_RECLUSTER(clusterToSteal, least, clusterToSteal.oldConnection!);\n }\n\n this.createCluster(least, clusterToSteal, true);\n }\n\n return new Promise<void>((resolve, reject) => {\n const interval = setInterval(async () => {\n const cluster = this.clusterCalculator.getOldClusterForConnection(instance)[0] || undefined;\n if (!cluster) {\n clearInterval(interval);\n await instance.eventManager.send({\n type: 'INSTANCE_STOPPED'\n })\n await instance.connection.close(\"Instance stopped.\", false);\n resolve();\n return;\n }\n }, 1000);\n })\n } else {\n for(const cluster of this.clusterCalculator.getClusterForConnection(instance)) {\n await instance.eventManager.send({\n type: 'CLUSTER_STOP',\n data: {\n id: cluster.clusterID\n }\n });\n }\n\n\n await instance.eventManager.send({\n type: 'INSTANCE_STOPPED'\n })\n await instance.connection.close(\"Instance stopped.\", false);\n }\n }\n}\n\n\n\nexport type BridgeEventListeners = {\n 'CLUSTER_READY': ((cluster: BridgeClientCluster, guilds: number, members: number) => void) | undefined,\n 'CLUSTER_STOPPED': ((cluster: BridgeClientCluster) => void) | undefined,\n 'CLUSTER_SPAWNED': ((cluster: BridgeClientCluster, connection: BridgeClientConnection) => void) | undefined,\n 'CLUSTER_RECLUSTER': ((cluster: BridgeClientCluster, newConnection: BridgeClientConnection, oldConnection: BridgeClientConnection) => void) | undefined,\n 'CLUSTER_HEARTBEAT_FAILED': ((cluster: BridgeClientCluster, error: unknown) => void) | undefined,\n 'CLIENT_CONNECTED': ((client: BridgeClientConnection) => void) | undefined,\n 'CLIENT_DISCONNECTED': ((client: BridgeClientConnection, reason: string) => void) | undefined,\n 'ERROR': ((error: string) => void) | undefined,\n 'CLIENT_STOP': ((instance: BridgeClientConnection) => void) | undefined\n};","import {BridgeClientCluster, BridgeClientClusterConnectionStatus} from \"./BridgeClientCluster\";\nimport {BridgeClientConnection, BridgeClientConnectionStatus} from \"./BridgeClientConnection\";\n\n/**\n * Manages the calculation and distribution of clusters for a Discord bot sharding system.\n * This class is responsible for creating clusters with their assigned shards,\n * tracking which clusters are in use, and providing methods to retrieve available clusters.\n */\nexport class ClusterCalculator {\n /** The total number of clusters to initialize */\n private readonly clusterToStart: number;\n\n /** The number of shards that each cluster will manage */\n private readonly shardsPerCluster: number;\n\n /** List of all clusters managed by this calculator */\n public readonly clusterList: BridgeClientCluster[]= [];\n\n /**\n * Creates a new ClusterCalculator and initializes the clusters.\n * \n * @param clusterToStart - The number of clusters to create\n * @param shardsPerCluster - The number of shards each cluster will manage\n */\n constructor(clusterToStart: number, shardsPerCluster: number) {\n this.shardsPerCluster = shardsPerCluster;\n this.clusterToStart = clusterToStart;\n\n this.calculateClusters();\n }\n\n /**\n * Calculates and initializes all clusters with their assigned shards.\n * Each cluster is assigned a sequential range of shard IDs based on its cluster index.\n */\n private calculateClusters(): void {\n const clusters: Map<number, number[]> = new Map();\n for (let i = 0; i < this.clusterToStart; i++) {\n clusters.set(i, []);\n for (let j = 0; j < this.shardsPerCluster; j++) {\n clusters.get(i)?.push(i * this.shardsPerCluster + j);\n }\n }\n\n for (let [clusterIndex, clusterShards] of clusters.entries()) {\n this.clusterList.push(new BridgeClientCluster(clusterIndex, clusterShards));\n }\n }\n\n /**\n * Retrieves the next available (unused) cluster and marks it as used.\n * \n * @returns The next available cluster, or undefined if all clusters are in use\n */\n public getNextCluster(): BridgeClientCluster | undefined {\n for (const cluster of this.clusterList) {\n if (!cluster.isUsed()) {\n return cluster;\n }\n }\n return undefined; // No available clusters\n }\n\n /**\n * Retrieves multiple available clusters up to the specified count.\n * Each returned cluster is marked as used.\n * \n * @param count - The maximum number of clusters to retrieve\n * @returns An array of available clusters (may be fewer than requested if not enough are available)\n */\n public getNextClusters(count: number): BridgeClientCluster[] {\n const availableClusters: BridgeClientCluster[] = [];\n for (const cluster of this.clusterList) {\n if (!cluster.isUsed() && availableClusters.length < count) {\n availableClusters.push(cluster);\n }\n }\n return availableClusters; // Returns the clusters that were found\n }\n\n /**\n * Sets the used status of a specific cluster by its ID.\n *\n * @param clusterID - The ID of the cluster to update\n * @param connection - The connection to associate with the cluster\n */\n public clearClusterConnection(clusterID: number): void {\n const cluster = this.clusterList.find(c => c.clusterID === clusterID);\n if (cluster) {\n cluster.setConnection(undefined);\n }\n }\n\n public getClusterForConnection(connection: BridgeClientConnection): BridgeClientCluster[] {\n return this.clusterList.filter(cluster =>\n cluster.connection?.instanceID === connection.instanceID\n );\n }\n\n public getOldClusterForConnection(connection: BridgeClientConnection): BridgeClientCluster[] {\n return this.clusterList.filter(cluster =>\n cluster.oldConnection?.instanceID === connection.instanceID\n );\n }\n\n public checkAllClustersConnected(): boolean {\n for (const cluster of this.clusterList) {\n if (cluster.connectionStatus != BridgeClientClusterConnectionStatus.CONNECTED){\n return false; // At least one cluster is not in use\n }\n }\n return true; // All clusters are in use\n }\n\n\n findMostAndLeastClustersForConnections(\n connectedClients: BridgeClientConnection[]\n ): {\n most: BridgeClientConnection | undefined,\n least: BridgeClientConnection | undefined\n } {\n\n const openClients = connectedClients.filter(x => !x.dev)\n\n const devClients = connectedClients.filter(x => x.dev)\n const summDevConnectedClusters = devClients.map(c => this.getClusterForConnection(c).length).reduce((a, b) => a + b, 0);\n\n let most: BridgeClientConnection | undefined;\n let least: BridgeClientConnection | undefined;\n let remainder = ((this.clusterToStart - summDevConnectedClusters) % openClients.length || 0);\n\n for (const client of openClients) {\n const clusters = this.getClusterForConnection(client);\n\n if (!most || clusters.length > this.getClusterForConnection(most).length) {\n most = client;\n }\n\n if (!least || clusters.length < this.getClusterForConnection(least).length) {\n least = client;\n }\n }\n\n if (most && least) {\n const mostCount = this.getClusterForConnection(most).length;\n const leastCount = this.getClusterForConnection(least).length;\n\n // Only recluster if the difference is greater than remainder\n if (mostCount - leastCount <= remainder) {\n return {most: undefined, least: undefined};\n }\n }\n\n return {most, least};\n }\n\n getClusterWithLowestLoad(connectedClients: Map<string, BridgeClientConnection>): BridgeClientConnection | undefined {\n let lowestLoadClient: BridgeClientConnection | undefined;\n let lowestLoad = Infinity;\n\n for (const client of connectedClients.values().filter(c =>\n c.connectionStatus === BridgeClientConnectionStatus.READY && !c.dev)) {\n const clusters = this.getClusterForConnection(client);\n\n const load = clusters.length; // Assuming load is determined by the number of clusters assigned\n if (load < lowestLoad) {\n lowestLoad = load;\n lowestLoadClient = client;\n }\n }\n\n return lowestLoadClient; // Returns the client with the lowest load, or undefined if no clients are connected\n }\n\n getClusterOfShard(shardID: number) {\n return this.clusterList.find(c => c.shardList.includes(shardID));\n }\n}\n","export class ShardingUtil {\n public static getShardIDForGuild(guildID: string, totalShards: number): number {\n if (!guildID || totalShards <= 0) {\n throw new Error(\"Invalid guild ID or total shards\");\n }\n\n return Number(BigInt(guildID) >> 22n) % totalShards;\n }\n}","import {Client, GatewayIntentsString, Status} from \"discord.js\";\nimport {EventManager} from \"../general/EventManager\";\nimport os from \"os\";\nexport class Cluster<T extends Client> {\n\n public readonly instanceID: number;\n\n public readonly clusterID: number;\n\n public readonly shardList: number[] = [];\n\n public readonly totalShards: number;\n\n public readonly token: string;\n\n public readonly intents: GatewayIntentsString[];\n\n public eventManager: EventManager;\n\n public client!: T;\n\n private readonly eventMap: {\n 'message': ((message: unknown) => void) | undefined,\n 'request': ((message: unknown, resolve: (data: unknown) => void, reject: (error: any) => void) => void) | undefined,\n 'CLUSTER_READY': (() => void) | undefined,\n } = {\n message: undefined, request: undefined, CLUSTER_READY: undefined,\n }\n\n constructor(instanceID: number, clusterID: number, shardList: number[], totalShards: number, token: string, intents: GatewayIntentsString[]) {\n this.instanceID = instanceID;\n this.clusterID = clusterID;\n this.shardList = shardList;\n this.totalShards = totalShards;\n this.token = token;\n this.intents = intents;\n this.eventManager = new EventManager((message: unknown) => {\n return new Promise((resolve, reject) => {\n if (typeof process.send !== 'function') {\n reject(new Error(\"Process does not support sending messages\"));\n return;\n }\n\n process.send?.(message, undefined, undefined, (error) => {\n if (error) {\n reject(error);\n } else {\n resolve();\n }\n });\n });\n }, (message: unknown) => {\n this._onMessage(message);\n }, (message: unknown) => {\n return this._onRequest(message);\n });\n process.on(\"message\", (message) => {\n this.eventManager.receive(message);\n })\n }\n\n static initial<T extends Client>(): Cluster<T> {\n const args = process.env;\n\n if (args.SHARD_LIST == undefined || args.INSTANCE_ID == undefined || args.TOTAL_SHARDS == undefined || args.TOKEN == undefined || args.INTENTS == undefined || args.CLUSTER_ID == undefined) {\n throw new Error(\"Missing required environment variables\");\n }\n\n const shardList = args.SHARD_LIST.split(',').map(Number);\n\n const totalShards = Number(args.TOTAL_SHARDS);\n\n const instanceID = Number(args.INSTANCE_ID);\n const clusterID = Number(args.CLUSTER_ID);\n\n const token = args.TOKEN;\n\n const intents = args.INTENTS.split(',').map(i => i.trim()) as GatewayIntentsString[];\n\n return new Cluster<T>(instanceID, clusterID, shardList, totalShards, token, intents);\n }\n\n triggerReady(guilds: number, members: number) {\n this.eventManager.send({\n type: 'CLUSTER_READY',\n id: this.clusterID,\n guilds: guilds,\n members: members,\n });\n\n if(this.eventMap?.CLUSTER_READY) {\n this.eventMap?.CLUSTER_READY();\n }\n }\n\n triggerError(e: any) {\n this.eventManager.send({\n type: 'CLUSTER_ERROR',\n id: this.clusterID,\n });\n }\n\n private async wait(ms: number) {\n return new Promise(resolve => setTimeout(resolve, ms));\n }\n\n private _onMessage(message: unknown): void {\n const m = message as { type: string, data: unknown };\n if(m.type == 'CUSTOM' && this.eventMap.message) {\n this.eventMap.message!(m.data);\n }\n }\n\n private _onRequest(message: unknown): unknown {\n const m = message as { type: string, data: unknown };\n if(m.type == 'CUSTOM' && this.eventMap.request) {\n return new Promise((resolve, reject) => {\n this.eventMap.request!(m.data, resolve, reject);\n });\n } else if(m.type == 'CLUSTER_HEARTBEAT'){\n const startTime = process.hrtime.bigint();\n const startUsage = process.cpuUsage();\n\n (async () => {\n await this.wait(500);\n })();\n\n const endTime = process.hrtime.bigint();\n const usageDiff = process.cpuUsage(startUsage);\n\n const elapsedTimeUs = Number((endTime - startTime) / 1000n);\n const totalCPUTime = usageDiff.user + usageDiff.system;\n\n const cpuCount = os.cpus().length;\n const cpuPercent = (totalCPUTime / (elapsedTimeUs * cpuCount)) * 100;\n\n // Collect per-shard ping information in addition to the overall ws ping\n let shardPings: { id: number, ping: number, status: Status, uptime?: unknown, guilds: number, members: number }[] = [];\n try {\n const shards = this.client.ws.shards;\n\n if(shards) {\n shards.forEach((shard) => {\n shardPings.push({ id: shard.id, ping: shard.ping, status: shard.status,\n guilds: this.client.guilds.cache.filter(g => g.shardId === shard.id).size,\n members: this.client.guilds.cache.filter(g => g.shardId === shard.id).reduce((acc, g) => acc + g.memberCount, 0)\n });\n\n this.client.shard?.fetchClientValues('uptime', shard.id).then(values => {\n shardPings[shard.id][\"uptime\"] = values\n console.log(values)\n }).catch(e => {\n\n })\n })\n }\n } catch (_) {\n // ignore and keep empty shardPings on failure\n }\n\n return {\n cpu: { raw: process.cpuUsage(), cpuPercent: cpuPercent.toFixed(2) },\n memory: { raw: process.memoryUsage(),\n memoryPercent: ((process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100).toFixed(2) + '%',\n usage: (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2) + 'MB'\n },\n ping: this.client.ws.ping,\n shardPings: shardPings,\n }\n } else if(m.type == 'BROADCAST_EVAL'){\n const broadcast = message as { type: 'BROADCAST_EVAL', data: string }\n\n const fn = eval(`(${broadcast.data})`);\n\n const result = fn(this.client);\n if(result instanceof Promise){\n return new Promise((resolve, reject) => {\n result.then(res => {\n resolve(res);\n }).catch(err => {\n reject(err);\n });\n });\n } else {\n return result;\n }\n }\n return undefined;\n }\n\n public on<K extends keyof ClusterEventListeners>(event: K, listener: ClusterEventListeners[K]): void {\n this.eventMap[event] = listener;\n }\n\n public sendMessage(data: unknown) {\n this.eventManager.send({\n type: 'CUSTOM',\n data: data,\n });\n }\n\n public sendRequest(data: unknown, timeout = 5000): Promise<unknown> {\n return this.eventManager.request({\n type: 'CUSTOM',\n data: data,\n }, timeout);\n }\n\n public broadcastEval<Result>(fn: (cluster: T) => Result, timeout = 20000): Promise<Result[]> {\n return this.eventManager.request({\n type: 'BROADCAST_EVAL',\n data: fn.toString(),\n }, timeout);\n }\n\n\n public sendMessageToClusterOfGuild(guildID: string, message: unknown): void {\n if (this.eventManager) {\n this.eventManager.send({\n type: 'REDIRECT_MESSAGE_TO_GUILD',\n guildID: guildID,\n data: message\n });\n }\n }\n\n public sendRequestToClusterOfGuild(guildID: string, message: unknown, timeout = 5000): Promise<unknown> {\n return new Promise((resolve, reject) => {\n if (this.eventManager) {\n this.eventManager.request({\n type: 'REDIRECT_REQUEST_TO_GUILD',\n guildID: guildID,\n data: message\n }, timeout).then((response) => {\n resolve(response);\n }).catch((error) => {\n reject(error);\n });\n } else {\n reject(new Error(\"Event manager is not initialized\"));\n }\n });\n }\n}\n\nexport type ClusterEventListeners = {\n message: (message: unknown) => void;\n request: (message: unknown, resolve: (data: unknown) => void, reject: (error: any) => void) => void;\n\n CLUSTER_READY: () => void;\n};","import {ChildProcess} from \"child_process\";\nimport {EventManager} from \"../general/EventManager\";\n\nexport type ClusterProcessState = 'starting' | 'running' | 'stopped';\n\nexport class ClusterProcess {\n public readonly child: ChildProcess;\n public readonly eventManager: EventManager;\n public readonly id: number;\n public readonly shardList: number[];\n public readonly totalShards: number;\n public status: ClusterProcessState;\n public readonly createdAt: number = Date.now();\n\n private _onMessage?: (message: unknown) => void;\n private _onRequest?: (message: unknown) => unknown;\n\n constructor(id: number, child: ChildProcess, shardList: number[], totalShards: number) {\n this.id = id;\n this.child = child;\n this.shardList = shardList;\n this.totalShards = totalShards;\n this.status = 'starting';\n this.eventManager = new EventManager((message) => {\n return new Promise<void>((resolve, reject) => {\n this.child.send(message, (error) => {\n if (error) {\n reject(error);\n } else {\n resolve();\n }\n });\n })\n }, (message) => {\n if (this._onMessage) {\n this._onMessage(message);\n }\n }, (message) => {\n if (this._onRequest) {\n return this._onRequest(message);\n }\n return undefined;\n })\n\n this.child.on('message', (message) => {\n this.eventManager.receive(message);\n });\n\n // Ensure we do not retain pending requests if the child dies or errors\n this.child.on('exit', () => {\n this.eventManager.close('child process exited');\n });\n this.child.on('error', () => {\n this.eventManager.close('child process error');\n });\n }\n\n onMessage(callback: (message: unknown) => void) {\n this._onMessage = callback;\n }\n\n onRequest(callback: (message: unknown) => unknown) {\n this._onRequest = callback;\n }\n\n public sendMessage(data: unknown) {\n this.eventManager.send({\n type: 'CUSTOM',\n data: data,\n });\n }\n\n public sendRequest(data: unknown, timeout = 5000): Promise<unknown> {\n return this.eventManager.request({\n type: 'CUSTOM',\n data: data,\n }, timeout);\n }\n}","import {fork} from 'child_process';\nimport {ClusterProcess} from \"../cluster/ClusterProcess\";\nimport {GatewayIntentsString} from \"discord.js\";\nimport {ShardingUtil} from \"../general/ShardingUtil\";\n\nexport abstract class BotInstance {\n\n private readonly entryPoint: string;\n\n private readonly execArgv: string[];\n\n public readonly clients: Map<number, ClusterProcess> = new Map();\n\n protected constructor(entryPoint: string, execArgv?: string[]) {\n this.entryPoint = entryPoint;\n this.execArgv = execArgv ?? [];\n }\n\n protected readonly eventMap: BotInstanceEventListeners = {\n 'message': undefined,\n 'request': undefined,\n\n 'PROCESS_KILLED': undefined,\n 'PROCESS_SPAWNED': undefined,\n 'ERROR': undefined,\n 'PROCESS_ERROR': undefined,\n 'CLUSTER_READY': undefined,\n 'CLUSTER_ERROR': undefined,\n 'CLUSTER_RECLUSTER': undefined,\n 'BRIDGE_CONNECTION_ESTABLISHED': undefined,\n 'BRIDGE_CONNECTION_CLOSED': undefined,\n 'BRIDGE_CONNECTION_STATUS_CHANGE': undefined,\n 'INSTANCE_STOP': undefined,\n 'INSTANCE_STOPPED': undefined,\n 'SELF_CHECK_SUCCESS': undefined,\n 'SELF_CHECK_ERROR': undefined,\n 'SELF_CHECK_RECEIVED': undefined,\n }\n\n protected startProcess(instanceID: number, clusterID: number, shardList: number[], totalShards: number, token: string, intents: GatewayIntentsString[]): void {\n try {\n const child = fork(this.entryPoint, {\n env: {\n INSTANCE_ID: instanceID.toString(),\n CLUSTER_ID: clusterID.toString(),\n SHARD_LIST: shardList.join(','),\n TOTAL_SHARDS: totalShards.toString(),\n TOKEN: token,\n INTENTS: intents.join(','),\n FORCE_COLOR: 'true'\n },\n stdio: 'inherit',\n execArgv: this.execArgv,\n silent: false,\n })\n\n const client = new ClusterProcess(clusterID, child, shardList, totalShards);\n\n child.stdout?.on('data', (data) => {\n process.stdout.write(data);\n });\n\n child.stderr?.on('data', (data) => {\n process.stderr.write(data);\n });\n\n child.on(\"spawn\", () => {\n if(this.eventMap.PROCESS_SPAWNED) this.eventMap.PROCESS_SPAWNED(client);\n\n this.setClusterSpawned(client);\n\n this.clients.set(clusterID, client);\n\n client.on