lunibee 0.3.0 → 0.3.1

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.
Files changed (38) hide show
  1. package/dist/builders/embed.d.ts +1 -0
  2. package/dist/builders/index.js +6 -2
  3. package/dist/builders/index.js.map +4 -4
  4. package/dist/core/index.js +66 -15
  5. package/dist/core/index.js.map +8 -8
  6. package/dist/index.js +2345 -199
  7. package/dist/index.js.map +22 -14
  8. package/dist/managers/index.js +2 -2
  9. package/dist/managers/index.js.map +3 -3
  10. package/dist/rest/errors.d.ts +8 -0
  11. package/dist/rest/index.d.ts +2 -0
  12. package/dist/rest/index.js +65 -14
  13. package/dist/rest/index.js.map +5 -5
  14. package/dist/rest/transport.d.ts +9 -0
  15. package/dist/sharding/bus.d.ts +8 -0
  16. package/dist/sharding/cluster.d.ts +224 -14
  17. package/dist/sharding/context.d.ts +41 -0
  18. package/dist/sharding/gateway-bot.d.ts +20 -0
  19. package/dist/sharding/health.d.ts +94 -0
  20. package/dist/sharding/host.d.ts +70 -0
  21. package/dist/sharding/identify.d.ts +62 -0
  22. package/dist/sharding/index.d.ts +56 -12
  23. package/dist/sharding/index.js +2255 -164
  24. package/dist/sharding/index.js.map +18 -10
  25. package/dist/sharding/protocol.d.ts +47 -0
  26. package/dist/sharding/remote-protocol.d.ts +100 -0
  27. package/dist/sharding/remote.d.ts +118 -0
  28. package/dist/sharding/requests.d.ts +54 -0
  29. package/dist/sharding/worker.d.ts +45 -0
  30. package/dist/structures/index.js +2 -2
  31. package/dist/structures/index.js.map +3 -3
  32. package/dist/types/gateway.d.ts +3 -0
  33. package/dist/types/index.js.map +1 -1
  34. package/dist/ws/index.d.ts +1 -1
  35. package/dist/ws/index.js +2 -2
  36. package/dist/ws/index.js.map +5 -5
  37. package/dist/ws/send-budget.d.ts +1 -1
  38. package/package.json +18 -16
@@ -2,7 +2,7 @@
2
2
  // packages/sharding/package.json
3
3
  var package_default = {
4
4
  name: "@lunibee/sharding",
5
- version: "0.3.0",
5
+ version: "0.3.1",
6
6
  type: "module",
7
7
  module: "./src/index.ts",
8
8
  types: "./src/index.ts",
@@ -38,6 +38,37 @@ async function fetchGatewayBot(token) {
38
38
  };
39
39
  return info;
40
40
  }
41
+ var MAX_GUILDS_PER_SHARD = 2500;
42
+ function assertGuildsPerShard(value) {
43
+ if (!Number.isInteger(value) || value < 1 || value > MAX_GUILDS_PER_SHARD)
44
+ throw new RangeError(`guildsPerShard must be an integer from 1 to ${MAX_GUILDS_PER_SHARD}.`);
45
+ }
46
+ function shardsForGuilds(guilds, guildsPerShard) {
47
+ assertGuildsPerShard(guildsPerShard);
48
+ if (!Number.isInteger(guilds) || guilds < 0)
49
+ throw new RangeError("guildCount must be a non-negative integer.");
50
+ return Math.max(1, Math.ceil(guilds / guildsPerShard));
51
+ }
52
+ async function fetchGuildCount(token) {
53
+ const response = await fetch("https://discord.com/api/v10/applications/@me", {
54
+ headers: {
55
+ Authorization: `Bot ${token}`,
56
+ "User-Agent": `Lunibee/${package_default.version}`
57
+ }
58
+ });
59
+ if (!response.ok)
60
+ throw new Error(`Guild count discovery failed with status ${response.status}`);
61
+ const data = await response.json();
62
+ const count = data.approximate_guild_count;
63
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0)
64
+ throw new Error("Discord did not report a guild count.");
65
+ return count;
66
+ }
67
+ async function resolveShardCount(token, info, options) {
68
+ if (options.guildsPerShard === undefined)
69
+ return info.shards;
70
+ return shardsForGuilds(options.guildCount ?? await fetchGuildCount(token), options.guildsPerShard);
71
+ }
41
72
 
42
73
  // packages/ws/src/session.ts
43
74
  class GatewaySession {
@@ -1036,7 +1067,7 @@ class Gateway {
1036
1067
  }
1037
1068
  connect(url = DEFAULT_GATEWAY_URL) {
1038
1069
  if (this.state === "CLOSED" /* Closed */)
1039
- throw new GatewayError("Gateway has been permanently closed.");
1070
+ return Promise.reject(new GatewayError("Gateway has been permanently closed."));
1040
1071
  const inFlight = this.#reconnect.attemptPromise;
1041
1072
  if (inFlight)
1042
1073
  return inFlight;
@@ -1310,6 +1341,238 @@ class Gateway {
1310
1341
  this.#emit("stateChange", { previous, next });
1311
1342
  }
1312
1343
  }
1344
+
1345
+ // packages/sharding/src/context.ts
1346
+ var WORKER_MARKER = "LUNIBEE_CLUSTER_WORKER";
1347
+ function isClusterWorker(env = process.env) {
1348
+ return env[WORKER_MARKER] === "1";
1349
+ }
1350
+ function validateShardIds(shardIds, total) {
1351
+ if (!Number.isInteger(total) || total < 1)
1352
+ throw new RangeError("Shard count must be a positive integer.");
1353
+ const seen = new Set;
1354
+ for (const id of shardIds) {
1355
+ if (!Number.isInteger(id) || id < 0 || id >= total)
1356
+ throw new RangeError(`Shard ID ${String(id)} is outside 0-${total - 1}.`);
1357
+ if (seen.has(id))
1358
+ throw new RangeError(`Shard ID ${id} is listed twice.`);
1359
+ seen.add(id);
1360
+ }
1361
+ }
1362
+ function allocateShards(shardIds, clusterCount) {
1363
+ if (!Number.isInteger(clusterCount) || clusterCount < 1)
1364
+ throw new RangeError("clusterCount must be a positive integer.");
1365
+ const chunks = Array.from({ length: clusterCount }, () => []);
1366
+ shardIds.forEach((id, index) => chunks[index % clusterCount].push(id));
1367
+ return chunks;
1368
+ }
1369
+ function assertCompleteAllocation(chunks, expected) {
1370
+ const owners = new Map;
1371
+ const duplicate = [];
1372
+ chunks.forEach((chunk, cluster) => {
1373
+ for (const id of chunk) {
1374
+ if (owners.has(id))
1375
+ duplicate.push(id);
1376
+ owners.set(id, cluster);
1377
+ }
1378
+ });
1379
+ const want = new Set(expected);
1380
+ const missing = expected.filter((id) => !owners.has(id));
1381
+ const extra = [...owners.keys()].filter((id) => !want.has(id));
1382
+ if (duplicate.length || missing.length || extra.length)
1383
+ throw new Error(`Shard allocation is not exact: duplicate [${duplicate}], missing [${missing}], unexpected [${extra}].`);
1384
+ }
1385
+ function parseInteger(name, value) {
1386
+ if (value === undefined || !/^\d+$/.test(value))
1387
+ throw new RangeError(`${name} must be a non-negative integer.`);
1388
+ return Number(value);
1389
+ }
1390
+ function readClusterContext(env = process.env) {
1391
+ if (!isClusterWorker(env))
1392
+ return null;
1393
+ const shardCount = parseInteger("SHARD_COUNT", env.SHARD_COUNT);
1394
+ const shardIds = (env.SHARD_LIST ?? "").split(",").filter((part) => part !== "").map((part) => parseInteger("SHARD_LIST", part));
1395
+ validateShardIds(shardIds, shardCount);
1396
+ return {
1397
+ clusterId: parseInteger("CLUSTER_ID", env.CLUSTER_ID),
1398
+ clusterCount: parseInteger("CLUSTER_COUNT", env.CLUSTER_COUNT),
1399
+ shardCount,
1400
+ shardIds,
1401
+ coordinatedIdentify: env.LUNIBEE_IDENTIFY === "coordinated"
1402
+ };
1403
+ }
1404
+
1405
+ // packages/sharding/src/protocol.ts
1406
+ var CLUSTER_FRAME = "__lunibeeCluster";
1407
+ var PARENT_ID = -1;
1408
+ var REQUEST_HEALTH = "__lunibee:health";
1409
+ var REQUEST_ASSIGN = "__lunibee:assign";
1410
+ var isRecord = (value) => typeof value === "object" && value !== null;
1411
+ var isShardId = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
1412
+ function parseChildFrame(value) {
1413
+ if (!isRecord(value))
1414
+ return null;
1415
+ switch (value.kind) {
1416
+ case "ready":
1417
+ return Array.isArray(value.shardIds) && value.shardIds.every(isShardId) ? { kind: "ready", shardIds: value.shardIds } : null;
1418
+ case "identify":
1419
+ return typeof value.id === "number" && isShardId(value.shardId) ? { kind: "identify", id: value.id, shardId: value.shardId } : null;
1420
+ case "identify-done":
1421
+ return isShardId(value.shardId) ? { kind: "identify-done", shardId: value.shardId } : null;
1422
+ case "reply":
1423
+ return typeof value.id === "string" && (value.error === undefined || typeof value.error === "string") ? {
1424
+ kind: "reply",
1425
+ id: value.id,
1426
+ ...value.error !== undefined ? { error: value.error } : { result: value.result }
1427
+ } : null;
1428
+ case "health":
1429
+ return isRecord(value.report) && Array.isArray(value.report.shards) && typeof value.report.reportedAt === "number" ? { kind: "health", report: value.report } : null;
1430
+ default:
1431
+ return null;
1432
+ }
1433
+ }
1434
+ function parseParentFrame(value) {
1435
+ if (!isRecord(value))
1436
+ return null;
1437
+ switch (value.kind) {
1438
+ case "identify-grant":
1439
+ return typeof value.id === "number" ? { kind: "identify-grant", id: value.id } : null;
1440
+ case "identify-deny":
1441
+ return typeof value.id === "number" && typeof value.error === "string" ? { kind: "identify-deny", id: value.id, error: value.error } : null;
1442
+ case "request":
1443
+ return typeof value.id === "string" && typeof value.type === "string" ? {
1444
+ kind: "request",
1445
+ id: value.id,
1446
+ type: value.type,
1447
+ data: value.data
1448
+ } : null;
1449
+ default:
1450
+ return null;
1451
+ }
1452
+ }
1453
+
1454
+ // packages/sharding/src/worker.ts
1455
+ class ClusterWorker {
1456
+ static #current;
1457
+ static current() {
1458
+ if (!ClusterWorker.#current) {
1459
+ const context = readClusterContext();
1460
+ if (context && typeof process.send === "function")
1461
+ ClusterWorker.#current = new ClusterWorker(context, process);
1462
+ }
1463
+ return ClusterWorker.#current;
1464
+ }
1465
+ static reset() {
1466
+ ClusterWorker.#current?.close();
1467
+ ClusterWorker.#current = undefined;
1468
+ }
1469
+ context;
1470
+ #endpoint;
1471
+ #handlers = new Map;
1472
+ #waiting = new Map;
1473
+ #onMessage;
1474
+ #onDisconnect;
1475
+ #counter = 0;
1476
+ #closed = false;
1477
+ constructor(context, endpoint = process) {
1478
+ if (typeof endpoint.send !== "function")
1479
+ throw new Error("ClusterWorker needs an IPC channel: run it in a process forked by ClusterManager.");
1480
+ this.context = context;
1481
+ this.#endpoint = endpoint;
1482
+ this.#onMessage = (frame) => this.#handle(frame);
1483
+ this.#onDisconnect = () => this.#failAll("The parent went away.");
1484
+ endpoint.on("message", this.#onMessage);
1485
+ endpoint.on("disconnect", this.#onDisconnect);
1486
+ }
1487
+ get identifyGate() {
1488
+ return this.context.coordinatedIdentify ? this.#gate : undefined;
1489
+ }
1490
+ #gate = {
1491
+ acquire: (shardId) => {
1492
+ if (this.#closed)
1493
+ return Promise.reject(new Error("The cluster worker is closed."));
1494
+ const id = ++this.#counter;
1495
+ return new Promise((resolve, reject) => {
1496
+ this.#waiting.set(id, { resolve, reject });
1497
+ this.#send({ kind: "identify", id, shardId });
1498
+ });
1499
+ },
1500
+ release: (shardId) => this.#send({ kind: "identify-done", shardId })
1501
+ };
1502
+ ready(shardIds = this.context.shardIds) {
1503
+ this.#send({ kind: "ready", shardIds: [...shardIds] });
1504
+ }
1505
+ reportHealth(report) {
1506
+ this.#send({ kind: "health", report });
1507
+ }
1508
+ onRequest(type, handler) {
1509
+ if (!type.trim() || typeof handler !== "function")
1510
+ throw new TypeError("A request type and handler are required.");
1511
+ this.#handlers.set(type, handler);
1512
+ return this;
1513
+ }
1514
+ close() {
1515
+ this.#closed = true;
1516
+ this.#endpoint.off("message", this.#onMessage);
1517
+ this.#endpoint.off("disconnect", this.#onDisconnect);
1518
+ this.#failAll("The cluster worker closed.");
1519
+ }
1520
+ #send(body) {
1521
+ try {
1522
+ this.#endpoint.send({ [CLUSTER_FRAME]: body });
1523
+ } catch (error) {
1524
+ this.#failAll(error instanceof Error ? error.message : String(error));
1525
+ }
1526
+ }
1527
+ #failAll(reason) {
1528
+ for (const waiter of this.#waiting.values())
1529
+ waiter.reject(new Error(reason));
1530
+ this.#waiting.clear();
1531
+ }
1532
+ #handle(frame) {
1533
+ if (!frame || typeof frame !== "object" || !(CLUSTER_FRAME in frame))
1534
+ return;
1535
+ const parsed = parseParentFrame(frame[CLUSTER_FRAME]);
1536
+ if (!parsed)
1537
+ return;
1538
+ if (parsed.kind === "identify-grant" || parsed.kind === "identify-deny") {
1539
+ const waiter = this.#waiting.get(parsed.id);
1540
+ if (!waiter)
1541
+ return;
1542
+ this.#waiting.delete(parsed.id);
1543
+ if (parsed.kind === "identify-grant")
1544
+ waiter.resolve();
1545
+ else
1546
+ waiter.reject(new Error(parsed.error));
1547
+ return;
1548
+ }
1549
+ const handler = this.#handlers.get(parsed.type);
1550
+ const id = parsed.id;
1551
+ if (!handler) {
1552
+ this.#send({
1553
+ kind: "reply",
1554
+ id,
1555
+ error: `No handler for "${parsed.type}".`
1556
+ });
1557
+ return;
1558
+ }
1559
+ (async () => {
1560
+ try {
1561
+ this.#send({
1562
+ kind: "reply",
1563
+ id,
1564
+ result: await handler(parsed.data)
1565
+ });
1566
+ } catch (error) {
1567
+ this.#send({
1568
+ kind: "reply",
1569
+ id,
1570
+ error: error instanceof Error ? error.message : String(error)
1571
+ });
1572
+ }
1573
+ })();
1574
+ }
1575
+ }
1313
1576
  // packages/sharding/src/transport.ts
1314
1577
  class BroadcastChannelTransport {
1315
1578
  #channel;
@@ -1370,6 +1633,13 @@ function defaultTransport(name) {
1370
1633
 
1371
1634
  // packages/sharding/src/bus.ts
1372
1635
  var REPLY_TYPE = "__lunibee:reply";
1636
+ var MAX_NAME_LENGTH = 256;
1637
+ function isShardMessage(message) {
1638
+ if (!message || typeof message !== "object")
1639
+ return false;
1640
+ const m = message;
1641
+ return typeof m.type === "string" && m.type.length > 0 && m.type.length <= MAX_NAME_LENGTH && typeof m.id === "string" && m.id.length <= MAX_NAME_LENGTH && Number.isInteger(m.source) && m.source >= -1 && (m.target === null || Number.isInteger(m.target) && m.target >= -1);
1642
+ }
1373
1643
 
1374
1644
  class ShardBus {
1375
1645
  #transport;
@@ -1381,6 +1651,7 @@ class ShardBus {
1381
1651
  channelName;
1382
1652
  #counter = 0;
1383
1653
  #pending = new Map;
1654
+ #closed = false;
1384
1655
  constructor(shardId, channelName, options = {}) {
1385
1656
  if (!Number.isInteger(shardId) || shardId < 0)
1386
1657
  throw new RangeError("Shard ID must be a non-negative integer.");
@@ -1453,6 +1724,8 @@ class ShardBus {
1453
1724
  }
1454
1725
  #collect(target, type, data, timeoutMs, expected, rejectOnPeerDown = false) {
1455
1726
  const replies = [];
1727
+ if (this.#closed)
1728
+ return Promise.reject(new Error("ShardBus is closed."));
1456
1729
  if (this.#pending.size >= this.#maxPending)
1457
1730
  return Promise.reject(new Error(`Too many shard requests waiting for a reply (${this.#maxPending}).`));
1458
1731
  return new Promise((resolve, reject) => {
@@ -1464,24 +1737,33 @@ class ShardBus {
1464
1737
  };
1465
1738
  const timer = setTimeout(finish, timeoutMs);
1466
1739
  requestId = this.#publish(target, type, data, true);
1740
+ const fail = (error) => {
1741
+ if (!rejectOnPeerDown)
1742
+ return;
1743
+ clearTimeout(timer);
1744
+ this.#pending.delete(requestId);
1745
+ reject(error);
1746
+ };
1467
1747
  this.#pending.set(requestId, {
1468
1748
  target,
1749
+ seen: new Set,
1469
1750
  reply: (reply) => {
1470
1751
  replies.push(reply);
1471
1752
  if (replies.length >= expected)
1472
1753
  finish();
1473
1754
  },
1474
- fail: (error) => {
1475
- if (rejectOnPeerDown) {
1476
- clearTimeout(timer);
1477
- this.#pending.delete(requestId);
1478
- reject(error);
1479
- }
1480
- }
1755
+ fail,
1756
+ cancel: (error) => rejectOnPeerDown ? fail(error) : finish()
1481
1757
  });
1482
1758
  });
1483
1759
  }
1484
1760
  close() {
1761
+ if (this.#closed)
1762
+ return;
1763
+ this.#closed = true;
1764
+ for (const pending of [...this.#pending.values()])
1765
+ pending.cancel(new Error("ShardBus is closed."));
1766
+ this.#pending.clear();
1485
1767
  this.#transport.close();
1486
1768
  this.#handlers.clear();
1487
1769
  this.#errorHandlers.clear();
@@ -1489,6 +1771,8 @@ class ShardBus {
1489
1771
  #publish(target, type, data, expectsReply = false) {
1490
1772
  if (!type.trim())
1491
1773
  throw new TypeError("Shard message type is required.");
1774
+ if (this.#closed)
1775
+ throw new Error("ShardBus is closed.");
1492
1776
  const id = `${this.#namespace}:${this.#shardId}:${++this.#counter}`;
1493
1777
  this.#transport.post({
1494
1778
  source: this.#shardId,
@@ -1501,15 +1785,21 @@ class ShardBus {
1501
1785
  return id;
1502
1786
  }
1503
1787
  #dispatch(message) {
1504
- if (!message || typeof message !== "object" || typeof message.type !== "string")
1788
+ if (this.#closed || !isShardMessage(message))
1505
1789
  return;
1506
1790
  if (message.source === this.#shardId || message.target !== null && message.target !== this.#shardId)
1507
1791
  return;
1508
1792
  if (message.type === REPLY_TYPE) {
1509
1793
  const reply = message.data;
1510
- this.#pending.get(reply?.requestId)?.reply({
1794
+ if (!reply || typeof reply !== "object" || typeof reply.requestId !== "string")
1795
+ return;
1796
+ const pending = this.#pending.get(reply.requestId);
1797
+ if (!pending || pending.seen.has(message.source) || pending.target !== null && pending.target !== message.source)
1798
+ return;
1799
+ pending.seen.add(message.source);
1800
+ pending.reply({
1511
1801
  shardId: message.source,
1512
- ...reply.error !== undefined ? { error: reply.error } : { result: reply.result }
1802
+ ...reply.error !== undefined ? { error: String(reply.error) } : { result: reply.result }
1513
1803
  });
1514
1804
  return;
1515
1805
  }
@@ -1590,163 +1880,1523 @@ class ShardSupervisor {
1590
1880
  // packages/sharding/src/cluster.ts
1591
1881
  import { fork } from "child_process";
1592
1882
  import { cpus } from "os";
1593
- var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1594
1883
 
1595
- class ClusterManager {
1596
- clusters = new Map;
1597
- shardCount = 0;
1598
- #options;
1599
- #auto;
1600
- #autoScaleTimer;
1601
- #spawned = false;
1602
- #stopping = false;
1603
- #restartTimers = new Set;
1604
- #supervisor;
1605
- constructor(options) {
1606
- if (!options.token?.trim())
1607
- throw new TypeError("A Discord bot token is required.");
1608
- if (!options.script?.trim())
1609
- throw new TypeError("A script path is required for clustering.");
1610
- if (options.autoScaleInterval !== undefined && (!Number.isInteger(options.autoScaleInterval) || options.autoScaleInterval < 1000)) {
1611
- throw new RangeError("autoScaleInterval must be an integer of at least 1000 milliseconds.");
1612
- }
1613
- this.#auto = options.shardCount === "auto";
1614
- this.#options = { ...options };
1615
- this.#supervisor = new ShardSupervisor({
1616
- restartDelay: options.restartDelay,
1617
- ...options.supervisor
1884
+ // packages/sharding/src/identify.ts
1885
+ var IDENTIFY_INTERVAL = 5000;
1886
+
1887
+ class IdentifyCoordinator {
1888
+ maxConcurrency;
1889
+ #interval;
1890
+ #holdTimeout;
1891
+ #now;
1892
+ #schedule;
1893
+ #cancel;
1894
+ #onBudgetWait;
1895
+ #keys = new Map;
1896
+ #remaining;
1897
+ #total;
1898
+ #resetAt = Number.POSITIVE_INFINITY;
1899
+ #timer;
1900
+ #timerAt = Number.POSITIVE_INFINITY;
1901
+ #closed = false;
1902
+ constructor(options = {}) {
1903
+ const concurrency = options.maxConcurrency ?? 1;
1904
+ if (!Number.isInteger(concurrency) || concurrency < 1)
1905
+ throw new RangeError("maxConcurrency must be a positive integer.");
1906
+ const interval = options.interval ?? IDENTIFY_INTERVAL;
1907
+ const hold = options.holdTimeout ?? 60000;
1908
+ if (!(interval >= 0) || !(hold > 0))
1909
+ throw new RangeError("interval cannot be negative and holdTimeout must be positive.");
1910
+ this.maxConcurrency = concurrency;
1911
+ this.#interval = interval;
1912
+ this.#holdTimeout = hold;
1913
+ this.#now = options.now ?? Date.now;
1914
+ this.#schedule = options.schedule ?? ((callback, ms) => {
1915
+ const timer = setTimeout(callback, ms);
1916
+ timer.unref?.();
1917
+ return timer;
1618
1918
  });
1919
+ this.#cancel = options.cancel ?? ((handle) => clearTimeout(handle));
1920
+ this.#onBudgetWait = options.onBudgetWait;
1921
+ if (options.budget) {
1922
+ this.#total = options.budget.total;
1923
+ this.#remaining = options.budget.remaining;
1924
+ this.#resetAt = this.#now() + options.budget.resetAfter;
1925
+ }
1619
1926
  }
1620
- async fetchRecommendedShardCount() {
1621
- return (await fetchGatewayBot(this.#options.token)).shards;
1927
+ get remaining() {
1928
+ return this.#remaining;
1929
+ }
1930
+ get waiting() {
1931
+ let count = 0;
1932
+ for (const key of this.#keys.values())
1933
+ count += key.queue.length;
1934
+ return count;
1935
+ }
1936
+ get holding() {
1937
+ let count = 0;
1938
+ for (const key of this.#keys.values())
1939
+ if (key.holder)
1940
+ count++;
1941
+ return count;
1942
+ }
1943
+ acquire(owner, shardId) {
1944
+ if (this.#closed)
1945
+ return Promise.reject(new Error("IDENTIFY coordinator is closed."));
1946
+ const index = shardId % this.maxConcurrency;
1947
+ let key = this.#keys.get(index);
1948
+ if (!key)
1949
+ this.#keys.set(index, key = { queue: [], nextAt: 0 });
1950
+ return new Promise((grant, fail) => {
1951
+ key.queue.push({ owner, shardId, grant, fail });
1952
+ this.pump();
1953
+ });
1622
1954
  }
1623
- async spawn() {
1624
- if (this.#spawned)
1955
+ release(owner, shardId) {
1956
+ const key = this.#keys.get(shardId % this.maxConcurrency);
1957
+ const holder = key?.holder;
1958
+ if (!key || !holder || holder.owner !== owner || holder.shardId !== shardId)
1625
1959
  return;
1626
- if (typeof process?.versions?.bun === "string") {
1627
- throw new Error("ClusterManager requires the Node.js runtime; Bun does not support child_process.fork() for this clustering mode.");
1960
+ this.#free(key);
1961
+ this.pump();
1962
+ }
1963
+ drop(owner, reason = "The cluster went away.") {
1964
+ for (const key of this.#keys.values()) {
1965
+ const keep = [];
1966
+ for (const waiter of key.queue)
1967
+ if (waiter.owner === owner)
1968
+ waiter.fail(new Error(reason));
1969
+ else
1970
+ keep.push(waiter);
1971
+ key.queue = keep;
1972
+ if (key.holder?.owner === owner)
1973
+ this.#free(key);
1628
1974
  }
1629
- this.#spawned = true;
1630
- const count = this.#options.shardCount === "auto" || this.#options.shardCount === undefined ? await this.fetchRecommendedShardCount() : this.#options.shardCount;
1631
- this.shardCount = count;
1632
- const clusterCount = this.#options.clusterCount ?? cpus().length;
1633
- const chunks = Array.from({ length: clusterCount }, () => []);
1634
- for (let i = 0;i < count; i++) {
1635
- chunks[i % clusterCount]?.push(i);
1975
+ this.pump();
1976
+ }
1977
+ close() {
1978
+ this.#closed = true;
1979
+ if (this.#timer !== undefined)
1980
+ this.#cancel(this.#timer);
1981
+ this.#timer = undefined;
1982
+ this.#timerAt = Number.POSITIVE_INFINITY;
1983
+ for (const key of this.#keys.values()) {
1984
+ if (key.holder)
1985
+ this.#cancel(key.holder.timer);
1986
+ key.holder = undefined;
1987
+ for (const waiter of key.queue)
1988
+ waiter.fail(new Error("IDENTIFY coordinator is closed."));
1989
+ key.queue = [];
1990
+ }
1991
+ }
1992
+ pump() {
1993
+ if (this.#closed)
1994
+ return;
1995
+ const now = this.#now();
1996
+ if (this.#total !== undefined && now >= this.#resetAt) {
1997
+ this.#remaining = this.#total;
1998
+ this.#resetAt = Number.POSITIVE_INFINITY;
1636
1999
  }
1637
- for (const [i, chunk] of chunks.entries()) {
1638
- if (chunk.length === 0)
2000
+ let next = Number.POSITIVE_INFINITY;
2001
+ for (const key of this.#keys.values()) {
2002
+ if (key.holder || key.queue.length === 0)
2003
+ continue;
2004
+ if (key.nextAt > now) {
2005
+ next = Math.min(next, key.nextAt);
2006
+ continue;
2007
+ }
2008
+ if (this.#remaining !== undefined && this.#remaining <= 0) {
2009
+ const waiter = key.queue[0];
2010
+ if (this.#resetAt === Number.POSITIVE_INFINITY) {
2011
+ key.queue.shift();
2012
+ waiter.fail(new Error("The session start limit is exhausted."));
2013
+ continue;
2014
+ }
2015
+ this.#onBudgetWait?.({
2016
+ shardId: waiter.shardId,
2017
+ waitMs: this.#resetAt - now
2018
+ });
2019
+ next = Math.min(next, this.#resetAt);
1639
2020
  continue;
1640
- this.#launch(i, chunk, count);
1641
- await sleep(500);
2021
+ }
2022
+ const waiter = key.queue.shift();
2023
+ if (this.#remaining !== undefined)
2024
+ this.#remaining--;
2025
+ const timer = this.#schedule(() => {
2026
+ if (key.holder?.timer === timer) {
2027
+ this.#free(key);
2028
+ this.pump();
2029
+ }
2030
+ }, this.#holdTimeout);
2031
+ key.holder = {
2032
+ owner: waiter.owner,
2033
+ shardId: waiter.shardId,
2034
+ timer
2035
+ };
2036
+ waiter.grant();
1642
2037
  }
1643
- if (this.#options.autoScaleInterval && this.#options.autoScaleInterval > 0) {
1644
- this.#autoScaleTimer = setInterval(() => {
1645
- this.checkAutoScale();
1646
- }, this.#options.autoScaleInterval);
2038
+ this.#arm(next);
2039
+ }
2040
+ #free(key) {
2041
+ if (key.holder)
2042
+ this.#cancel(key.holder.timer);
2043
+ key.holder = undefined;
2044
+ key.nextAt = this.#now() + this.#interval;
2045
+ }
2046
+ #arm(at) {
2047
+ if (at === this.#timerAt && this.#timer !== undefined)
2048
+ return;
2049
+ if (this.#timer !== undefined)
2050
+ this.#cancel(this.#timer);
2051
+ this.#timer = undefined;
2052
+ this.#timerAt = Number.POSITIVE_INFINITY;
2053
+ if (at === Number.POSITIVE_INFINITY)
2054
+ return;
2055
+ this.#timerAt = at;
2056
+ this.#timer = this.#schedule(() => {
2057
+ this.#timer = undefined;
2058
+ this.#timerAt = Number.POSITIVE_INFINITY;
2059
+ this.pump();
2060
+ }, Math.max(0, at - this.#now()));
2061
+ }
2062
+ }
2063
+
2064
+ // packages/sharding/src/remote.ts
2065
+ import { EventEmitter } from "events";
2066
+ import { randomBytes } from "crypto";
2067
+
2068
+ // packages/sharding/src/remote-protocol.ts
2069
+ import { createHmac, timingSafeEqual } from "crypto";
2070
+ var REMOTE_PROTOCOL_VERSION = 1;
2071
+ var MAX_REMOTE_FRAME_BYTES = 8 * 1024 * 1024;
2072
+ var RemoteClose = {
2073
+ BadRequest: 4400,
2074
+ AuthFailed: 4401,
2075
+ HelloTimeout: 4408,
2076
+ DuplicateHost: 4409,
2077
+ TooManyConnections: 4429,
2078
+ UnknownHost: 4410,
2079
+ VersionMismatch: 4426,
2080
+ HostLost: 4504,
2081
+ Shutdown: 4500
2082
+ };
2083
+ var SPAWN_ENV_KEYS = new Set([
2084
+ "SHARD_LIST",
2085
+ "SHARD_COUNT",
2086
+ "CLUSTER_ID",
2087
+ "CLUSTER_COUNT",
2088
+ "LUNIBEE_SHARD_BUS",
2089
+ "LUNIBEE_CLUSTER_WORKER",
2090
+ "LUNIBEE_IDENTIFY"
2091
+ ]);
2092
+ var KILL_SIGNALS = new Set([
2093
+ "SIGTERM",
2094
+ "SIGKILL",
2095
+ "SIGINT"
2096
+ ]);
2097
+ function isLoopbackHost(hostname) {
2098
+ return /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)$/i.test(hostname);
2099
+ }
2100
+ var HOST_ID = /^[A-Za-z0-9._-]{1,64}$/;
2101
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
2102
+ var isInt = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
2103
+ function utf8Bytes(text) {
2104
+ return Buffer.byteLength(text, "utf8");
2105
+ }
2106
+ function parseJson(text, maxBytes) {
2107
+ if (typeof text !== "string")
2108
+ return null;
2109
+ if (text.length > maxBytes)
2110
+ return null;
2111
+ if (text.length * 3 > maxBytes && utf8Bytes(text) > maxBytes)
2112
+ return null;
2113
+ try {
2114
+ const value = JSON.parse(text);
2115
+ return isRecord2(value) ? value : null;
2116
+ } catch {
2117
+ return null;
2118
+ }
2119
+ }
2120
+ function signChallenge(secret, nonce, hostId) {
2121
+ return createHmac("sha256", secret).update(`${nonce}:${hostId}`).digest("hex");
2122
+ }
2123
+ function verifyChallenge(secret, nonce, hostId, mac) {
2124
+ if (typeof mac !== "string")
2125
+ return false;
2126
+ const expected = Buffer.from(signChallenge(secret, nonce, hostId), "hex");
2127
+ const given = Buffer.from(mac, "hex");
2128
+ return given.length === expected.length && timingSafeEqual(given, expected);
2129
+ }
2130
+ function parseToHost(text, maxBytes = MAX_REMOTE_FRAME_BYTES) {
2131
+ const v = parseJson(text, maxBytes);
2132
+ if (!v)
2133
+ return null;
2134
+ switch (v.t) {
2135
+ case "challenge":
2136
+ return typeof v.nonce === "string" && isInt(v.version) ? { t: "challenge", nonce: v.nonce, version: v.version } : null;
2137
+ case "welcome":
2138
+ return isInt(v.leaseMs) && isInt(v.pingMs) ? { t: "welcome", leaseMs: v.leaseMs, pingMs: v.pingMs } : null;
2139
+ case "ping":
2140
+ return isInt(v.n) ? { t: "ping", n: v.n } : null;
2141
+ case "spawn": {
2142
+ if (!isInt(v.cluster) || !isInt(v.epoch) || !isRecord2(v.env))
2143
+ return null;
2144
+ const env = {};
2145
+ for (const [key, value] of Object.entries(v.env)) {
2146
+ if (!SPAWN_ENV_KEYS.has(key) || typeof value !== "string")
2147
+ return null;
2148
+ env[key] = value;
2149
+ }
2150
+ return { t: "spawn", cluster: v.cluster, epoch: v.epoch, env };
1647
2151
  }
2152
+ case "frame":
2153
+ return isInt(v.cluster) && isInt(v.epoch) ? {
2154
+ t: "frame",
2155
+ cluster: v.cluster,
2156
+ epoch: v.epoch,
2157
+ frame: v.frame
2158
+ } : null;
2159
+ case "kill":
2160
+ return isInt(v.cluster) && isInt(v.epoch) && typeof v.signal === "string" && KILL_SIGNALS.has(v.signal) ? {
2161
+ t: "kill",
2162
+ cluster: v.cluster,
2163
+ epoch: v.epoch,
2164
+ signal: v.signal
2165
+ } : null;
2166
+ case "bye":
2167
+ return typeof v.reason === "string" ? { t: "bye", reason: v.reason } : null;
2168
+ default:
2169
+ return null;
1648
2170
  }
1649
- #launch(id, shards, shardCount) {
1650
- const child = fork(this.#options.script, [], {
1651
- env: {
1652
- ...process.env,
1653
- SHARD_LIST: shards.join(","),
1654
- SHARD_COUNT: shardCount.toString(),
1655
- CLUSTER_ID: id.toString(),
1656
- LUNIBEE_SHARD_BUS: "ipc"
2171
+ }
2172
+ function parseToCoordinator(text, maxBytes = MAX_REMOTE_FRAME_BYTES) {
2173
+ const v = parseJson(text, maxBytes);
2174
+ if (!v)
2175
+ return null;
2176
+ switch (v.t) {
2177
+ case "hello":
2178
+ return typeof v.id === "string" && typeof v.mac === "string" && isInt(v.version) ? { t: "hello", id: v.id, mac: v.mac, version: v.version } : null;
2179
+ case "pong":
2180
+ return isInt(v.n) ? { t: "pong", n: v.n } : null;
2181
+ case "spawned":
2182
+ return isInt(v.cluster) && isInt(v.epoch) && (v.pid === undefined || isInt(v.pid)) ? {
2183
+ t: "spawned",
2184
+ cluster: v.cluster,
2185
+ epoch: v.epoch,
2186
+ ...v.pid !== undefined ? { pid: v.pid } : {}
2187
+ } : null;
2188
+ case "frame":
2189
+ return isInt(v.cluster) && isInt(v.epoch) ? {
2190
+ t: "frame",
2191
+ cluster: v.cluster,
2192
+ epoch: v.epoch,
2193
+ frame: v.frame
2194
+ } : null;
2195
+ case "exit":
2196
+ return isInt(v.cluster) && isInt(v.epoch) && (v.code === null || typeof v.code === "number") && (v.signal === null || typeof v.signal === "string") ? {
2197
+ t: "exit",
2198
+ cluster: v.cluster,
2199
+ epoch: v.epoch,
2200
+ code: v.code,
2201
+ signal: v.signal
2202
+ } : null;
2203
+ case "error":
2204
+ return isInt(v.cluster) && isInt(v.epoch) && typeof v.message === "string" ? {
2205
+ t: "error",
2206
+ cluster: v.cluster,
2207
+ epoch: v.epoch,
2208
+ message: v.message
2209
+ } : null;
2210
+ default:
2211
+ return null;
2212
+ }
2213
+ }
2214
+
2215
+ // packages/sharding/src/remote.ts
2216
+ class RemoteChild extends EventEmitter {
2217
+ host;
2218
+ cluster;
2219
+ epoch;
2220
+ pid;
2221
+ exitCode = null;
2222
+ signalCode = null;
2223
+ connected = true;
2224
+ #send;
2225
+ #kill;
2226
+ constructor(host, cluster, epoch, send, kill) {
2227
+ super();
2228
+ this.host = host;
2229
+ this.cluster = cluster;
2230
+ this.epoch = epoch;
2231
+ this.#send = send;
2232
+ this.#kill = kill;
2233
+ }
2234
+ send(frame) {
2235
+ return this.connected && this.exitCode === null && this.signalCode === null ? this.#send(frame) : false;
2236
+ }
2237
+ kill(signal = "SIGTERM") {
2238
+ if (this.exitCode !== null || this.signalCode !== null)
2239
+ return false;
2240
+ return this.#kill(signal);
2241
+ }
2242
+ spawned(pid) {
2243
+ this.pid = pid;
2244
+ }
2245
+ deliver(frame) {
2246
+ if (this.exitCode === null && this.signalCode === null)
2247
+ this.emit("message", frame);
2248
+ }
2249
+ finish(code, signal) {
2250
+ if (this.exitCode !== null || this.signalCode !== null)
2251
+ return;
2252
+ this.connected = false;
2253
+ this.exitCode = code ?? (signal === null ? 1 : null);
2254
+ this.signalCode = signal ?? null;
2255
+ this.emit("exit", code, signal);
2256
+ }
2257
+ }
2258
+
2259
+ class RemoteHosts {
2260
+ #options;
2261
+ #links = new Map;
2262
+ #waiters = new Set;
2263
+ #hooks;
2264
+ #server;
2265
+ #watchdog;
2266
+ #epoch = 0;
2267
+ #ping = 0;
2268
+ #unauthenticated = 0;
2269
+ constructor(options, hooks) {
2270
+ if (typeof options.secret !== "string" || options.secret.length < 16)
2271
+ throw new RangeError("remote.secret must be a string of at least 16 characters.");
2272
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535)
2273
+ throw new RangeError("remote.port must be an integer from 0 to 65535.");
2274
+ if (options.tls && (typeof options.tls.cert !== "string" || typeof options.tls.key !== "string" || !options.tls.cert || !options.tls.key))
2275
+ throw new TypeError("remote.tls needs a non-empty cert and key.");
2276
+ if (!options.tls && !options.allowInsecure && !isLoopbackHost(options.hostname ?? "127.0.0.1"))
2277
+ throw new RangeError("remote listens on a network address without TLS: set remote.tls, bind 127.0.0.1 behind a TLS proxy, or set remote.allowInsecure for a network you fully trust.");
2278
+ const lease = options.lease ?? 15000;
2279
+ if (!Number.isFinite(lease) || lease < 30)
2280
+ throw new RangeError("remote.lease must be at least 30 milliseconds.");
2281
+ for (const id of options.hosts ?? [])
2282
+ if (!HOST_ID.test(id))
2283
+ throw new RangeError(`"${id}" is not a valid host ID.`);
2284
+ if (options.hosts && (options.hosts.length === 0 || new Set(options.hosts).size !== options.hosts.length))
2285
+ throw new RangeError("remote.hosts must be unique and not empty.");
2286
+ if (options.minHosts !== undefined && (!Number.isInteger(options.minHosts) || options.minHosts < 1))
2287
+ throw new RangeError("remote.minHosts must be a positive integer.");
2288
+ this.#options = { ...options, lease };
2289
+ this.#hooks = hooks;
2290
+ }
2291
+ get port() {
2292
+ return this.#server?.port;
2293
+ }
2294
+ connected() {
2295
+ return [...this.#links.keys()].sort();
2296
+ }
2297
+ start() {
2298
+ if (this.#server)
2299
+ return;
2300
+ const { port, hostname, tls, lease } = this.#options;
2301
+ this.#server = Bun.serve({
2302
+ port,
2303
+ hostname: hostname ?? "127.0.0.1",
2304
+ ...tls ? { tls } : {},
2305
+ fetch: (request, server) => {
2306
+ if (server.upgrade(request, {
2307
+ data: { nonce: randomBytes(16).toString("hex") }
2308
+ }))
2309
+ return;
2310
+ return new Response("Lunibee cluster coordinator", {
2311
+ status: 426
2312
+ });
2313
+ },
2314
+ websocket: {
2315
+ maxPayloadLength: MAX_REMOTE_FRAME_BYTES,
2316
+ idleTimeout: 0,
2317
+ open: (ws) => this.#open(ws),
2318
+ message: (ws, raw) => this.#message(ws, raw),
2319
+ close: (ws, _code, reason) => this.#closed(ws, reason)
1657
2320
  }
1658
2321
  });
1659
- const info = { id, process: child, shards };
1660
- this.clusters.set(id, info);
1661
- child.on("message", (frame) => this.#relay(id, frame));
1662
- child.once("exit", (code, signal) => {
1663
- if (this.clusters.get(id)?.process !== child)
1664
- return;
1665
- try {
1666
- this.#options.onClusterExit?.(info, code, signal);
1667
- } catch {}
1668
- if (!this.#stopping)
1669
- this.#announceDown(id, shards);
1670
- if (this.#stopping || this.#options.restartOnExit === false)
1671
- return;
1672
- this.clusters.delete(id);
1673
- const delay = this.#supervisor.next(id);
1674
- if (delay === null) {
1675
- try {
1676
- this.#options.onGiveUp?.(info);
1677
- } catch {}
1678
- return;
1679
- }
2322
+ const pingMs = Math.max(10, Math.floor(lease / 3));
2323
+ this.#watchdog = setInterval(() => this.#tick(), pingMs);
2324
+ this.#watchdog.unref?.();
2325
+ }
2326
+ stop() {
2327
+ if (this.#watchdog)
2328
+ clearInterval(this.#watchdog);
2329
+ this.#watchdog = undefined;
2330
+ for (const link of [...this.#links.values()]) {
2331
+ this.#post(link, { t: "bye", reason: "The coordinator stopped." });
2332
+ this.#drop(link, "The coordinator stopped.", RemoteClose.Shutdown);
2333
+ }
2334
+ this.#server?.stop(true);
2335
+ this.#server = undefined;
2336
+ this.abortWaits();
2337
+ }
2338
+ waitFor(hosts, count, timeoutMs) {
2339
+ const done = () => hosts ? hosts.every((id) => this.#links.has(id)) : this.#links.size >= count;
2340
+ if (done())
2341
+ return Promise.resolve();
2342
+ return new Promise((resolve, reject) => {
2343
+ const waiter = {
2344
+ check: () => {
2345
+ if (!done())
2346
+ return;
2347
+ clearTimeout(timer);
2348
+ this.#waiters.delete(waiter);
2349
+ resolve();
2350
+ },
2351
+ abort: (error) => {
2352
+ clearTimeout(timer);
2353
+ this.#waiters.delete(waiter);
2354
+ reject(error);
2355
+ }
2356
+ };
1680
2357
  const timer = setTimeout(() => {
1681
- this.#restartTimers.delete(timer);
1682
- if (this.#stopping || !this.#spawned)
1683
- return;
1684
- this.#launch(id, shards, shardCount);
1685
- }, delay);
1686
- timer.unref?.();
1687
- this.#restartTimers.add(timer);
2358
+ const missing = hosts?.filter((id) => !this.#links.has(id));
2359
+ waiter.abort(new Error(hosts ? `Hosts did not join within ${timeoutMs}ms: [${missing}].` : `Only ${this.#links.size} of ${count} hosts joined within ${timeoutMs}ms.`));
2360
+ }, timeoutMs);
2361
+ this.#waiters.add(waiter);
1688
2362
  });
1689
- return info;
1690
2363
  }
1691
- #relay(from, frame) {
1692
- if (!frame || typeof frame !== "object" || !(BUS_FRAME in frame))
1693
- return;
1694
- for (const [id, cluster] of this.clusters)
1695
- if (id !== from)
1696
- this.#sendFrame(cluster, frame);
2364
+ abortWaits() {
2365
+ for (const waiter of [...this.#waiters])
2366
+ waiter.abort(new Error("The coordinator stopped."));
1697
2367
  }
1698
- #announceDown(exited, shards) {
1699
- for (const [id, cluster] of this.clusters)
1700
- if (id !== exited)
1701
- this.#sendFrame(cluster, { [PEER_DOWN_FRAME]: shards });
2368
+ status(declared = []) {
2369
+ const ids = new Set([...declared, ...this.#links.keys()]);
2370
+ return [...ids].sort().map((id) => {
2371
+ const link = this.#links.get(id);
2372
+ return {
2373
+ id,
2374
+ connected: link !== undefined,
2375
+ lastSeen: link?.lastSeen ?? 0,
2376
+ clusters: link ? [...link.children.keys()].sort((a, b) => a - b) : []
2377
+ };
2378
+ });
1702
2379
  }
1703
- #sendFrame(cluster, frame) {
1704
- const child = cluster.process;
1705
- if (typeof child.send !== "function" || !child.connected)
1706
- return;
2380
+ createChild(hostId, cluster, env) {
2381
+ const epoch = ++this.#epoch;
2382
+ const link = this.#links.get(hostId);
2383
+ const child = new RemoteChild(hostId, cluster, epoch, (frame) => link !== undefined && this.#post(link, { t: "frame", cluster, epoch, frame }), (signal) => link !== undefined && this.#post(link, { t: "kill", cluster, epoch, signal }));
2384
+ if (!link) {
2385
+ queueMicrotask(() => child.finish(null, "SIGHUP"));
2386
+ return child;
2387
+ }
2388
+ link.children.get(cluster)?.finish(null, "SIGKILL");
2389
+ link.children.set(cluster, child);
2390
+ if (!this.#post(link, { t: "spawn", cluster, epoch, env }))
2391
+ queueMicrotask(() => child.finish(null, "SIGHUP"));
2392
+ return child;
2393
+ }
2394
+ #post(link, message) {
1707
2395
  try {
1708
- child.send(frame);
1709
- } catch {}
2396
+ return link.ws.send(JSON.stringify(message)) > 0;
2397
+ } catch {
2398
+ return false;
2399
+ }
1710
2400
  }
1711
- async checkAutoScale() {
1712
- if (!this.#auto)
2401
+ #open(ws) {
2402
+ if (this.#unauthenticated >= (this.#options.maxUnauthenticated ?? 16)) {
2403
+ ws.close(RemoteClose.TooManyConnections, "Too many connections.");
1713
2404
  return;
1714
- try {
1715
- const recommended = await this.fetchRecommendedShardCount();
1716
- if (recommended !== this.shardCount) {
1717
- await this.respawn(recommended);
1718
- }
1719
- } catch (error) {
1720
- const normalized = error instanceof Error ? error : new Error(String(error));
1721
- this.#options.onAutoScaleError?.(normalized);
1722
2405
  }
2406
+ this.#unauthenticated++;
2407
+ ws.data.counted = true;
2408
+ ws.data.helloTimer = setTimeout(() => ws.close(RemoteClose.HelloTimeout, "No hello."), this.#options.helloTimeout ?? 1e4);
2409
+ ws.send(JSON.stringify({
2410
+ t: "challenge",
2411
+ nonce: ws.data.nonce,
2412
+ version: REMOTE_PROTOCOL_VERSION
2413
+ }));
1723
2414
  }
1724
- async respawn(newShardCount) {
1725
- this.#options.shardCount = newShardCount;
1726
- await this.shutdownAll();
1727
- this.#spawned = false;
1728
- this.#supervisor.reset();
2415
+ #message(ws, raw) {
2416
+ if (typeof raw !== "string") {
2417
+ ws.close(RemoteClose.BadRequest, "Text frames only.");
2418
+ return;
2419
+ }
2420
+ const message = parseToCoordinator(raw);
2421
+ if (!message) {
2422
+ ws.close(RemoteClose.BadRequest, "Malformed frame.");
2423
+ return;
2424
+ }
2425
+ if (ws.data.id === undefined) {
2426
+ this.#hello(ws, message);
2427
+ return;
2428
+ }
2429
+ const link = this.#links.get(ws.data.id);
2430
+ if (!link || link.ws !== ws)
2431
+ return;
2432
+ link.lastSeen = Date.now();
2433
+ if (message.t === "pong" || message.t === "hello")
2434
+ return;
2435
+ const child = link.children.get(message.cluster);
2436
+ if (!child || child.epoch !== message.epoch)
2437
+ return;
2438
+ switch (message.t) {
2439
+ case "spawned":
2440
+ child.spawned(message.pid);
2441
+ break;
2442
+ case "frame":
2443
+ child.deliver(message.frame);
2444
+ break;
2445
+ case "exit":
2446
+ link.children.delete(message.cluster);
2447
+ child.finish(message.code, message.signal);
2448
+ break;
2449
+ case "error":
2450
+ this.#hooks.error(new Error(`Host ${link.id} could not run cluster ${message.cluster}: ${message.message}`));
2451
+ link.children.delete(message.cluster);
2452
+ child.finish(1, null);
2453
+ break;
2454
+ }
2455
+ }
2456
+ #hello(ws, message) {
2457
+ if (message?.t !== "hello") {
2458
+ ws.close(RemoteClose.BadRequest, "Expected hello.");
2459
+ return;
2460
+ }
2461
+ const { id } = message;
2462
+ if (message.version !== REMOTE_PROTOCOL_VERSION) {
2463
+ ws.close(RemoteClose.VersionMismatch, "Protocol version mismatch.");
2464
+ return;
2465
+ }
2466
+ if (!HOST_ID.test(id) || !verifyChallenge(this.#options.secret, ws.data.nonce, id, message.mac)) {
2467
+ ws.close(RemoteClose.AuthFailed, "Authentication failed.");
2468
+ return;
2469
+ }
2470
+ if (this.#options.hosts && !this.#options.hosts.includes(id)) {
2471
+ ws.close(RemoteClose.UnknownHost, "Host is not part of this deployment.");
2472
+ return;
2473
+ }
2474
+ if (this.#links.has(id)) {
2475
+ ws.close(RemoteClose.DuplicateHost, "A host with this ID is connected.");
2476
+ return;
2477
+ }
2478
+ clearTimeout(ws.data.helloTimer);
2479
+ this.#uncount(ws);
2480
+ ws.data.id = id;
2481
+ const link = {
2482
+ id,
2483
+ ws,
2484
+ lastSeen: Date.now(),
2485
+ children: new Map
2486
+ };
2487
+ this.#links.set(id, link);
2488
+ const lease = this.#options.lease;
2489
+ this.#post(link, {
2490
+ t: "welcome",
2491
+ leaseMs: lease,
2492
+ pingMs: Math.max(10, Math.floor(lease / 3))
2493
+ });
2494
+ this.#hooks.join(id);
2495
+ for (const waiter of [...this.#waiters])
2496
+ waiter.check();
2497
+ }
2498
+ #uncount(ws) {
2499
+ if (ws.data.counted) {
2500
+ ws.data.counted = false;
2501
+ this.#unauthenticated--;
2502
+ }
2503
+ }
2504
+ #closed(ws, reason) {
2505
+ clearTimeout(ws.data.helloTimer);
2506
+ this.#uncount(ws);
2507
+ const link = ws.data.id ? this.#links.get(ws.data.id) : undefined;
2508
+ if (link && link.ws === ws)
2509
+ this.#drop(link, reason || "The connection closed.");
2510
+ }
2511
+ #drop(link, reason, code) {
2512
+ if (this.#links.get(link.id) !== link)
2513
+ return;
2514
+ this.#links.delete(link.id);
2515
+ if (code !== undefined)
2516
+ try {
2517
+ link.ws.close(code, reason.slice(0, 100));
2518
+ } catch {}
2519
+ for (const child of [...link.children.values()])
2520
+ child.finish(null, "SIGHUP");
2521
+ link.children.clear();
2522
+ this.#hooks.leave(link.id, reason);
2523
+ }
2524
+ #tick() {
2525
+ const now = Date.now();
2526
+ for (const link of [...this.#links.values()]) {
2527
+ if (now - link.lastSeen > this.#options.lease * 2) {
2528
+ this.#drop(link, "The host stopped answering.", RemoteClose.HostLost);
2529
+ continue;
2530
+ }
2531
+ this.#post(link, { t: "ping", n: ++this.#ping });
2532
+ }
2533
+ }
2534
+ }
2535
+
2536
+ // packages/sharding/src/requests.ts
2537
+ class RequestTracker {
2538
+ #pending = new Map;
2539
+ #maxPending;
2540
+ #prefix;
2541
+ #counter = 0;
2542
+ constructor(prefix = "parent", maxPending = 1e4) {
2543
+ if (!Number.isInteger(maxPending) || maxPending < 1)
2544
+ throw new RangeError("maxPending must be a positive integer.");
2545
+ this.#prefix = prefix;
2546
+ this.#maxPending = maxPending;
2547
+ }
2548
+ get size() {
2549
+ return this.#pending.size;
2550
+ }
2551
+ begin(options) {
2552
+ if (this.#pending.size >= this.#maxPending)
2553
+ throw new Error(`Too many requests waiting for a reply (${this.#maxPending}).`);
2554
+ const id = `${this.#prefix}:${++this.#counter}`;
2555
+ const done = new Promise((resolve) => {
2556
+ const finish = () => {
2557
+ const entry = this.#pending.get(id);
2558
+ if (!entry)
2559
+ return;
2560
+ this.#pending.delete(id);
2561
+ clearTimeout(entry.timer);
2562
+ entry.cleanup?.();
2563
+ resolve({
2564
+ replies: entry.replies,
2565
+ missing: [...entry.keys.keys()].filter((key) => !entry.answered.has(key))
2566
+ });
2567
+ };
2568
+ const entry = {
2569
+ keys: new Map(options.keys),
2570
+ replies: [],
2571
+ answered: new Set,
2572
+ finish,
2573
+ timer: setTimeout(finish, options.timeoutMs)
2574
+ };
2575
+ if (options.signal) {
2576
+ const signal = options.signal;
2577
+ if (signal.aborted)
2578
+ queueMicrotask(finish);
2579
+ else {
2580
+ signal.addEventListener("abort", finish, { once: true });
2581
+ entry.cleanup = () => signal.removeEventListener("abort", finish);
2582
+ }
2583
+ }
2584
+ this.#pending.set(id, entry);
2585
+ if (entry.keys.size === 0)
2586
+ queueMicrotask(finish);
2587
+ });
2588
+ return { id, done };
2589
+ }
2590
+ answer(id, key, from, value) {
2591
+ const entry = this.#pending.get(id);
2592
+ if (!entry || entry.keys.get(key) !== from)
2593
+ return false;
2594
+ if (entry.answered.has(key))
2595
+ return false;
2596
+ entry.answered.add(key);
2597
+ entry.replies.push({ key, ...value });
2598
+ if (entry.replies.length >= entry.keys.size)
2599
+ entry.finish();
2600
+ return true;
2601
+ }
2602
+ failOwner(owner, error) {
2603
+ for (const entry of [...this.#pending.values()]) {
2604
+ for (const [key, keyOwner] of entry.keys)
2605
+ if (keyOwner === owner && !entry.answered.has(key)) {
2606
+ entry.answered.add(key);
2607
+ entry.replies.push({ key, error });
2608
+ }
2609
+ if (entry.replies.length >= entry.keys.size)
2610
+ entry.finish();
2611
+ }
2612
+ }
2613
+ close() {
2614
+ for (const entry of [...this.#pending.values()])
2615
+ entry.finish();
2616
+ }
2617
+ }
2618
+
2619
+ // packages/sharding/src/cluster.ts
2620
+ var DEFAULT_MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
2621
+ function payloadSize(value) {
2622
+ if (value === undefined || value === null)
2623
+ return 0;
2624
+ if (typeof value === "string")
2625
+ return Buffer.byteLength(value, "utf8");
2626
+ if (typeof value !== "object")
2627
+ return String(value).length;
2628
+ try {
2629
+ const text = JSON.stringify(value);
2630
+ return text === undefined ? 0 : Buffer.byteLength(text, "utf8");
2631
+ } catch {
2632
+ return Number.POSITIVE_INFINITY;
2633
+ }
2634
+ }
2635
+ var isLive = (state) => state === "starting" || state === "ready";
2636
+
2637
+ class ClusterManager {
2638
+ clusters = new Map;
2639
+ shardCount = 0;
2640
+ #options;
2641
+ #auto;
2642
+ #autoScaleTimer;
2643
+ #infos = new Map;
2644
+ #owner = new Map;
2645
+ #startPromise;
2646
+ #stopPromise;
2647
+ #wasStarted = false;
2648
+ #stopping = false;
2649
+ #restartTimers = new Set;
2650
+ #startTimers = new Map;
2651
+ #pauses = new Set;
2652
+ #readyWaiters = new Set;
2653
+ #supervisor;
2654
+ #tracker;
2655
+ #coordinator;
2656
+ #allReady = false;
2657
+ #clusterCount = 0;
2658
+ #identifyWaiting = new Map;
2659
+ #remote;
2660
+ #hostOf = new Map;
2661
+ #lastWait = "";
2662
+ #listeners = new Map;
2663
+ constructor(options) {
2664
+ if (!options.token?.trim())
2665
+ throw new TypeError("A Discord bot token is required.");
2666
+ if (!options.script?.trim())
2667
+ throw new TypeError("A script path is required for clustering.");
2668
+ if (options.autoScaleInterval !== undefined && (!Number.isInteger(options.autoScaleInterval) || options.autoScaleInterval < 1000)) {
2669
+ throw new RangeError("autoScaleInterval must be an integer of at least 1000 milliseconds.");
2670
+ }
2671
+ if (options.clusterCount !== undefined && options.shardsPerCluster !== undefined)
2672
+ throw new RangeError("Set either clusterCount or shardsPerCluster, not both.");
2673
+ for (const [name, value] of [
2674
+ ["clusterCount", options.clusterCount],
2675
+ ["shardsPerCluster", options.shardsPerCluster],
2676
+ ["maxConcurrency", options.maxConcurrency]
2677
+ ])
2678
+ if (value !== undefined && (!Number.isInteger(value) || value < 1))
2679
+ throw new RangeError(`${name} must be a positive integer.`);
2680
+ if (options.maxMessageBytes !== undefined && (!Number.isInteger(options.maxMessageBytes) || options.maxMessageBytes < 1))
2681
+ throw new RangeError("maxMessageBytes must be a positive integer.");
2682
+ for (const [name, value] of [
2683
+ ["spawnInterval", options.spawnInterval],
2684
+ ["startTimeout", options.startTimeout],
2685
+ ["requestTimeout", options.requestTimeout]
2686
+ ])
2687
+ if (value !== undefined && !(value >= 0))
2688
+ throw new RangeError(`${name} cannot be negative.`);
2689
+ if (options.guildsPerShard !== undefined) {
2690
+ assertGuildsPerShard(options.guildsPerShard);
2691
+ if (typeof options.shardCount === "number")
2692
+ throw new RangeError('guildsPerShard needs shardCount: "auto".');
2693
+ }
2694
+ if (options.shardIds && typeof options.shardCount === "number")
2695
+ validateShardIds(options.shardIds, options.shardCount);
2696
+ this.#auto = options.shardCount === "auto";
2697
+ this.#options = { ...options };
2698
+ this.#supervisor = new ShardSupervisor({
2699
+ restartDelay: options.restartDelay,
2700
+ ...options.supervisor
2701
+ });
2702
+ this.#tracker = new RequestTracker("parent", options.maxPendingRequests);
2703
+ if (options.remote)
2704
+ this.#remote = new RemoteHosts(options.remote, {
2705
+ join: (id) => this.#emit("hostJoin", id),
2706
+ leave: (id, reason) => this.#emit("hostLeave", id, reason),
2707
+ error: (error) => this.#emit("error", error)
2708
+ });
2709
+ }
2710
+ on(event, listener) {
2711
+ let set = this.#listeners.get(event);
2712
+ if (!set)
2713
+ this.#listeners.set(event, set = new Set);
2714
+ set.add(listener);
2715
+ return this;
2716
+ }
2717
+ off(event, listener) {
2718
+ this.#listeners.get(event)?.delete(listener);
2719
+ return this;
2720
+ }
2721
+ #emit(event, ...args) {
2722
+ for (const listener of this.#listeners.get(event) ?? [])
2723
+ try {
2724
+ listener(...args);
2725
+ } catch {}
2726
+ }
2727
+ async fetchRecommendedShardCount() {
2728
+ return resolveShardCount(this.#options.token, await fetchGatewayBot(this.#options.token), this.#options);
2729
+ }
2730
+ clusterForShard(shardId) {
2731
+ const id = this.#owner.get(shardId);
2732
+ return id === undefined ? undefined : this.#infos.get(id);
2733
+ }
2734
+ assignments() {
2735
+ return new Map(this.#owner);
2736
+ }
2737
+ spawn() {
2738
+ if (isClusterWorker())
2739
+ return Promise.reject(new Error("A cluster worker cannot start a ClusterManager: it would fork the whole fleet again."));
2740
+ if (this.#stopPromise)
2741
+ return this.#stopPromise.then(() => this.spawn());
2742
+ if (!this.#startPromise) {
2743
+ const attempt = this.#start();
2744
+ this.#startPromise = attempt;
2745
+ attempt.catch(() => {
2746
+ if (this.#startPromise === attempt)
2747
+ this.#startPromise = undefined;
2748
+ });
2749
+ }
2750
+ return this.#startPromise;
2751
+ }
2752
+ async#resolveGateway() {
2753
+ const { shardCount, maxConcurrency, fetchLimits } = this.#options;
2754
+ let info;
2755
+ let count;
2756
+ if (shardCount === "auto" || shardCount === undefined) {
2757
+ info = await fetchGatewayBot(this.#options.token);
2758
+ count = await resolveShardCount(this.#options.token, info, this.#options);
2759
+ } else if (fetchLimits)
2760
+ try {
2761
+ info = await fetchGatewayBot(this.#options.token);
2762
+ } catch (error) {
2763
+ this.#emit("error", error instanceof Error ? error : new Error(String(error)));
2764
+ }
2765
+ const limit = info?.sessionStartLimit;
2766
+ return {
2767
+ count: count ?? shardCount,
2768
+ maxConcurrency: Math.min(maxConcurrency ?? limit?.maxConcurrency ?? 1, limit?.maxConcurrency ?? Number.POSITIVE_INFINITY),
2769
+ ...limit ? {
2770
+ budget: {
2771
+ total: limit.total,
2772
+ remaining: limit.remaining,
2773
+ resetAfter: limit.resetAfter
2774
+ }
2775
+ } : {}
2776
+ };
2777
+ }
2778
+ async#start() {
2779
+ const { count, maxConcurrency, budget } = await this.#resolveGateway();
2780
+ if (this.#stopping)
2781
+ return;
2782
+ const ids = this.#options.shardIds ?? Array.from({ length: count }, (_, i) => i);
2783
+ validateShardIds(ids, count);
2784
+ const clusterCount = this.#options.clusterCount ?? (this.#options.shardsPerCluster ? Math.ceil(ids.length / this.#options.shardsPerCluster) : cpus().length);
2785
+ const chunks = allocateShards(ids, clusterCount);
2786
+ assertCompleteAllocation(chunks, ids);
2787
+ const placement = await this.#placeHosts().catch((error) => {
2788
+ if (this.#stopping)
2789
+ return;
2790
+ throw error;
2791
+ });
2792
+ if (!placement || this.#stopping)
2793
+ return;
2794
+ this.shardCount = count;
2795
+ this.#wasStarted = true;
2796
+ this.#owner.clear();
2797
+ this.#infos.clear();
2798
+ const identify = this.#options.identify;
2799
+ this.#coordinator = identify?.coordinate === false ? undefined : new IdentifyCoordinator({
2800
+ maxConcurrency,
2801
+ interval: identify?.interval,
2802
+ holdTimeout: identify?.holdTimeout,
2803
+ budget,
2804
+ onBudgetWait: (wait) => {
2805
+ const key = `${wait.shardId}:${Math.round(wait.waitMs / 1000)}`;
2806
+ if (key === this.#lastWait)
2807
+ return;
2808
+ this.#lastWait = key;
2809
+ this.#emit("identifyWait", wait);
2810
+ }
2811
+ });
2812
+ const active = chunks.map((shards, id) => ({ id, shards })).filter((chunk) => chunk.shards.length > 0);
2813
+ this.#clusterCount = active.length;
2814
+ this.#hostOf.clear();
2815
+ for (const chunk of active)
2816
+ this.#hostOf.set(chunk.id, placement[chunk.id % placement.length]);
2817
+ for (const [index, chunk] of active.entries()) {
2818
+ if (this.#stopping)
2819
+ return;
2820
+ for (const shard of chunk.shards)
2821
+ this.#owner.set(shard, chunk.id);
2822
+ this.#launch(chunk.id, chunk.shards);
2823
+ const gap = this.#options.spawnInterval ?? 500;
2824
+ if (index < active.length - 1 && gap > 0)
2825
+ await this.#pause(gap);
2826
+ }
2827
+ if (this.#stopping)
2828
+ return;
2829
+ if (this.#options.waitForReady)
2830
+ await this.ready();
2831
+ if (this.#options.autoScaleInterval && this.#options.autoScaleInterval > 0) {
2832
+ this.#autoScaleTimer = setInterval(() => {
2833
+ this.checkAutoScale();
2834
+ }, this.#options.autoScaleInterval);
2835
+ }
2836
+ }
2837
+ async#placeHosts() {
2838
+ const remote = this.#remote;
2839
+ const options = this.#options.remote;
2840
+ if (!remote || !options)
2841
+ return ["local"];
2842
+ remote.start();
2843
+ try {
2844
+ await remote.waitFor(options.hosts, options.minHosts ?? 1, options.joinTimeout ?? 60000);
2845
+ } catch (error) {
2846
+ remote.stop();
2847
+ throw error;
2848
+ }
2849
+ const ids = options.hosts ?? remote.connected();
2850
+ return options.local ? ["local", ...ids] : ids;
2851
+ }
2852
+ get remotePort() {
2853
+ return this.#remote?.port;
2854
+ }
2855
+ hosts() {
2856
+ return this.#remote?.status(this.#options.remote?.hosts) ?? [];
2857
+ }
2858
+ #pause(ms) {
2859
+ return new Promise((resolve) => {
2860
+ const wake = () => {
2861
+ clearTimeout(timer);
2862
+ this.#pauses.delete(wake);
2863
+ resolve();
2864
+ };
2865
+ const timer = setTimeout(wake, ms);
2866
+ this.#pauses.add(wake);
2867
+ });
2868
+ }
2869
+ #launch(id, shards) {
2870
+ const contract = {
2871
+ SHARD_LIST: shards.join(","),
2872
+ SHARD_COUNT: this.shardCount.toString(),
2873
+ CLUSTER_ID: id.toString(),
2874
+ CLUSTER_COUNT: this.#clusterCount.toString(),
2875
+ LUNIBEE_SHARD_BUS: "ipc",
2876
+ [WORKER_MARKER]: "1",
2877
+ LUNIBEE_IDENTIFY: this.#coordinator ? "coordinated" : "local"
2878
+ };
2879
+ const host = this.#hostOf.get(id) ?? "local";
2880
+ const env = {
2881
+ ...process.env,
2882
+ ...contract
2883
+ };
2884
+ const child = host !== "local" && this.#remote ? this.#remote.createChild(host, id, contract) : this.#options.fork ? this.#options.fork(this.#options.script, env) : fork(this.#options.script, [], {
2885
+ env
2886
+ });
2887
+ const known = this.#infos.get(id);
2888
+ const info = known ?? {
2889
+ id,
2890
+ host,
2891
+ process: child,
2892
+ shards,
2893
+ state: "starting",
2894
+ restarts: 0,
2895
+ startedAt: Date.now()
2896
+ };
2897
+ info.process = child;
2898
+ info.state = "starting";
2899
+ info.startedAt = Date.now();
2900
+ info.readyAt = undefined;
2901
+ this.#infos.set(id, info);
2902
+ this.clusters.set(id, info);
2903
+ this.#allReady = false;
2904
+ child.on("message", (frame) => this.#onFrame(info, child, frame));
2905
+ child.once("exit", (code, signal) => this.#onExit(info, child, code, signal));
2906
+ const timeout = this.#options.startTimeout;
2907
+ if (timeout) {
2908
+ const timer = setTimeout(() => {
2909
+ this.#startTimers.delete(id);
2910
+ if (info.process !== child || info.state !== "starting")
2911
+ return;
2912
+ this.#emit("clusterStartTimeout", info);
2913
+ try {
2914
+ child.kill("SIGKILL");
2915
+ } catch {}
2916
+ }, timeout);
2917
+ timer.unref?.();
2918
+ this.#startTimers.set(id, timer);
2919
+ }
2920
+ this.#emit("clusterSpawn", info);
2921
+ return info;
2922
+ }
2923
+ #onExit(info, child, code, signal) {
2924
+ if (info.process !== child)
2925
+ return;
2926
+ child.removeAllListeners("message");
2927
+ this.#clearStartTimer(info.id);
2928
+ this.#coordinator?.drop(info.id, `Cluster ${info.id} exited.`);
2929
+ this.#identifyWaiting.delete(info.id);
2930
+ this.#tracker.failOwner(info.id, `Cluster ${info.id} exited.`);
2931
+ this.clusters.delete(info.id);
2932
+ this.#allReady = false;
2933
+ try {
2934
+ this.#options.onClusterExit?.(info, code, signal);
2935
+ } catch {}
2936
+ const expected = this.#stopping || info.state === "stopping" || info.state === "stopped";
2937
+ this.#emit("clusterExit", info, code, signal, expected);
2938
+ if (expected) {
2939
+ info.state = "stopped";
2940
+ return;
2941
+ }
2942
+ this.#announceDown(info.id, info.shards);
2943
+ if (this.#options.restartOnExit === false) {
2944
+ info.state = "failed";
2945
+ this.#checkReady();
2946
+ return;
2947
+ }
2948
+ const delay = this.#supervisor.next(info.id);
2949
+ if (delay === null) {
2950
+ info.state = "failed";
2951
+ try {
2952
+ this.#options.onGiveUp?.(info);
2953
+ } catch {}
2954
+ this.#emit("clusterGiveUp", info);
2955
+ this.#checkReady();
2956
+ if (this.#options.reassignOnGiveUp)
2957
+ this.#reassign(info);
2958
+ return;
2959
+ }
2960
+ info.state = "restarting";
2961
+ info.restarts++;
2962
+ this.#emit("clusterRestart", info, { delay, attempt: info.restarts });
2963
+ if (info.restarts >= 3)
2964
+ this.#emit("clusterUnstable", info, {
2965
+ attempt: info.restarts,
2966
+ delay
2967
+ });
2968
+ const timer = setTimeout(() => {
2969
+ this.#restartTimers.delete(timer);
2970
+ if (this.#stopping || info.state !== "restarting")
2971
+ return;
2972
+ this.#launch(info.id, info.shards);
2973
+ }, delay);
2974
+ timer.unref?.();
2975
+ this.#restartTimers.add(timer);
2976
+ }
2977
+ async#reassign(failed) {
2978
+ const shardIds = [...failed.shards];
2979
+ const target = [...this.#infos.values()].filter((cluster) => cluster.state === "ready").sort((a, b) => a.shards.length - b.shards.length)[0];
2980
+ if (!target) {
2981
+ this.#emit("error", new Error(`No ready cluster can take over shards [${shardIds}] of cluster ${failed.id}.`));
2982
+ return;
2983
+ }
2984
+ try {
2985
+ await this.requestCluster(target.id, REQUEST_ASSIGN, { shardIds }, { timeoutMs: this.#options.startTimeout ?? 120000 });
2986
+ } catch (error) {
2987
+ this.#emit("error", new Error(`Cluster ${target.id} could not take over shards [${shardIds}]: ${error instanceof Error ? error.message : String(error)}`));
2988
+ return;
2989
+ }
2990
+ if (this.#stopping || failed.state !== "failed")
2991
+ return;
2992
+ for (const shard of shardIds)
2993
+ this.#owner.set(shard, target.id);
2994
+ target.shards.push(...shardIds);
2995
+ failed.shards = [];
2996
+ this.#emit("shardsReassigned", {
2997
+ shardIds,
2998
+ from: failed.id,
2999
+ to: target.id
3000
+ });
3001
+ }
3002
+ #clearStartTimer(id) {
3003
+ const timer = this.#startTimers.get(id);
3004
+ if (timer)
3005
+ clearTimeout(timer);
3006
+ this.#startTimers.delete(id);
3007
+ }
3008
+ #sendFrame(cluster, frame) {
3009
+ const child = cluster.process;
3010
+ if (!isLive(cluster.state) || typeof child.send !== "function" || !child.connected)
3011
+ return false;
3012
+ try {
3013
+ child.send(frame);
3014
+ return true;
3015
+ } catch {
3016
+ return false;
3017
+ }
3018
+ }
3019
+ #announceDown(exited, shards) {
3020
+ for (const [id, cluster] of this.clusters)
3021
+ if (id !== exited)
3022
+ this.#sendFrame(cluster, { [PEER_DOWN_FRAME]: shards });
3023
+ }
3024
+ #onFrame(info, child, frame) {
3025
+ if (info.process !== child)
3026
+ return;
3027
+ if (!frame || typeof frame !== "object")
3028
+ return;
3029
+ if (BUS_FRAME in frame) {
3030
+ this.#onBus(info, frame[BUS_FRAME]);
3031
+ return;
3032
+ }
3033
+ if (CLUSTER_FRAME in frame) {
3034
+ const body = parseChildFrame(frame[CLUSTER_FRAME]);
3035
+ if (body)
3036
+ this.#onControl(info, child, body);
3037
+ else
3038
+ this.#emit("error", new Error(`Cluster ${info.id} sent a malformed control frame.`));
3039
+ }
3040
+ }
3041
+ #onControl(info, child, frame) {
3042
+ switch (frame.kind) {
3043
+ case "ready": {
3044
+ if (!frame.shardIds.every((id) => info.shards.includes(id))) {
3045
+ this.#emit("error", new Error(`Cluster ${info.id} reported shards it does not own: [${frame.shardIds}].`));
3046
+ return;
3047
+ }
3048
+ this.#clearStartTimer(info.id);
3049
+ info.state = "ready";
3050
+ info.readyAt = info.lastReadyAt = Date.now();
3051
+ this.#emit("clusterReady", info);
3052
+ this.#checkReady();
3053
+ return;
3054
+ }
3055
+ case "identify": {
3056
+ const reply = (body) => {
3057
+ if (info.process === child)
3058
+ this.#sendFrame(info, { [CLUSTER_FRAME]: body });
3059
+ };
3060
+ if (!this.#coordinator) {
3061
+ reply({
3062
+ kind: "identify-deny",
3063
+ id: frame.id,
3064
+ error: "IDENTIFY coordination is off."
3065
+ });
3066
+ return;
3067
+ }
3068
+ if (!info.shards.includes(frame.shardId)) {
3069
+ reply({
3070
+ kind: "identify-deny",
3071
+ id: frame.id,
3072
+ error: `Shard ${frame.shardId} does not belong to cluster ${info.id}.`
3073
+ });
3074
+ return;
3075
+ }
3076
+ let waiting = this.#identifyWaiting.get(info.id);
3077
+ if (!waiting)
3078
+ this.#identifyWaiting.set(info.id, waiting = new Set);
3079
+ if (waiting.has(frame.shardId)) {
3080
+ reply({
3081
+ kind: "identify-deny",
3082
+ id: frame.id,
3083
+ error: `Shard ${frame.shardId} is already waiting for a slot.`
3084
+ });
3085
+ return;
3086
+ }
3087
+ waiting.add(frame.shardId);
3088
+ const settled = () => {
3089
+ waiting.delete(frame.shardId);
3090
+ };
3091
+ this.#coordinator.acquire(info.id, frame.shardId).then(() => {
3092
+ settled();
3093
+ reply({ kind: "identify-grant", id: frame.id });
3094
+ }, (error) => {
3095
+ settled();
3096
+ reply({
3097
+ kind: "identify-deny",
3098
+ id: frame.id,
3099
+ error: error instanceof Error ? error.message : String(error)
3100
+ });
3101
+ });
3102
+ return;
3103
+ }
3104
+ case "identify-done":
3105
+ this.#coordinator?.release(info.id, frame.shardId);
3106
+ return;
3107
+ case "reply": {
3108
+ const tooBig = frame.error === undefined && this.#tooBig(frame.result);
3109
+ this.#tracker.answer(frame.id, `c:${info.id}`, info.id, {
3110
+ ...frame.error !== undefined ? { error: frame.error } : tooBig ? {
3111
+ error: `Cluster ${info.id} answered with more than ${this.#maxBytes} bytes.`
3112
+ } : { result: frame.result }
3113
+ });
3114
+ return;
3115
+ }
3116
+ case "health":
3117
+ if (this.#tooBig(frame.report)) {
3118
+ this.#emit("error", new Error(`Cluster ${info.id} sent a health report of more than ${this.#maxBytes} bytes.`));
3119
+ return;
3120
+ }
3121
+ info.report = frame.report;
3122
+ return;
3123
+ }
3124
+ }
3125
+ get #maxBytes() {
3126
+ return this.#options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
3127
+ }
3128
+ #tooBig(value) {
3129
+ return payloadSize(value) > this.#maxBytes;
3130
+ }
3131
+ #assertSize(data, what) {
3132
+ if (this.#tooBig(data))
3133
+ throw new RangeError(`${what} is larger than maxMessageBytes (${this.#maxBytes}).`);
3134
+ }
3135
+ #onBus(from, raw) {
3136
+ const message = raw;
3137
+ if (!message || typeof message !== "object" || typeof message.type !== "string" || typeof message.source !== "number" || message.target !== null && !Number.isInteger(message.target))
3138
+ return;
3139
+ if (this.#tooBig(message.data)) {
3140
+ this.#emit("error", new Error(`Cluster ${from.id} sent a shard-bus message of more than ${this.#maxBytes} bytes; it was not relayed.`));
3141
+ return;
3142
+ }
3143
+ const frame = { [BUS_FRAME]: message };
3144
+ if (message.target === PARENT_ID) {
3145
+ const reply = message.data;
3146
+ if (message.type === REPLY_TYPE && reply && typeof reply.requestId === "string")
3147
+ this.#tracker.answer(reply.requestId, `s:${message.source}`, from.id, reply.error !== undefined ? { error: String(reply.error) } : { result: reply.result });
3148
+ return;
3149
+ }
3150
+ if (message.target === null) {
3151
+ for (const [id, cluster] of this.clusters)
3152
+ if (id !== from.id)
3153
+ this.#sendFrame(cluster, frame);
3154
+ return;
3155
+ }
3156
+ const owner = this.clusterForShard(message.target);
3157
+ if (!owner || owner.id === from.id)
3158
+ return;
3159
+ if (!this.#sendFrame(owner, frame))
3160
+ this.#sendFrame(from, {
3161
+ [PEER_DOWN_FRAME]: [message.target]
3162
+ });
3163
+ }
3164
+ async request(shardId, type, data, options = {}) {
3165
+ this.#assertSize(data, "The request data");
3166
+ const owner = this.clusterForShard(shardId);
3167
+ if (!owner)
3168
+ throw new RangeError(`Shard ${shardId} is not part of this deployment.`);
3169
+ const { id, done } = this.#tracker.begin({
3170
+ keys: new Map([[`s:${shardId}`, owner.id]]),
3171
+ timeoutMs: options.timeoutMs ?? this.#options.requestTimeout ?? 5000,
3172
+ signal: options.signal
3173
+ });
3174
+ const sent = this.#sendFrame(owner, {
3175
+ [BUS_FRAME]: {
3176
+ source: PARENT_ID,
3177
+ target: shardId,
3178
+ type,
3179
+ data,
3180
+ id,
3181
+ expectsReply: true
3182
+ }
3183
+ });
3184
+ if (!sent) {
3185
+ this.#tracker.failOwner(owner.id, `Shard ${shardId} is down: cluster ${owner.id} is ${owner.state}.`);
3186
+ }
3187
+ const { replies } = await done;
3188
+ const reply = replies[0];
3189
+ if (!reply)
3190
+ throw new Error(`Shard ${shardId} did not reply to "${type}" in time.`);
3191
+ if (reply.error !== undefined)
3192
+ throw new Error(reply.error);
3193
+ return reply.result;
3194
+ }
3195
+ async broadcast(type, data, options = {}) {
3196
+ this.#assertSize(data, "The request data");
3197
+ const keys = new Map;
3198
+ const down = [];
3199
+ for (const [shard, clusterId] of this.#owner) {
3200
+ const cluster = this.#infos.get(clusterId);
3201
+ if (cluster && isLive(cluster.state))
3202
+ keys.set(`s:${shard}`, clusterId);
3203
+ else
3204
+ down.push(shard);
3205
+ }
3206
+ const { id, done } = this.#tracker.begin({
3207
+ keys,
3208
+ timeoutMs: options.timeoutMs ?? this.#options.requestTimeout ?? 5000,
3209
+ signal: options.signal
3210
+ });
3211
+ const frame = {
3212
+ [BUS_FRAME]: {
3213
+ source: PARENT_ID,
3214
+ target: null,
3215
+ type,
3216
+ data,
3217
+ id,
3218
+ expectsReply: true
3219
+ }
3220
+ };
3221
+ for (const cluster of this.clusters.values())
3222
+ if (!this.#sendFrame(cluster, frame))
3223
+ this.#tracker.failOwner(cluster.id, `Cluster ${cluster.id} is ${cluster.state}.`);
3224
+ const { replies, missing } = await done;
3225
+ return {
3226
+ replies: replies.map((reply) => ({
3227
+ shardId: Number(reply.key.slice(2)),
3228
+ ...reply.error !== undefined ? { error: reply.error } : { result: reply.result }
3229
+ })),
3230
+ missing: [
3231
+ ...down,
3232
+ ...missing.map((key) => Number(key.slice(2)))
3233
+ ].sort((a, b) => a - b)
3234
+ };
3235
+ }
3236
+ async requestCluster(clusterId, type, data, options = {}) {
3237
+ this.#assertSize(data, "The request data");
3238
+ const cluster = this.#infos.get(clusterId);
3239
+ if (!cluster)
3240
+ throw new RangeError(`Cluster ${clusterId} does not exist.`);
3241
+ const { id, done } = this.#tracker.begin({
3242
+ keys: new Map([[`c:${clusterId}`, clusterId]]),
3243
+ timeoutMs: options.timeoutMs ?? this.#options.requestTimeout ?? 5000,
3244
+ signal: options.signal
3245
+ });
3246
+ if (!this.#sendFrame(cluster, {
3247
+ [CLUSTER_FRAME]: { kind: "request", id, type, data }
3248
+ }))
3249
+ this.#tracker.failOwner(clusterId, `Cluster ${clusterId} is ${cluster.state}.`);
3250
+ const { replies } = await done;
3251
+ const reply = replies[0];
3252
+ if (!reply)
3253
+ throw new Error(`Cluster ${clusterId} did not reply to "${type}" in time.`);
3254
+ if (reply.error !== undefined)
3255
+ throw new Error(reply.error);
3256
+ return reply.result;
3257
+ }
3258
+ ready(timeoutMs) {
3259
+ if (this.#isReady())
3260
+ return Promise.resolve();
3261
+ return new Promise((resolve, reject) => {
3262
+ const waiter = {
3263
+ resolve: () => {
3264
+ if (waiter.timer)
3265
+ clearTimeout(waiter.timer);
3266
+ this.#readyWaiters.delete(waiter);
3267
+ resolve();
3268
+ },
3269
+ reject: (error) => {
3270
+ if (waiter.timer)
3271
+ clearTimeout(waiter.timer);
3272
+ this.#readyWaiters.delete(waiter);
3273
+ reject(error);
3274
+ }
3275
+ };
3276
+ if (timeoutMs !== undefined)
3277
+ waiter.timer = setTimeout(() => waiter.reject(new Error(`Clusters were not all ready within ${timeoutMs}ms.`)), timeoutMs);
3278
+ this.#readyWaiters.add(waiter);
3279
+ });
3280
+ }
3281
+ #isReady() {
3282
+ if (this.#infos.size === 0)
3283
+ return false;
3284
+ for (const cluster of this.#infos.values())
3285
+ if (cluster.state !== "ready")
3286
+ return false;
3287
+ return true;
3288
+ }
3289
+ #checkReady() {
3290
+ if (!this.#isReady())
3291
+ return;
3292
+ for (const waiter of [...this.#readyWaiters])
3293
+ waiter.resolve();
3294
+ if (!this.#allReady) {
3295
+ this.#allReady = true;
3296
+ this.#emit("ready");
3297
+ }
3298
+ }
3299
+ health() {
3300
+ const now = Date.now();
3301
+ const clusters = [...this.#infos.values()].sort((a, b) => a.id - b.id).map((cluster) => ({
3302
+ id: cluster.id,
3303
+ state: cluster.state,
3304
+ ...cluster.process.pid !== undefined && isLive(cluster.state) ? { pid: cluster.process.pid } : {},
3305
+ host: cluster.host,
3306
+ shardIds: [...cluster.shards],
3307
+ uptimeMs: isLive(cluster.state) ? now - cluster.startedAt : 0,
3308
+ restarts: cluster.restarts,
3309
+ startedAt: cluster.startedAt,
3310
+ ...cluster.readyAt !== undefined ? { readyAt: cluster.readyAt } : {},
3311
+ ...cluster.lastReadyAt !== undefined ? { lastReadyAt: cluster.lastReadyAt } : {},
3312
+ shards: cluster.report?.shards ?? [],
3313
+ ...cluster.report?.metrics ? { metrics: cluster.report.metrics } : {},
3314
+ ...cluster.report ? { reportedAt: cluster.report.reportedAt } : {}
3315
+ }));
3316
+ const missingShards = [...this.#owner].filter(([, id]) => this.#infos.get(id)?.state !== "ready").map(([shard]) => shard).sort((a, b) => a - b);
3317
+ const states = clusters.map((cluster) => cluster.state);
3318
+ const state = this.#stopping ? "stopping" : clusters.length === 0 ? this.#wasStarted ? "stopped" : "idle" : states.every((s) => s === "stopped") ? "stopped" : states.every((s) => s === "ready") ? "ready" : states.some((s) => s === "restarting" || s === "failed") ? "degraded" : "starting";
3319
+ return {
3320
+ state,
3321
+ totalShards: this.shardCount,
3322
+ clusters,
3323
+ readyClusters: states.filter((s) => s === "ready").length,
3324
+ missingShards
3325
+ };
3326
+ }
3327
+ async refreshHealth(options = {}) {
3328
+ await Promise.all([...this.clusters.values()].map(async (cluster) => {
3329
+ try {
3330
+ cluster.report = await this.requestCluster(cluster.id, REQUEST_HEALTH, { metrics: options.metrics === true }, { timeoutMs: options.timeoutMs ?? 2000 });
3331
+ } catch {}
3332
+ }));
3333
+ return this.health();
3334
+ }
3335
+ async checkAutoScale() {
3336
+ if (!this.#auto)
3337
+ return;
3338
+ try {
3339
+ const recommended = await this.fetchRecommendedShardCount();
3340
+ if (recommended !== this.shardCount) {
3341
+ await this.respawn(recommended);
3342
+ }
3343
+ } catch (error) {
3344
+ const normalized = error instanceof Error ? error : new Error(String(error));
3345
+ this.#options.onAutoScaleError?.(normalized);
3346
+ }
3347
+ }
3348
+ async respawn(newShardCount) {
3349
+ this.#options.shardCount = newShardCount;
3350
+ await this.#stop(this.#options.shutdownTimeout ?? 5000, true);
3351
+ this.#supervisor.reset();
1729
3352
  await this.spawn();
1730
3353
  }
1731
- async shutdownAll(timeoutMs = this.#options.shutdownTimeout ?? 5000) {
3354
+ shutdownAll(timeoutMs = this.#options.shutdownTimeout ?? 5000) {
3355
+ return this.#stop(timeoutMs, false);
3356
+ }
3357
+ #stop(timeoutMs, keepRemote) {
3358
+ this.#stopPromise ??= this.#shutdown(timeoutMs, keepRemote).finally(() => {
3359
+ this.#stopPromise = undefined;
3360
+ });
3361
+ return this.#stopPromise;
3362
+ }
3363
+ async#shutdown(timeoutMs, keepRemote) {
1732
3364
  this.#stopping = true;
1733
- this.#clearRestartTimers();
1734
- if (this.#autoScaleTimer) {
1735
- clearInterval(this.#autoScaleTimer);
1736
- this.#autoScaleTimer = undefined;
1737
- }
3365
+ this.#halt();
3366
+ await this.#startPromise?.catch(() => {
3367
+ return;
3368
+ });
1738
3369
  await Promise.all([...this.clusters.values()].map((cluster) => this.#shutdownCluster(cluster, timeoutMs)));
3370
+ for (const cluster of this.#infos.values())
3371
+ cluster.state = "stopped";
1739
3372
  this.clusters.clear();
1740
- this.#spawned = false;
3373
+ this.#startPromise = undefined;
3374
+ if (!keepRemote)
3375
+ this.#remote?.stop();
1741
3376
  this.#stopping = false;
1742
3377
  }
1743
- #clearRestartTimers() {
3378
+ #halt() {
1744
3379
  for (const timer of this.#restartTimers)
1745
3380
  clearTimeout(timer);
1746
3381
  this.#restartTimers.clear();
3382
+ for (const id of [...this.#startTimers.keys()])
3383
+ this.#clearStartTimer(id);
3384
+ for (const wake of [...this.#pauses])
3385
+ wake();
3386
+ if (this.#autoScaleTimer) {
3387
+ clearInterval(this.#autoScaleTimer);
3388
+ this.#autoScaleTimer = undefined;
3389
+ }
3390
+ this.#coordinator?.close();
3391
+ this.#coordinator = undefined;
3392
+ this.#remote?.abortWaits();
3393
+ this.#tracker.close();
3394
+ for (const waiter of [...this.#readyWaiters])
3395
+ waiter.reject(new Error("The cluster manager is stopping."));
1747
3396
  }
1748
3397
  async#shutdownCluster(cluster, timeoutMs) {
1749
3398
  const child = cluster.process;
3399
+ cluster.state = "stopping";
1750
3400
  if (child.exitCode !== null || child.signalCode !== null)
1751
3401
  return;
1752
3402
  await new Promise((resolve) => {
@@ -1773,24 +3423,293 @@ class ClusterManager {
1773
3423
  }
1774
3424
  killAll() {
1775
3425
  this.#stopping = true;
1776
- this.#clearRestartTimers();
1777
- if (this.#autoScaleTimer) {
1778
- clearInterval(this.#autoScaleTimer);
1779
- this.#autoScaleTimer = undefined;
1780
- }
1781
- for (const cluster of this.clusters.values()) {
3426
+ this.#halt();
3427
+ for (const cluster of this.#infos.values()) {
3428
+ cluster.state = "stopped";
1782
3429
  try {
1783
3430
  cluster.process.kill();
1784
3431
  } catch {}
1785
3432
  }
1786
3433
  this.clusters.clear();
1787
- this.#spawned = false;
3434
+ this.#startPromise = undefined;
3435
+ this.#remote?.stop();
1788
3436
  this.#stopping = false;
1789
3437
  }
1790
3438
  }
3439
+ // packages/sharding/src/host.ts
3440
+ import { fork as fork2 } from "child_process";
3441
+ class ClusterHost {
3442
+ #options;
3443
+ #running = new Map;
3444
+ #listeners = new Map;
3445
+ #socket;
3446
+ #lease = 15000;
3447
+ #lastRx = 0;
3448
+ #watchdog;
3449
+ #retry;
3450
+ #delay;
3451
+ #closed = true;
3452
+ #connected = false;
3453
+ #firstConnect;
3454
+ constructor(options) {
3455
+ if (!HOST_ID.test(options.id))
3456
+ throw new RangeError("id must be 1-64 letters, digits, '.', '_' or '-'.");
3457
+ if (!options.secret || options.secret.length < 16)
3458
+ throw new RangeError("secret must be at least 16 characters.");
3459
+ if (!options.script?.trim())
3460
+ throw new TypeError("A worker script path is required.");
3461
+ let url;
3462
+ try {
3463
+ url = new URL(options.url);
3464
+ } catch {
3465
+ throw new TypeError("url must be an absolute ws:// or wss:// URL.");
3466
+ }
3467
+ if (url.protocol !== "ws:" && url.protocol !== "wss:")
3468
+ throw new TypeError("url must start with ws:// or wss://.");
3469
+ if (url.username || url.password)
3470
+ throw new TypeError("url must not contain credentials.");
3471
+ if (url.protocol === "ws:" && !options.allowInsecure && !isLoopbackHost(url.hostname))
3472
+ throw new TypeError("url must use wss:// to reach another machine (allowInsecure: true overrides this for a network you fully trust).");
3473
+ this.#options = options;
3474
+ this.#delay = options.reconnectDelay ?? 1000;
3475
+ }
3476
+ get connected() {
3477
+ return this.#connected;
3478
+ }
3479
+ clusters() {
3480
+ return [...this.#running.keys()].sort((a, b) => a - b);
3481
+ }
3482
+ on(event, listener) {
3483
+ let set = this.#listeners.get(event);
3484
+ if (!set)
3485
+ this.#listeners.set(event, set = new Set);
3486
+ set.add(listener);
3487
+ return this;
3488
+ }
3489
+ off(event, listener) {
3490
+ this.#listeners.get(event)?.delete(listener);
3491
+ return this;
3492
+ }
3493
+ #emit(event, ...args) {
3494
+ for (const listener of this.#listeners.get(event) ?? [])
3495
+ try {
3496
+ listener(...args);
3497
+ } catch {}
3498
+ }
3499
+ connect() {
3500
+ if (!this.#closed)
3501
+ return Promise.resolve();
3502
+ this.#closed = false;
3503
+ return new Promise((resolve, reject) => {
3504
+ this.#firstConnect = { resolve, reject };
3505
+ this.#open();
3506
+ });
3507
+ }
3508
+ close() {
3509
+ this.#closed = true;
3510
+ if (this.#retry)
3511
+ clearTimeout(this.#retry);
3512
+ this.#retry = undefined;
3513
+ this.#fence();
3514
+ if (this.#watchdog)
3515
+ clearInterval(this.#watchdog);
3516
+ this.#watchdog = undefined;
3517
+ this.#connected = false;
3518
+ const socket = this.#socket;
3519
+ this.#socket = undefined;
3520
+ this.#firstConnect?.reject(new Error("The host was closed."));
3521
+ this.#firstConnect = undefined;
3522
+ if (!socket || socket.readyState === WebSocket.CLOSED)
3523
+ return Promise.resolve();
3524
+ return new Promise((resolve) => {
3525
+ socket.addEventListener("close", () => resolve(), { once: true });
3526
+ try {
3527
+ socket.close(1000, "Host closed.");
3528
+ } catch {
3529
+ resolve();
3530
+ }
3531
+ });
3532
+ }
3533
+ #open() {
3534
+ const socket = new WebSocket(this.#options.url);
3535
+ this.#socket = socket;
3536
+ socket.addEventListener("message", (event) => this.#message(socket, event.data));
3537
+ socket.addEventListener("error", () => {});
3538
+ socket.addEventListener("close", (event) => {
3539
+ if (this.#socket === socket)
3540
+ this.#lost(event.code, event.reason);
3541
+ });
3542
+ }
3543
+ #send(socket, message) {
3544
+ if (socket.readyState !== WebSocket.OPEN)
3545
+ return false;
3546
+ try {
3547
+ socket.send(JSON.stringify(message));
3548
+ return true;
3549
+ } catch {
3550
+ return false;
3551
+ }
3552
+ }
3553
+ #message(socket, data) {
3554
+ if (this.#socket !== socket)
3555
+ return;
3556
+ const message = parseToHost(data);
3557
+ if (!message) {
3558
+ this.#emit("error", new Error("The coordinator sent a malformed frame."));
3559
+ return;
3560
+ }
3561
+ this.#lastRx = Date.now();
3562
+ switch (message.t) {
3563
+ case "challenge":
3564
+ if (message.version !== REMOTE_PROTOCOL_VERSION) {
3565
+ socket.close(RemoteClose.VersionMismatch, "Protocol version mismatch.");
3566
+ return;
3567
+ }
3568
+ this.#send(socket, {
3569
+ t: "hello",
3570
+ id: this.#options.id,
3571
+ mac: signChallenge(this.#options.secret, message.nonce, this.#options.id),
3572
+ version: REMOTE_PROTOCOL_VERSION
3573
+ });
3574
+ return;
3575
+ case "welcome":
3576
+ this.#lease = message.leaseMs;
3577
+ this.#connected = true;
3578
+ this.#delay = this.#options.reconnectDelay ?? 1000;
3579
+ this.#startWatchdog();
3580
+ this.#firstConnect?.resolve();
3581
+ this.#firstConnect = undefined;
3582
+ this.#emit("connected");
3583
+ return;
3584
+ case "ping":
3585
+ this.#send(socket, { t: "pong", n: message.n });
3586
+ return;
3587
+ case "bye":
3588
+ this.#fence();
3589
+ return;
3590
+ case "spawn":
3591
+ this.#spawn(socket, message.cluster, message.epoch, message.env);
3592
+ return;
3593
+ case "frame": {
3594
+ const run = this.#running.get(message.cluster);
3595
+ if (run?.epoch === message.epoch && run.child.connected)
3596
+ try {
3597
+ run.child.send(message.frame);
3598
+ } catch {}
3599
+ return;
3600
+ }
3601
+ case "kill": {
3602
+ const run = this.#running.get(message.cluster);
3603
+ if (run?.epoch === message.epoch && KILL_SIGNALS.has(message.signal))
3604
+ try {
3605
+ run.child.kill(message.signal);
3606
+ } catch {}
3607
+ return;
3608
+ }
3609
+ }
3610
+ }
3611
+ #spawn(socket, cluster, epoch, env) {
3612
+ const reply = (message) => {
3613
+ if (this.#socket === socket)
3614
+ this.#send(socket, message);
3615
+ };
3616
+ if (this.#running.has(cluster)) {
3617
+ reply({
3618
+ t: "error",
3619
+ cluster,
3620
+ epoch,
3621
+ message: `Cluster ${cluster} is already running here.`
3622
+ });
3623
+ return;
3624
+ }
3625
+ let child;
3626
+ try {
3627
+ const forkWorker = this.#options.fork ?? defaultFork;
3628
+ child = forkWorker(this.#options.script, {
3629
+ ...process.env,
3630
+ ...env
3631
+ });
3632
+ } catch (error) {
3633
+ reply({
3634
+ t: "error",
3635
+ cluster,
3636
+ epoch,
3637
+ message: error instanceof Error ? error.message : String(error)
3638
+ });
3639
+ return;
3640
+ }
3641
+ this.#running.set(cluster, { epoch, child });
3642
+ child.on("message", (frame) => reply({ t: "frame", cluster, epoch, frame }));
3643
+ child.once("exit", (code, signal) => {
3644
+ if (this.#running.get(cluster)?.child === child)
3645
+ this.#running.delete(cluster);
3646
+ this.#emit("clusterExit", cluster, code, signal);
3647
+ reply({ t: "exit", cluster, epoch, code, signal });
3648
+ });
3649
+ child.on("error", (error) => this.#emit("error", error instanceof Error ? error : new Error(String(error))));
3650
+ reply({
3651
+ t: "spawned",
3652
+ cluster,
3653
+ epoch,
3654
+ ...child.pid ? { pid: child.pid } : {}
3655
+ });
3656
+ this.#emit("clusterSpawn", cluster);
3657
+ }
3658
+ #fence() {
3659
+ for (const { child } of [...this.#running.values()])
3660
+ try {
3661
+ child.kill("SIGKILL");
3662
+ } catch {}
3663
+ }
3664
+ #startWatchdog() {
3665
+ if (this.#watchdog)
3666
+ clearInterval(this.#watchdog);
3667
+ this.#lastRx = Date.now();
3668
+ this.#watchdog = setInterval(() => {
3669
+ if (Date.now() - this.#lastRx > this.#lease) {
3670
+ this.#fence();
3671
+ this.#socket?.close(RemoteClose.HostLost, "Coordinator silent.");
3672
+ }
3673
+ }, Math.max(10, Math.floor(this.#lease / 3)));
3674
+ this.#watchdog.unref?.();
3675
+ }
3676
+ #lost(code, reason) {
3677
+ if (this.#watchdog)
3678
+ clearInterval(this.#watchdog);
3679
+ this.#watchdog = undefined;
3680
+ this.#socket = undefined;
3681
+ const wasConnected = this.#connected;
3682
+ this.#connected = false;
3683
+ this.#fence();
3684
+ if (wasConnected)
3685
+ this.#emit("disconnected", { code, reason });
3686
+ if (this.#closed)
3687
+ return;
3688
+ const refused = code === RemoteClose.AuthFailed || code === RemoteClose.UnknownHost || code === RemoteClose.DuplicateHost || code === RemoteClose.VersionMismatch;
3689
+ const first = this.#firstConnect;
3690
+ if (first) {
3691
+ this.#firstConnect = undefined;
3692
+ this.#closed = true;
3693
+ first.reject(new Error(`The coordinator refused this host (${code}${reason ? `: ${reason}` : ""}).`));
3694
+ return;
3695
+ }
3696
+ if (refused)
3697
+ this.#emit("error", new Error(`The coordinator refused this host (${code}: ${reason}).`));
3698
+ const wait = Math.round(this.#delay * (1 + Math.random() * 0.2));
3699
+ this.#delay = Math.min(this.#options.maxReconnectDelay ?? 30000, this.#delay * 2);
3700
+ this.#retry = setTimeout(() => {
3701
+ this.#retry = undefined;
3702
+ if (!this.#closed)
3703
+ this.#open();
3704
+ }, wait);
3705
+ }
3706
+ }
3707
+ function defaultFork(script, env) {
3708
+ return fork2(script, [], { env });
3709
+ }
1791
3710
 
1792
3711
  // packages/sharding/src/index.ts
1793
- var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3712
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1794
3713
  function needsConnection(gateway) {
1795
3714
  return gateway.state === "CONNECT" /* Connect */ || gateway.state === "RECONNECT" /* Reconnect */ || gateway.state === "CLOSED" /* Closed */;
1796
3715
  }
@@ -1824,9 +3743,17 @@ class ShardManager {
1824
3743
  static IDENTIFY_INTERVAL = 5000;
1825
3744
  shards = new Map;
1826
3745
  get shardCount() {
1827
- return this.shards.size;
3746
+ return this.#resolved ? this.#total : 0;
3747
+ }
3748
+ get shardIds() {
3749
+ return [...this.shards.keys()].sort((a, b) => a - b);
1828
3750
  }
1829
3751
  #options;
3752
+ #ids;
3753
+ #gate;
3754
+ #worker;
3755
+ #lifecycle = new Map;
3756
+ #announced = false;
1830
3757
  #resolved = false;
1831
3758
  #destroyed = false;
1832
3759
  #auto;
@@ -1839,9 +3766,30 @@ class ShardManager {
1839
3766
  throw new TypeError("A Discord bot token is required.");
1840
3767
  if (!Array.isArray(options.intents))
1841
3768
  throw new TypeError("Gateway intents must be an array, e.g. [GatewayIntentBits.Guilds].");
1842
- const count = options.shardCount === "auto" || options.shardCount === undefined ? 1 : options.shardCount;
3769
+ const context = options.cluster === false ? null : options.cluster ?? readClusterContext();
3770
+ if (context) {
3771
+ if (options.shardCount === "auto")
3772
+ throw new RangeError(`shardCount "auto" is the ClusterManager's decision inside a cluster worker.`);
3773
+ if (options.shardCount !== undefined && options.shardCount !== context.shardCount)
3774
+ throw new RangeError(`shardCount ${options.shardCount} differs from the cluster's ${context.shardCount}.`);
3775
+ if (options.autoScaleInterval !== undefined)
3776
+ throw new TypeError("autoScaleInterval belongs to ClusterManager inside a cluster worker.");
3777
+ if (options.shardIds && !options.shardIds.every((id) => context.shardIds.includes(id)))
3778
+ throw new RangeError("shardIds must be a subset of the cluster's own shards.");
3779
+ }
3780
+ const count = context ? context.shardCount : options.shardCount === "auto" || options.shardCount === undefined ? 1 : options.shardCount;
1843
3781
  if (!Number.isInteger(count) || count < 1)
1844
3782
  throw new RangeError("Shard count must be a positive integer.");
3783
+ if (options.guildsPerShard !== undefined) {
3784
+ assertGuildsPerShard(options.guildsPerShard);
3785
+ if (options.shardCount !== "auto")
3786
+ throw new RangeError('guildsPerShard needs shardCount: "auto".');
3787
+ }
3788
+ this.#ids = options.shardIds ?? context?.shardIds;
3789
+ if (this.#ids && options.shardCount !== "auto")
3790
+ validateShardIds(this.#ids, count);
3791
+ this.#worker = options.worker === false ? undefined : options.worker ?? (context && options.cluster === undefined ? ClusterWorker.current() : undefined);
3792
+ this.#gate = options.identify === false ? undefined : options.identify ?? (context?.coordinatedIdentify ? this.#worker?.identifyGate : undefined);
1845
3793
  if (options.autoScaleInterval !== undefined && (!Number.isInteger(options.autoScaleInterval) || options.autoScaleInterval < 1000))
1846
3794
  throw new RangeError("autoScaleInterval must be an integer of at least 1000 milliseconds.");
1847
3795
  if (options.spawnDelay !== undefined && (!Number.isFinite(options.spawnDelay) || options.spawnDelay < 0))
@@ -1856,11 +3804,19 @@ class ShardManager {
1856
3804
  spawnDelay: ShardManager.IDENTIFY_INTERVAL,
1857
3805
  ...options
1858
3806
  };
3807
+ this.#worker?.onRequest(REQUEST_HEALTH, (data) => this.report(data?.metrics === true));
3808
+ this.#worker?.onRequest(REQUEST_ASSIGN, async (data) => {
3809
+ const ids = data?.shardIds;
3810
+ if (!Array.isArray(ids))
3811
+ throw new TypeError("shardIds must be an array.");
3812
+ await this.addShards(ids);
3813
+ return { shardIds: ids };
3814
+ });
1859
3815
  if (options.shardCount !== "auto")
1860
3816
  this.#initialize(count);
1861
3817
  }
1862
3818
  async fetchRecommendedShardCount() {
1863
- return (await this.fetchGatewayInfo()).shards;
3819
+ return resolveShardCount(this.#options.token, await this.fetchGatewayInfo(), this.#options);
1864
3820
  }
1865
3821
  async fetchGatewayInfo() {
1866
3822
  const info = await fetchGatewayBot(this.#options.token);
@@ -1873,8 +3829,63 @@ class ShardManager {
1873
3829
  this.#autoScaleTimer = undefined;
1874
3830
  }
1875
3831
  await this.#ensureInitialized();
3832
+ await this.#start([...this.shards].filter(([, gateway]) => needsConnection(gateway)).map(([id]) => id));
3833
+ if (this.#options.autoScaleInterval && !this.#autoScaleTimer) {
3834
+ this.#autoScaleTimer = setInterval(() => {
3835
+ this.checkAutoScale();
3836
+ }, this.#options.autoScaleInterval);
3837
+ }
3838
+ }
3839
+ async addShards(shardIds) {
3840
+ await this.#ensureInitialized();
3841
+ validateShardIds(shardIds, this.#total);
3842
+ for (const id of shardIds)
3843
+ if (this.shards.has(id))
3844
+ throw new RangeError(`Shard ${id} is already owned.`);
3845
+ for (const id of shardIds)
3846
+ this.shards.set(id, this.#createShard(id, this.#total));
3847
+ if (this.#ids)
3848
+ this.#ids = [...this.#ids, ...shardIds];
3849
+ this.#announced = false;
3850
+ try {
3851
+ await this.#start([...shardIds], false);
3852
+ } catch (error) {
3853
+ for (const id of shardIds) {
3854
+ this.shards.get(id)?.close();
3855
+ this.shards.delete(id);
3856
+ this.#lifecycle.delete(id);
3857
+ }
3858
+ if (this.#ids)
3859
+ this.#ids = this.#ids.filter((id) => !shardIds.includes(id));
3860
+ throw error;
3861
+ }
3862
+ }
3863
+ async#start(ids, destroyOnFailure = true) {
3864
+ const gate = this.#gate;
3865
+ if (gate) {
3866
+ const timeout = this.#options.handshakeTimeout ?? 15000;
3867
+ const results = await Promise.allSettled(ids.map(async (id) => {
3868
+ const gateway = this.shards.get(id);
3869
+ this.#setStatus(id, "connecting");
3870
+ await gate.acquire(id);
3871
+ try {
3872
+ await gateway.connect();
3873
+ await handshakeSent(gateway, timeout);
3874
+ } finally {
3875
+ gate.release(id);
3876
+ }
3877
+ }));
3878
+ const failed = results.findIndex((result) => result.status === "rejected");
3879
+ if (failed !== -1) {
3880
+ if (destroyOnFailure)
3881
+ this.destroy();
3882
+ throw new Error(`Failed to connect shard ${ids[failed]}.`, {
3883
+ cause: results[failed].reason
3884
+ });
3885
+ }
3886
+ return;
3887
+ }
1876
3888
  const limit = this.#gatewayInfo?.sessionStartLimit;
1877
- const ids = [...this.shards].filter(([, gateway]) => needsConnection(gateway)).map(([id]) => id);
1878
3889
  if (limit && limit.remaining < ids.length)
1879
3890
  throw new Error(`Session start limit exhausted: ${limit.remaining} of ${limit.total} IDENTIFYs left for ${ids.length} shards; resets in ${Math.ceil(limit.resetAfter / 1000)}s.`);
1880
3891
  const concurrency = Math.min(this.#options.maxConcurrency ?? limit?.maxConcurrency ?? 1, limit?.maxConcurrency ?? Infinity);
@@ -1890,24 +3901,22 @@ class ShardManager {
1890
3901
  const rounds = Math.max(0, ...[...buckets.values()].map((bucket) => bucket.length));
1891
3902
  for (let index = 0;index < rounds; index++) {
1892
3903
  const round = [...buckets.values()].map((bucket) => bucket[index]).filter((id) => id !== undefined);
3904
+ for (const id of round)
3905
+ this.#setStatus(id, "connecting");
1893
3906
  const results = await Promise.allSettled(round.map((id) => this.shards.get(id).connect()));
1894
3907
  const failed = results.findIndex((result) => result.status === "rejected");
1895
3908
  if (failed !== -1) {
1896
- this.destroy();
3909
+ if (destroyOnFailure)
3910
+ this.destroy();
1897
3911
  throw new Error(`Failed to connect shard ${round[failed]}.`, {
1898
3912
  cause: results[failed].reason
1899
3913
  });
1900
3914
  }
1901
3915
  if (index + 1 < rounds && this.#options.spawnDelay && this.#options.spawnDelay > 0) {
1902
3916
  await Promise.all(round.map((id) => handshakeSent(this.shards.get(id), this.#options.handshakeTimeout ?? 15000)));
1903
- await sleep2(this.#options.spawnDelay);
3917
+ await sleep(this.#options.spawnDelay);
1904
3918
  }
1905
3919
  }
1906
- if (this.#options.autoScaleInterval && !this.#autoScaleTimer) {
1907
- this.#autoScaleTimer = setInterval(() => {
1908
- this.checkAutoScale();
1909
- }, this.#options.autoScaleInterval);
1910
- }
1911
3920
  }
1912
3921
  async checkAutoScale() {
1913
3922
  if (!this.#auto)
@@ -1929,7 +3938,11 @@ class ShardManager {
1929
3938
  destroy() {
1930
3939
  for (const shard of this.shards.values())
1931
3940
  shard.close();
3941
+ for (const id of this.shards.keys())
3942
+ this.#setStatus(id, "destroyed");
1932
3943
  this.shards.clear();
3944
+ this.#lifecycle.clear();
3945
+ this.#announced = false;
1933
3946
  this.#resolved = false;
1934
3947
  this.#destroyed = true;
1935
3948
  if (this.#autoScaleTimer) {
@@ -1965,11 +3978,60 @@ class ShardManager {
1965
3978
  return this.shards.get(id);
1966
3979
  }
1967
3980
  health() {
1968
- return [...this.shards].map(([id, gateway]) => ({
1969
- id,
1970
- state: gateway.state,
1971
- ping: gateway.ping
1972
- }));
3981
+ return [...this.shards].map(([id, gateway]) => {
3982
+ const life = this.#lifecycle.get(id);
3983
+ return {
3984
+ id,
3985
+ state: gateway.state,
3986
+ ping: gateway.ping,
3987
+ status: life?.status ?? "idle",
3988
+ ...life?.readyAt !== undefined ? { readyAt: life.readyAt } : {},
3989
+ ...life?.disconnectedAt !== undefined ? { disconnectedAt: life.disconnectedAt } : {}
3990
+ };
3991
+ });
3992
+ }
3993
+ report(metrics = false) {
3994
+ let numbers;
3995
+ if (metrics) {
3996
+ const memory = process.memoryUsage();
3997
+ const cpu = process.cpuUsage();
3998
+ numbers = {
3999
+ rssBytes: memory.rss,
4000
+ heapUsedBytes: memory.heapUsed,
4001
+ cpuUserMicros: cpu.user,
4002
+ cpuSystemMicros: cpu.system
4003
+ };
4004
+ }
4005
+ return {
4006
+ shards: this.health(),
4007
+ ...numbers ? { metrics: numbers } : {},
4008
+ reportedAt: Date.now()
4009
+ };
4010
+ }
4011
+ #setStatus(id, status) {
4012
+ const life = this.#lifecycle.get(id) ?? { status };
4013
+ life.status = status;
4014
+ const now = Date.now();
4015
+ if (status === "ready")
4016
+ life.readyAt = now;
4017
+ if (status === "disconnected")
4018
+ life.disconnectedAt = now;
4019
+ this.#lifecycle.set(id, life);
4020
+ if (status !== "ready")
4021
+ this.#announced = false;
4022
+ else
4023
+ this.#maybeAnnounce();
4024
+ }
4025
+ #maybeAnnounce() {
4026
+ if (this.#announced || this.shards.size === 0)
4027
+ return;
4028
+ for (const id of this.shards.keys())
4029
+ if (this.#lifecycle.get(id)?.status !== "ready")
4030
+ return;
4031
+ this.#announced = true;
4032
+ this.#emit("ready");
4033
+ this.#worker?.ready(this.shardIds);
4034
+ this.#worker?.reportHealth(this.report());
1973
4035
  }
1974
4036
  values() {
1975
4037
  return [...this.shards].map(([id, gateway]) => ({ id, gateway }));
@@ -1986,7 +4048,9 @@ class ShardManager {
1986
4048
  if (this.#resolved)
1987
4049
  return;
1988
4050
  this.#total = count;
1989
- for (let id = 0;id < count; id++)
4051
+ const ids = this.#ids ?? Array.from({ length: count }, (_, i) => i);
4052
+ validateShardIds(ids, count);
4053
+ for (const id of ids)
1990
4054
  this.shards.set(id, this.#createShard(id, count));
1991
4055
  this.#resolved = true;
1992
4056
  }
@@ -1998,23 +4062,50 @@ class ShardManager {
1998
4062
  shardCount: count,
1999
4063
  reconnect: this.#options.reconnect ?? true
2000
4064
  });
2001
- gateway.on("ready", () => this.#emit("shardReady", id));
2002
- gateway.on("resumed", () => this.#emit("shardResume", id));
2003
- gateway.on("close", (close) => this.#emit("shardDisconnect", id, close));
4065
+ gateway.on("ready", () => {
4066
+ this.#setStatus(id, "ready");
4067
+ this.#emit("shardReady", id);
4068
+ });
4069
+ gateway.on("resumed", () => {
4070
+ this.#setStatus(id, "ready");
4071
+ this.#emit("shardResume", id);
4072
+ });
4073
+ gateway.on("close", (close) => {
4074
+ this.#setStatus(id, "disconnected");
4075
+ this.#emit("shardDisconnect", id, close);
4076
+ });
2004
4077
  gateway.on("error", (error) => this.#emit("shardError", id, error));
2005
- gateway.on("unstable", (info) => this.#emit("shardUnstable", id, info));
4078
+ gateway.on("unstable", (info) => {
4079
+ this.#setStatus(id, "unstable");
4080
+ this.#emit("shardUnstable", id, info);
4081
+ });
2006
4082
  return gateway;
2007
4083
  }
2008
4084
  }
2009
4085
  export {
2010
4086
  BroadcastChannelTransport,
4087
+ ClusterHost,
2011
4088
  ClusterManager,
4089
+ ClusterWorker,
4090
+ IDENTIFY_INTERVAL,
4091
+ IdentifyCoordinator,
2012
4092
  IpcTransport,
4093
+ MAX_GUILDS_PER_SHARD,
4094
+ REMOTE_PROTOCOL_VERSION,
4095
+ RemoteClose,
4096
+ RequestTracker,
2013
4097
  ShardBus,
2014
4098
  ShardManager,
2015
4099
  ShardSupervisor,
2016
4100
  ShardManager as ShardingManager,
2017
- fetchGatewayBot
4101
+ allocateShards,
4102
+ assertCompleteAllocation,
4103
+ fetchGatewayBot,
4104
+ fetchGuildCount,
4105
+ isClusterWorker,
4106
+ readClusterContext,
4107
+ shardsForGuilds,
4108
+ validateShardIds
2018
4109
  };
2019
4110
 
2020
- //# debugId=8B521E4F34BB811E64756E2164756E21
4111
+ //# debugId=7D2FE3134CAB1F3364756E2164756E21