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
package/.gitattributes
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
------------------------------------------------------------------------------------------
|
|
2
|
+
| This following License accounts to the changes in Changes.md, |
|
|
3
|
+
| the unchanged code is the intellectual property of discord.js and their Contributors. |
|
|
4
|
+
| Check the attached License of Discord.js in the Repo |
|
|
5
|
+
------------------------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 Luigi Colantuono
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# ⚡ Buncord-Hybrid-Sharding
|
|
2
|
+
|
|
3
|
+
The ultimate **Enterprise Bun-native** sharding manager for Discord bots. Built for performance, reliability, and scale.
|
|
4
|
+
|
|
5
|
+
`buncord-hybrid-sharding` is a ground-up refactor of the hybrid sharding concept, optimized specifically for the Bun runtime. It eliminates all Node.js dependencies, leveraging `Bun.spawn` and native Bun IPC for ultra-fast, low-overhead clustering.
|
|
6
|
+
|
|
7
|
+
## 🚀 Key Features
|
|
8
|
+
|
|
9
|
+
- **🔋 Bun-Native Core**: Zero Node.js dependencies. Uses `Bun.spawn` and native IPC for maximum performance.
|
|
10
|
+
- **🔄 Zero-Downtime Rolling Restarts**: Built-in `ReClusterManager` for updating your bot with zero service interruption.
|
|
11
|
+
- **💓 Redis-Backed Heartbeats**: Distributed health monitoring using Redis. If a cluster hangs, it's detected and restarted automatically via TTL.
|
|
12
|
+
- **📊 Integrated Dashboard API**: Built-in monitoring server (port 3001) using `Bun.serve` to track cluster health and trigger administrative actions.
|
|
13
|
+
- **🚦 QueueManager Plugin**: Advanced control over cluster spawning to respect Discord's rate limits precisely.
|
|
14
|
+
- **📉 Resource Efficiency**: Hybrid sharding (multiple shards per process) reduces memory overhead by 40-60%.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 📦 Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
bun add buncord-hybrid-sharding
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 🛠️ Quick Start
|
|
25
|
+
|
|
26
|
+
### 1. The Manager (`cluster.js`)
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
import { ClusterManager, ReClusterManager, HeartbeatManager, DashboardServer } from 'buncord-hybrid-sharding';
|
|
30
|
+
|
|
31
|
+
const manager = new ClusterManager(`./bot.js`, {
|
|
32
|
+
totalShards: 'auto',
|
|
33
|
+
shardsPerClusters: 2,
|
|
34
|
+
mode: 'process', // Native Bun processes
|
|
35
|
+
token: 'YOUR_BOT_TOKEN',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Extend with Enterprise features
|
|
39
|
+
manager.extend(
|
|
40
|
+
new ReClusterManager(),
|
|
41
|
+
new HeartbeatManager({
|
|
42
|
+
redis: { host: 'localhost', port: 6379 },
|
|
43
|
+
interval: 10000,
|
|
44
|
+
}),
|
|
45
|
+
new DashboardServer({ port: 3001 })
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
manager.on('clusterCreate', (cluster) => console.log(`🚀 Launched Cluster ${cluster.id}`));
|
|
49
|
+
manager.spawn();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. The Client (`bot.js`)
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { ClusterClient, getInfo } from 'buncord-hybrid-sharding';
|
|
56
|
+
import { Client, GatewayIntentBits } from 'discord.js';
|
|
57
|
+
|
|
58
|
+
const client = new Client({
|
|
59
|
+
shards: getInfo().SHARD_LIST,
|
|
60
|
+
shardCount: getInfo().TOTAL_SHARDS,
|
|
61
|
+
intents: [GatewayIntentBits.Guilds],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
client.cluster = new ClusterClient(client);
|
|
65
|
+
|
|
66
|
+
client.on('ready', () => {
|
|
67
|
+
client.cluster.triggerReady();
|
|
68
|
+
console.log(`✅ Cluster ${client.cluster.id} is ready!`);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
client.login('YOUR_BOT_TOKEN');
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 📈 Monitoring API
|
|
77
|
+
|
|
78
|
+
The built-in `DashboardServer` provides a JSON API for monitoring and management:
|
|
79
|
+
|
|
80
|
+
- `GET /stats`: Unified metrics across all clusters.
|
|
81
|
+
- `POST /restart`: Trigger a rolling restart.
|
|
82
|
+
- `POST /maintenance`: Toggle maintenance mode.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 📝 License
|
|
87
|
+
|
|
88
|
+
This project is licensed under the MIT License. See the [LICENSE](file:///LICENSE) file for details.
|
|
89
|
+
Portions of this code are based on `discord.js` and `discord-hybrid-sharding`, copyright of their respective authors.
|
|
90
|
+
|
|
91
|
+
Developed with ❤️ by [Luigi Colantuono](https://github.com/LuigiColantuono).
|
package/bun.lock
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "buncord-hybrid-sharding",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"discord.js": "^14.25.1",
|
|
9
|
+
"redis": "^5.1.0",
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/bun": "latest",
|
|
13
|
+
"typescript": "^5.9.3",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"packages": {
|
|
18
|
+
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
|
|
19
|
+
|
|
20
|
+
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
|
|
21
|
+
|
|
22
|
+
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
|
|
23
|
+
|
|
24
|
+
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
|
|
25
|
+
|
|
26
|
+
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
|
27
|
+
|
|
28
|
+
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
|
|
29
|
+
|
|
30
|
+
"@redis/bloom": ["@redis/bloom@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A=="],
|
|
31
|
+
|
|
32
|
+
"@redis/client": ["@redis/client@5.10.0", "", { "dependencies": { "cluster-key-slot": "1.1.2" } }, "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA=="],
|
|
33
|
+
|
|
34
|
+
"@redis/json": ["@redis/json@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA=="],
|
|
35
|
+
|
|
36
|
+
"@redis/search": ["@redis/search@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg=="],
|
|
37
|
+
|
|
38
|
+
"@redis/time-series": ["@redis/time-series@5.10.0", "", { "peerDependencies": { "@redis/client": "^5.10.0" } }, "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg=="],
|
|
39
|
+
|
|
40
|
+
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
|
41
|
+
|
|
42
|
+
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
|
|
43
|
+
|
|
44
|
+
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
|
|
45
|
+
|
|
46
|
+
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
|
|
47
|
+
|
|
48
|
+
"@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
|
|
49
|
+
|
|
50
|
+
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
|
51
|
+
|
|
52
|
+
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
|
53
|
+
|
|
54
|
+
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
|
55
|
+
|
|
56
|
+
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
|
57
|
+
|
|
58
|
+
"discord-api-types": ["discord-api-types@0.38.37", "", {}, "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w=="],
|
|
59
|
+
|
|
60
|
+
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
|
|
61
|
+
|
|
62
|
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
|
63
|
+
|
|
64
|
+
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
|
65
|
+
|
|
66
|
+
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
|
67
|
+
|
|
68
|
+
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
|
69
|
+
|
|
70
|
+
"redis": ["redis@5.10.0", "", { "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", "@redis/json": "5.10.0", "@redis/search": "5.10.0", "@redis/time-series": "5.10.0" } }, "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw=="],
|
|
71
|
+
|
|
72
|
+
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
|
|
73
|
+
|
|
74
|
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
75
|
+
|
|
76
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
77
|
+
|
|
78
|
+
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
|
|
79
|
+
|
|
80
|
+
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
81
|
+
|
|
82
|
+
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
|
|
83
|
+
|
|
84
|
+
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
|
85
|
+
|
|
86
|
+
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
|
87
|
+
}
|
|
88
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "buncord-hybrid-sharding",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enterprise Bun-native sharding manager for Discord bots, featuring Redis heartbeats, rolling restarts, and integrated monitoring.",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "bun test",
|
|
10
|
+
"format": "bun format"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/LuigiColantuono/buncord-hybrid-sharding.git"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"discord",
|
|
21
|
+
"bun",
|
|
22
|
+
"sharding",
|
|
23
|
+
"clustering",
|
|
24
|
+
"hybrid-sharding",
|
|
25
|
+
"zero-downtime",
|
|
26
|
+
"redis"
|
|
27
|
+
],
|
|
28
|
+
"author": "Luigi Colantuono",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"discord.js": "^14.25.1",
|
|
32
|
+
"redis": "^5.1.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"typescript": "^5.9.3"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { Child } from "../Structures/Child.js";
|
|
5
|
+
import { ClusterHandler } from "../Structures/IPCHandler.js";
|
|
6
|
+
import { BaseMessage, IPCMessage, RawMessage } from "../Structures/IPCMessage.js";
|
|
7
|
+
import { ClusterEvents, ClusterKillOptions, DjsDiscordClient, messageType } from "../types/shared.js"; // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
8
|
+
import { delayFor, generateNonce } from "../Util/Util.js";
|
|
9
|
+
import { ClusterManager } from "./ClusterManager.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A self-contained cluster created by the {@link ClusterManager}. Each one has a {@link DjsDiscordClient} that contains
|
|
13
|
+
* an instance of the bot and its {@link DjsDiscordClient}. When its child process exits for any reason, the cluster will
|
|
14
|
+
* spawn a new one to replace it as necessary.
|
|
15
|
+
* @augments EventEmitter
|
|
16
|
+
*/
|
|
17
|
+
export class Cluster extends EventEmitter {
|
|
18
|
+
/**
|
|
19
|
+
* Manager that created the cluster
|
|
20
|
+
*/
|
|
21
|
+
manager: ClusterManager;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ID of the cluster in the manager
|
|
25
|
+
*/
|
|
26
|
+
id: number;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Arguments for the shard's process
|
|
30
|
+
*/
|
|
31
|
+
args: string[];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Arguments for the shard's process executable
|
|
35
|
+
*/
|
|
36
|
+
execArgv: string[];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Internal Shards which will get spawned in the cluster
|
|
40
|
+
*/
|
|
41
|
+
shardList: number[];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* the amount of real shards
|
|
45
|
+
*/
|
|
46
|
+
totalShards: number;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Environment variables for the cluster's process
|
|
50
|
+
*/
|
|
51
|
+
env: Record<string, any> & {
|
|
52
|
+
SHARD_LIST: number[];
|
|
53
|
+
TOTAL_SHARDS: number;
|
|
54
|
+
CLUSTER_MANAGER: boolean;
|
|
55
|
+
CLUSTER: number;
|
|
56
|
+
CLUSTER_COUNT: number;
|
|
57
|
+
DISCORD_TOKEN: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Process of the cluster
|
|
62
|
+
*/
|
|
63
|
+
thread: null | Child;
|
|
64
|
+
|
|
65
|
+
restarts: {
|
|
66
|
+
current: number;
|
|
67
|
+
max: number;
|
|
68
|
+
interval: number;
|
|
69
|
+
reset?: any;
|
|
70
|
+
resetRestarts: () => void;
|
|
71
|
+
cleanup: () => void;
|
|
72
|
+
append: () => void;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
messageHandler: any;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether the cluster's {@link DjsDiscordClient} is ready
|
|
79
|
+
*/
|
|
80
|
+
ready: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param manager Manager that is creating this cluster
|
|
84
|
+
* @param id ID of this cluster
|
|
85
|
+
* @param shardList
|
|
86
|
+
* @param totalShards
|
|
87
|
+
*/
|
|
88
|
+
constructor(manager: ClusterManager, id: number, shardList: number[], totalShards: number) {
|
|
89
|
+
super();
|
|
90
|
+
|
|
91
|
+
this.manager = manager;
|
|
92
|
+
|
|
93
|
+
this.id = id;
|
|
94
|
+
|
|
95
|
+
this.args = manager.shardArgs || [];
|
|
96
|
+
|
|
97
|
+
this.execArgv = manager.execArgv;
|
|
98
|
+
|
|
99
|
+
this.shardList = shardList;
|
|
100
|
+
|
|
101
|
+
this.totalShards = totalShards;
|
|
102
|
+
|
|
103
|
+
this.env = Object.assign({}, process.env, {
|
|
104
|
+
SHARD_LIST: this.shardList,
|
|
105
|
+
TOTAL_SHARDS: this.totalShards,
|
|
106
|
+
CLUSTER_MANAGER: true,
|
|
107
|
+
CLUSTER: this.id,
|
|
108
|
+
CLUSTER_COUNT: this.manager.totalClusters,
|
|
109
|
+
DISCORD_TOKEN: this.manager.token as string,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.ready = false;
|
|
113
|
+
|
|
114
|
+
this.thread = null;
|
|
115
|
+
|
|
116
|
+
this.restarts = {
|
|
117
|
+
current: this.manager.restarts.current ?? 0,
|
|
118
|
+
max: this.manager.restarts.max,
|
|
119
|
+
interval: this.manager.restarts.interval,
|
|
120
|
+
reset: undefined,
|
|
121
|
+
resetRestarts: () => {
|
|
122
|
+
this.restarts.reset = setInterval(() => {
|
|
123
|
+
this.restarts.current = 0;
|
|
124
|
+
}, this.manager.restarts.interval);
|
|
125
|
+
},
|
|
126
|
+
cleanup: () => {
|
|
127
|
+
if (this.restarts.reset) clearInterval(this.restarts.reset);
|
|
128
|
+
},
|
|
129
|
+
append: () => {
|
|
130
|
+
this.restarts.current++;
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Spawns a child process for the cluster.
|
|
136
|
+
* <warn>You should not need to call this manually.</warn>
|
|
137
|
+
* @param spawnTimeout The amount in milliseconds to wait until the {@link DjsDiscordClient} has become ready
|
|
138
|
+
* before resolving. (-1 or Infinity for no wait)
|
|
139
|
+
*/
|
|
140
|
+
public async spawn(spawnTimeout = -1) {
|
|
141
|
+
if (this.thread) throw new Error('CLUSTER ALREADY SPAWNED | ClusterId: ' + this.id);
|
|
142
|
+
this.thread = new Child(path.resolve(this.manager.file), {
|
|
143
|
+
...this.manager.clusterOptions,
|
|
144
|
+
env: this.env as any,
|
|
145
|
+
/** Construct args with hooks, to provide parameters with in the context of a cluster */
|
|
146
|
+
args: this.manager.hooks.constructClusterArgs(this, this.args),
|
|
147
|
+
clusterData: { ...this.env, ...this.manager.clusterData },
|
|
148
|
+
});
|
|
149
|
+
this.messageHandler = new ClusterHandler(this.manager, this, this.thread);
|
|
150
|
+
|
|
151
|
+
this.thread
|
|
152
|
+
.spawn()
|
|
153
|
+
.on('message', this._handleMessage.bind(this))
|
|
154
|
+
.on('exit', this._handleExit.bind(this))
|
|
155
|
+
.on('error', this._handleError.bind(this));
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Emitted upon the creation of the cluster's child process.
|
|
159
|
+
* @event Cluster#spawn
|
|
160
|
+
* @param {Child} process Child process that was created
|
|
161
|
+
*/
|
|
162
|
+
this.emit('spawn', this.thread.process);
|
|
163
|
+
|
|
164
|
+
await new Promise((resolve, reject) => {
|
|
165
|
+
let spawnTimeoutTimer: any | undefined = undefined;
|
|
166
|
+
|
|
167
|
+
const cleanup = (death = false) => {
|
|
168
|
+
clearTimeout(spawnTimeoutTimer);
|
|
169
|
+
|
|
170
|
+
// Remove listeners if cluster died to prevent event emitter leaks
|
|
171
|
+
if (death) {
|
|
172
|
+
this.off('ready', onReady);
|
|
173
|
+
this.off('death', onDeath);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const onReady = () => {
|
|
178
|
+
this.manager.emit('clusterReady', this);
|
|
179
|
+
this.restarts.cleanup();
|
|
180
|
+
this.restarts.resetRestarts();
|
|
181
|
+
cleanup();
|
|
182
|
+
resolve('Cluster is ready');
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const onDeath = () => {
|
|
186
|
+
cleanup(true);
|
|
187
|
+
reject(new Error('CLUSTERING_READY_DIED | ClusterId: ' + this.id));
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const onTimeout = () => {
|
|
191
|
+
cleanup();
|
|
192
|
+
reject(new Error('CLUSTERING_READY_TIMEOUT | ClusterId: ' + this.id));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// If there is a spawn timeout wait and error if cluster does not get ready
|
|
196
|
+
if (spawnTimeout !== -1 && spawnTimeout !== Infinity) {
|
|
197
|
+
spawnTimeoutTimer = setTimeout(onTimeout, spawnTimeout);
|
|
198
|
+
} else {
|
|
199
|
+
// No timeout, next cluster will be spawned, without waiting for ready
|
|
200
|
+
resolve('Skipping ready check');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.once('ready', onReady);
|
|
204
|
+
this.once('death', onDeath);
|
|
205
|
+
});
|
|
206
|
+
return this.thread.process;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Immediately kills the clusters process and does not restart it.
|
|
210
|
+
* @param options Some Options for managing the Kill
|
|
211
|
+
* @param options.force Whether the Cluster should be force kill and be ever respawned...
|
|
212
|
+
*/
|
|
213
|
+
public kill(options: ClusterKillOptions) {
|
|
214
|
+
this.thread?.kill();
|
|
215
|
+
if (this.thread) {
|
|
216
|
+
this.thread = null;
|
|
217
|
+
}
|
|
218
|
+
// Heartbeat will automatically detect death via Redis TTL Key expiration
|
|
219
|
+
this.restarts.cleanup();
|
|
220
|
+
this.manager._debug('[KILL] Cluster killed with reason: ' + (options?.reason || 'not given'), this.id);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Kills and restarts the cluster's process.
|
|
224
|
+
* @param options Options for respawning the cluster
|
|
225
|
+
*/
|
|
226
|
+
public async respawn({ delay = 5500, timeout = -1 } = this.manager.spawnOptions) {
|
|
227
|
+
if (this.thread) this.kill({ force: true });
|
|
228
|
+
if (delay > 0) await delayFor(delay);
|
|
229
|
+
// Heartbeat will automatically detect death via Redis TTL Key expiration
|
|
230
|
+
return this.spawn(timeout);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Sends a message to the cluster's process.
|
|
234
|
+
* @param message Message to send to the cluster
|
|
235
|
+
*/
|
|
236
|
+
public send(message: RawMessage) {
|
|
237
|
+
if (typeof message === 'object') this.thread?.send(new BaseMessage(message).toJSON());
|
|
238
|
+
else return this.thread?.send(message);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sends a Request to the ClusterClient and returns the reply
|
|
243
|
+
* @param message Message, which should be sent as request
|
|
244
|
+
* @returns Reply of the Message
|
|
245
|
+
* @example
|
|
246
|
+
* client.cluster.request({content: 'hello'})
|
|
247
|
+
* .then(result => console.log(result)) //hi
|
|
248
|
+
* .catch(console.error);
|
|
249
|
+
* @see {@link IPCMessage#reply}
|
|
250
|
+
*/
|
|
251
|
+
public request(message: RawMessage) {
|
|
252
|
+
message._type = messageType.CUSTOM_REQUEST;
|
|
253
|
+
this.send(message);
|
|
254
|
+
return this.manager.promise.create(message, message.options);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Evaluates a script or function on the cluster, in the context of the {@link DjsDiscordClient}.
|
|
258
|
+
* @param script JavaScript to run on the cluster
|
|
259
|
+
* @param context
|
|
260
|
+
* @param timeout
|
|
261
|
+
* @returns Result of the script execution
|
|
262
|
+
*/
|
|
263
|
+
public async eval(script: string, context: any, timeout: number) {
|
|
264
|
+
// Stringify the script if it's a Function
|
|
265
|
+
const _eval = typeof script === 'function' ? `(${script})(this, ${JSON.stringify(context)})` : script;
|
|
266
|
+
|
|
267
|
+
// cluster is dead (maybe respawning), don't cache anything and error immediately
|
|
268
|
+
if (!this.thread) return Promise.reject(new Error('CLUSTERING_NO_CHILD_EXISTS | ClusterId: ' + this.id));
|
|
269
|
+
const nonce = generateNonce();
|
|
270
|
+
const message = { nonce, _eval, options: { timeout }, _type: messageType.CLIENT_EVAL_REQUEST };
|
|
271
|
+
await this.send(message);
|
|
272
|
+
return await this.manager.promise.create(message, message.options);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param reason If maintenance should be enabled with a given reason or disabled when nonce provided
|
|
277
|
+
*/
|
|
278
|
+
public triggerMaintenance(reason?: string) {
|
|
279
|
+
const _type = reason ? messageType.CLIENT_MAINTENANCE_ENABLE : messageType.CLIENT_MAINTENANCE_DISABLE;
|
|
280
|
+
return this.send({ _type, maintenance: reason });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Handles a message received from the child process.
|
|
285
|
+
* @param message Message received
|
|
286
|
+
* @private
|
|
287
|
+
*/
|
|
288
|
+
private _handleMessage(message: any) {
|
|
289
|
+
if (!message) return;
|
|
290
|
+
const emit = this.messageHandler.handleMessage(message);
|
|
291
|
+
if (!emit) return;
|
|
292
|
+
|
|
293
|
+
let emitMessage;
|
|
294
|
+
if (typeof message === 'object') {
|
|
295
|
+
emitMessage = new IPCMessage(this, message);
|
|
296
|
+
if (emitMessage._type === messageType.CUSTOM_REQUEST) this.manager.emit('clientRequest', emitMessage);
|
|
297
|
+
} else emitMessage = message;
|
|
298
|
+
/**
|
|
299
|
+
* Emitted upon receiving a message from the child process.
|
|
300
|
+
* @event Shard#message
|
|
301
|
+
* @param {*|IPCMessage} message Message that was received
|
|
302
|
+
*/
|
|
303
|
+
this.emit('message', emitMessage);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Handles the cluster's process exiting.
|
|
308
|
+
* @private
|
|
309
|
+
* @param {Number} exitCode
|
|
310
|
+
*/
|
|
311
|
+
private _handleExit(exitCode: number) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
|
312
|
+
/**
|
|
313
|
+
* Emitted upon the cluster's child process exiting.
|
|
314
|
+
* @event Cluster#death
|
|
315
|
+
* @param {Child} process Child process that exited
|
|
316
|
+
*/
|
|
317
|
+
|
|
318
|
+
const respawn = this.manager.respawn;
|
|
319
|
+
|
|
320
|
+
// Cleanup functions
|
|
321
|
+
this.manager.heartbeat?.stop(); // This is just a placeholder if needed, but actually the manager-side heartbeat stops automatically or doesn't need per-cluster stop anymore.
|
|
322
|
+
this.restarts.cleanup();
|
|
323
|
+
|
|
324
|
+
this.emit('death', this, this.thread?.process);
|
|
325
|
+
|
|
326
|
+
this.manager._debug(
|
|
327
|
+
'[DEATH] Cluster died, attempting respawn | Restarts Left: ' + (this.restarts.max - this.restarts.current),
|
|
328
|
+
this.id,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
this.ready = false;
|
|
332
|
+
|
|
333
|
+
this.thread = null;
|
|
334
|
+
|
|
335
|
+
if (!respawn) return;
|
|
336
|
+
|
|
337
|
+
if (this.restarts.current >= this.restarts.max)
|
|
338
|
+
this.manager._debug(
|
|
339
|
+
'[ATTEMPTED_RESPAWN] Attempted Respawn Declined | Max Restarts have been exceeded',
|
|
340
|
+
this.id,
|
|
341
|
+
);
|
|
342
|
+
if (respawn && this.restarts.current < this.restarts.max) this.spawn().catch(err => this.emit('error', err));
|
|
343
|
+
|
|
344
|
+
this.restarts.append();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Handles the cluster's process error.
|
|
349
|
+
* @param error the error, which occurred on the child process
|
|
350
|
+
* @private
|
|
351
|
+
*/
|
|
352
|
+
private _handleError(error: Error) {
|
|
353
|
+
/**
|
|
354
|
+
* Emitted upon the cluster's child process error.
|
|
355
|
+
* @event Cluster#error
|
|
356
|
+
* @param {Child} process Child process, where error occurred
|
|
357
|
+
*/
|
|
358
|
+
this.manager.emit('error', error);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Credits for EventEmitter typings: https://github.com/discordjs/discord.js/blob/main/packages/rest/src/lib/RequestManager.ts#L159 | See attached license
|
|
363
|
+
export interface Cluster {
|
|
364
|
+
emit: (<K extends keyof ClusterEvents>(event: K, ...args: ClusterEvents[K]) => boolean) &
|
|
365
|
+
(<S extends string | symbol>(event: Exclude<S, keyof ClusterEvents>, ...args: any[]) => boolean);
|
|
366
|
+
|
|
367
|
+
off: (<K extends keyof ClusterEvents>(event: K, listener: (...args: ClusterEvents[K]) => void) => this) &
|
|
368
|
+
(<S extends string | symbol>(
|
|
369
|
+
event: Exclude<S, keyof ClusterEvents>,
|
|
370
|
+
listener: (...args: any[]) => void,
|
|
371
|
+
) => this);
|
|
372
|
+
|
|
373
|
+
on: (<K extends keyof ClusterEvents>(event: K, listener: (...args: ClusterEvents[K]) => void) => this) &
|
|
374
|
+
(<S extends string | symbol>(
|
|
375
|
+
event: Exclude<S, keyof ClusterEvents>,
|
|
376
|
+
listener: (...args: any[]) => void,
|
|
377
|
+
) => this);
|
|
378
|
+
|
|
379
|
+
once: (<K extends keyof ClusterEvents>(event: K, listener: (...args: ClusterEvents[K]) => void) => this) &
|
|
380
|
+
(<S extends string | symbol>(
|
|
381
|
+
event: Exclude<S, keyof ClusterEvents>,
|
|
382
|
+
listener: (...args: any[]) => void,
|
|
383
|
+
) => this);
|
|
384
|
+
|
|
385
|
+
removeAllListeners: (<K extends keyof ClusterEvents>(event?: K) => this) &
|
|
386
|
+
(<S extends string | symbol>(event?: Exclude<S, keyof ClusterEvents>) => this);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Credits for EventEmitter typings: https://github.com/discordjs/discord.js/blob/main/packages/rest/src/lib/RequestManager.ts#L159 | See attached license
|
|
390
|
+
export interface Cluster {
|
|
391
|
+
emit: (<K extends keyof ClusterEvents>(event: K, ...args: ClusterEvents[K]) => boolean) &
|
|
392
|
+
(<S extends string | symbol>(event: Exclude<S, keyof ClusterEvents>, ...args: any[]) => boolean);
|
|
393
|
+
|
|
394
|
+
off: (<K extends keyof ClusterEvents>(event: K, listener: (...args: ClusterEvents[K]) => void) => this) &
|
|
395
|
+
(<S extends string | symbol>(
|
|
396
|
+
event: Exclude<S, keyof ClusterEvents>,
|
|
397
|
+
listener: (...args: any[]) => void,
|
|
398
|
+
) => this);
|
|
399
|
+
|
|
400
|
+
on: (<K extends keyof ClusterEvents>(event: K, listener: (...args: ClusterEvents[K]) => void) => this) &
|
|
401
|
+
(<S extends string | symbol>(
|
|
402
|
+
event: Exclude<S, keyof ClusterEvents>,
|
|
403
|
+
listener: (...args: any[]) => void,
|
|
404
|
+
) => this);
|
|
405
|
+
|
|
406
|
+
once: (<K extends keyof ClusterEvents>(event: K, listener: (...args: ClusterEvents[K]) => void) => this) &
|
|
407
|
+
(<S extends string | symbol>(
|
|
408
|
+
event: Exclude<S, keyof ClusterEvents>,
|
|
409
|
+
listener: (...args: any[]) => void,
|
|
410
|
+
) => this);
|
|
411
|
+
|
|
412
|
+
removeAllListeners: (<K extends keyof ClusterEvents>(event?: K) => this) &
|
|
413
|
+
(<S extends string | symbol>(event?: Exclude<S, keyof ClusterEvents>) => this);
|
|
414
|
+
}
|