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
@@ -1,4 +1,7 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
+ import { type ShardReply } from "./bus.js";
3
+ import type { ClusterManagerHealth, ClusterState, WorkerReport } from "./health.js";
4
+ import { type HostStatus, type RemoteOptions } from "./remote.js";
2
5
  import { type SupervisorOptions } from "./supervisor.js";
3
6
  /** Configuration for managing multiple shard clusters. */
4
7
  export interface ClusterManagerOptions {
@@ -6,10 +9,81 @@ export interface ClusterManagerOptions {
6
9
  token: string;
7
10
  /** Path to the worker script to execute for each cluster. */
8
11
  script: string;
9
- /** Number of shards. Use "auto" to request Discord's recommended count. */
12
+ /** Total number of shards. Use "auto" to request Discord's recommended count. */
10
13
  shardCount?: number | "auto";
11
- /** Number of clusters to spawn. Defaults to the number of logical CPUs. */
14
+ /**
15
+ * With `shardCount: "auto"`, size the deployment by guilds instead of
16
+ * Discord's recommendation: `ceil(guilds / guildsPerShard)` shards, at
17
+ * least one. 1 to 2500 (Discord's limit); Discord recommends about 1000.
18
+ * Guilds are assigned to shards by their ID, so this sets the average,
19
+ * not an exact split.
20
+ */
21
+ guildsPerShard?: number;
22
+ /** The bot's guild count for `guildsPerShard`, instead of asking Discord for it. */
23
+ guildCount?: number;
24
+ /**
25
+ * The shards this deployment runs, out of `shardCount`. Use it to split one
26
+ * bot across hosts: each host runs a `ClusterManager` with its own range.
27
+ * Defaults to every shard.
28
+ */
29
+ shardIds?: number[];
30
+ /** Number of clusters to spawn. Defaults to the number of logical CPUs. Not combinable with `shardsPerCluster`. */
12
31
  clusterCount?: number;
32
+ /** Shards per cluster; the cluster count follows. Not combinable with `clusterCount`. */
33
+ shardsPerCluster?: number;
34
+ /**
35
+ * Discord's `max_concurrency` for IDENTIFY pacing. Fetched from `/gateway/bot`
36
+ * when `shardCount` is "auto" (this value may only lower it); otherwise defaults to 1.
37
+ */
38
+ maxConcurrency?: number;
39
+ /** Ask `/gateway/bot` for `max_concurrency` and the session budget even with a fixed `shardCount`. */
40
+ fetchLimits?: boolean;
41
+ /** IDENTIFY coordination across clusters. On by default. */
42
+ identify?: {
43
+ /** Set `false` to let every cluster pace its own IDENTIFYs. */
44
+ coordinate?: boolean;
45
+ /** Milliseconds between IDENTIFYs on one key. Default 5000. */
46
+ interval?: number;
47
+ /** A slot a worker never releases is released after this many ms. Default 60,000. */
48
+ holdTimeout?: number;
49
+ };
50
+ /** Milliseconds between forking two clusters, to spread the CPU cost of starting. Default 500. IDENTIFY pacing does not depend on it. */
51
+ spawnInterval?: number;
52
+ /**
53
+ * A cluster that has not reported ready this many ms after it was forked is
54
+ * killed and restarted like a crash. Off by default, because a worker that
55
+ * never reports ready (no `ShardManager`, no `ClusterWorker`) would be killed.
56
+ */
57
+ startTimeout?: number;
58
+ /** Make `spawn()` wait until every cluster reported ready. Default false. */
59
+ waitForReady?: boolean;
60
+ /**
61
+ * When a cluster crashes more often than `supervisor.maxRestarts` allows,
62
+ * move its shards to the healthiest ready cluster instead of leaving them
63
+ * offline. Default false.
64
+ */
65
+ reassignOnGiveUp?: boolean;
66
+ /**
67
+ * Largest shard-bus message, request payload or worker reply the manager
68
+ * handles, in UTF-8 bytes (a string as it is, anything else as its JSON text). Bigger ones from a worker are
69
+ * dropped before they are relayed (an `error` event says so; a request
70
+ * waiting for the answer times out as usual), and bigger ones passed to
71
+ * `request()`, `broadcast()` or `requestCluster()` throw a `RangeError`.
72
+ * Default 8 MiB, the same limit as the host link.
73
+ */
74
+ maxMessageBytes?: number;
75
+ /** Default timeout for `request()`, `requestCluster()` and `broadcast()`. Default 5000. */
76
+ requestTimeout?: number;
77
+ /** Most requests waiting for answers at once. Default 10,000. */
78
+ maxPendingRequests?: number;
79
+ /**
80
+ * Accept hosts on other machines. The coordinator listens for
81
+ * `ClusterHost` agents, places clusters on them and supervises those
82
+ * workers like local ones. See {@link RemoteOptions}.
83
+ */
84
+ remote?: RemoteOptions;
85
+ /** Starts a worker process. Defaults to `child_process.fork()`; replace it for tests or custom launchers. */
86
+ fork?: (script: string, env: Record<string, string | undefined>) => ChildProcess;
13
87
  /** Interval in milliseconds to automatically check for recommended shard count and re-scale if needed. */
14
88
  autoScaleInterval?: number;
15
89
  /** Optional handler invoked when a background auto-scale check fails. Receives the thrown error. */
@@ -37,35 +111,170 @@ export interface ClusterManagerOptions {
37
111
  /** Called whenever a cluster process exits, before any restart. */
38
112
  onClusterExit?(cluster: ClusterInfo, code: number | null, signal: NodeJS.Signals | null): void;
39
113
  }
40
- /** Information about a running cluster. */
114
+ /** Information about a cluster. The object is live: it is updated as the cluster changes. */
41
115
  export interface ClusterInfo {
42
116
  /** Cluster identifier. */
43
117
  id: number;
44
- /** The child process running the cluster. */
118
+ /** The current child process (the last one while a restart is pending). For a cluster on another host this is a proxy; `pid` is the pid on that host. */
45
119
  process: ChildProcess;
120
+ /** Where it runs: `"local"` or a host ID. */
121
+ host: string;
46
122
  /** The shard IDs managed by this cluster. */
47
123
  shards: number[];
124
+ /** Where the cluster is in its life. */
125
+ state: ClusterState;
126
+ /** How many times it was restarted after a crash. */
127
+ restarts: number;
128
+ /** When the current process was forked (epoch ms). */
129
+ startedAt: number;
130
+ /** When the current process reported ready. */
131
+ readyAt?: number;
132
+ /** When any process of this cluster last reported ready. */
133
+ lastReadyAt?: number;
134
+ /** The worker's last health report. */
135
+ report?: WorkerReport;
136
+ }
137
+ /** What `ClusterManager` emits. */
138
+ export interface ClusterManagerEvents {
139
+ /** A cluster process was forked. */
140
+ clusterSpawn: [cluster: ClusterInfo];
141
+ /** A cluster's shards all finished their handshake. */
142
+ clusterReady: [cluster: ClusterInfo];
143
+ /** Every cluster is ready. Emitted again after a recovery. */
144
+ ready: [];
145
+ /** A cluster process exited. `expected` is true for a deliberate shutdown. */
146
+ clusterExit: [
147
+ cluster: ClusterInfo,
148
+ code: number | null,
149
+ signal: NodeJS.Signals | null,
150
+ expected: boolean
151
+ ];
152
+ /** A restart was scheduled after a crash. */
153
+ clusterRestart: [
154
+ cluster: ClusterInfo,
155
+ info: {
156
+ delay: number;
157
+ attempt: number;
158
+ }
159
+ ];
160
+ /** A cluster keeps crashing: three or more restarts are pending or recent. */
161
+ clusterUnstable: [
162
+ cluster: ClusterInfo,
163
+ info: {
164
+ attempt: number;
165
+ delay: number;
166
+ }
167
+ ];
168
+ /** A cluster did not report ready within `startTimeout` and was killed. */
169
+ clusterStartTimeout: [cluster: ClusterInfo];
170
+ /** A cluster crashed too often and will not be restarted. */
171
+ clusterGiveUp: [cluster: ClusterInfo];
172
+ /** A failed cluster's shards were moved to another cluster. */
173
+ shardsReassigned: [info: {
174
+ shardIds: number[];
175
+ from: number;
176
+ to: number;
177
+ }];
178
+ /** A host joined the deployment. */
179
+ hostJoin: [hostId: string];
180
+ /** A host left or was lost. Its clusters are treated as crashed. */
181
+ hostLeave: [hostId: string, reason: string];
182
+ /** An IDENTIFY is waiting for the session-start budget to reset. */
183
+ identifyWait: [info: {
184
+ shardId: number;
185
+ waitMs: number;
186
+ }];
187
+ /** Something went wrong that did not stop the deployment (a bad frame, a failed recovery). */
188
+ error: [error: Error];
189
+ }
190
+ /** Options for a request to a shard or cluster. */
191
+ export interface RequestOptions {
192
+ /** Milliseconds to wait. Defaults to `requestTimeout`. */
193
+ timeoutMs?: number;
194
+ /** Cancels the wait. */
195
+ signal?: AbortSignal;
196
+ }
197
+ /** What a broadcast collected. It never rejects for a slow or failed shard. */
198
+ export interface BroadcastResult<R = unknown> {
199
+ /** One reply per answering shard: its result, or the error its handler threw. */
200
+ replies: ShardReply<R>[];
201
+ /** Shards that did not answer: down, restarting, or too slow. */
202
+ missing: number[];
48
203
  }
49
204
  /**
50
- * Manages multiple clusters of shards, distributing shards across child processes.
205
+ * Orchestrates cluster processes: forks one worker per cluster, gives each a
206
+ * fixed set of shards, restarts crashed clusters with backoff, hands out
207
+ * IDENTIFY slots across all of them, routes shard-bus messages to the cluster
208
+ * that owns the target shard, and answers requests, broadcasts and health
209
+ * queries for the whole deployment.
51
210
  *
52
- * **Runtime requirement:** clustering relies on Node.js `child_process.fork()`
53
- * and IPC. `fork()` is not fully supported under Bun, so run the cluster
54
- * manager under the Node.js runtime; `spawn()` will fail on a runtime whose
55
- * `fork()` is unavailable.
211
+ * Ownership: the `ClusterManager` decides which cluster owns which shard and
212
+ * keeps processes alive; the `ShardManager` inside each worker runs those
213
+ * shards' Gateways. A shard is never owned by two clusters at once.
214
+ *
215
+ * Works under Node.js and Bun (`child_process.fork()` with IPC).
56
216
  */
57
217
  export declare class ClusterManager {
58
218
  #private;
59
- /** Active clusters indexed by cluster ID. */
219
+ /** Live clusters (starting or ready) indexed by cluster ID. A crashed cluster leaves this map until it is restarted. */
60
220
  readonly clusters: Map<number, ClusterInfo>;
61
- /** Total number of shards across all clusters. */
221
+ /** Total number of shards in the deployment. */
62
222
  shardCount: number;
63
- /** Creates a cluster manager. @param options Clustering configuration. @throws {TypeError} If token or script is missing. */
223
+ /** Creates a cluster manager. @param options Clustering configuration. @throws {TypeError} If token or script is missing. @throws {RangeError} If a numeric option is invalid. */
64
224
  constructor(options: ClusterManagerOptions);
225
+ /** Listens for a deployment event. @returns This manager. */
226
+ on<E extends keyof ClusterManagerEvents>(event: E, listener: (...args: ClusterManagerEvents[E]) => void): this;
227
+ /** Stops listening for a deployment event. @returns This manager. */
228
+ off<E extends keyof ClusterManagerEvents>(event: E, listener: (...args: ClusterManagerEvents[E]) => void): this;
65
229
  /** Retrieves Discord's recommended shard count. @returns Recommended shard count. @throws {Error} If discovery fails or returns invalid data. */
66
230
  fetchRecommendedShardCount(): Promise<number>;
67
- /** Spawns all clusters. @returns A promise fulfilled after clusters have launched. */
231
+ /** The cluster that owns a shard, or `undefined` when the shard is not part of this deployment. */
232
+ clusterForShard(shardId: number): ClusterInfo | undefined;
233
+ /** Which cluster owns each shard. A copy: change ownership through recovery, not by writing to it. */
234
+ assignments(): ReadonlyMap<number, number>;
235
+ /**
236
+ * Spawns all clusters. Calling it again while clusters run, or while the
237
+ * first call is still starting them, returns the same promise.
238
+ * @throws {Error} If called from a cluster worker (a worker must not start the fleet again).
239
+ */
68
240
  spawn(): Promise<void>;
241
+ /** The port hosts connect to, once the listener is open (useful with `remote.port: 0`). */
242
+ get remotePort(): number | undefined;
243
+ /** The hosts of this deployment: connected or not, and the clusters on each. Empty without `remote`. */
244
+ hosts(): HostStatus[];
245
+ /**
246
+ * Sends a request to one shard and resolves with its handler's result.
247
+ * @throws {RangeError} If the shard is not part of this deployment.
248
+ * @throws {Error} If its cluster is down, the handler failed, or no answer arrives in time.
249
+ */
250
+ request<R = unknown, T = unknown>(shardId: number, type: string, data: T, options?: RequestOptions): Promise<R>;
251
+ /**
252
+ * Asks every shard of every live cluster and collects the answers. Shards
253
+ * whose cluster is down, or that do not answer in time, are listed in
254
+ * `missing`; it does not reject for them.
255
+ */
256
+ broadcast<R = unknown, T = unknown>(type: string, data: T, options?: RequestOptions): Promise<BroadcastResult<R>>;
257
+ /**
258
+ * Sends a request to a cluster's worker (see `ClusterWorker.onRequest()`).
259
+ * @throws {Error} If the cluster is unknown or down, the handler failed, or no answer arrives in time.
260
+ */
261
+ requestCluster<R = unknown, T = unknown>(clusterId: number, type: string, data: T, options?: RequestOptions): Promise<R>;
262
+ /** Resolves when every cluster is ready. @throws {Error} On timeout, or if the manager stops first. */
263
+ ready(timeoutMs?: number): Promise<void>;
264
+ /**
265
+ * The last known health of every cluster and shard. Reads what the
266
+ * workers last reported; nothing is sent. Use {@link refreshHealth} for fresh numbers.
267
+ */
268
+ health(): ClusterManagerHealth;
269
+ /**
270
+ * Asks every live cluster for a fresh report, then returns {@link health}.
271
+ * A cluster that does not answer in time keeps its last report; it is not
272
+ * treated as failed (only its process exiting does that).
273
+ */
274
+ refreshHealth(options?: {
275
+ metrics?: boolean;
276
+ timeoutMs?: number;
277
+ }): Promise<ClusterManagerHealth>;
69
278
  /** Checks if the recommended shard count has changed and respawns if so. */
70
279
  checkAutoScale(): Promise<void>;
71
280
  /** Gracefully shuts the existing clusters down and respawns them with the new shard count. @param newShardCount The new total shard count. */
@@ -73,7 +282,8 @@ export declare class ClusterManager {
73
282
  /**
74
283
  * Gracefully stops all active clusters: sends SIGTERM, awaits each child's
75
284
  * clean exit, and force-kills (SIGKILL) only children that outlast the
76
- * shutdown timeout. Clears the auto-scale timer and cluster map.
285
+ * shutdown timeout. Safe to call twice or concurrently; cancels restarts,
286
+ * timers and pending requests.
77
287
  * @param timeoutMs Grace period per child before force-kill. Defaults to the configured `shutdownTimeout` (5000 ms).
78
288
  */
79
289
  shutdownAll(timeoutMs?: number): Promise<void>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What a cluster worker knows about itself. `ClusterManager` sets it through
3
+ * environment variables; {@link readClusterContext} reads it back.
4
+ */
5
+ export interface ClusterContext {
6
+ /** This cluster's ID. */
7
+ clusterId: number;
8
+ /** How many clusters the deployment started. */
9
+ clusterCount: number;
10
+ /** Total shards in the deployment (what Discord calls `num_shards`). */
11
+ shardCount: number;
12
+ /** Shards this cluster owns. */
13
+ shardIds: number[];
14
+ /** Whether the parent hands out IDENTIFY slots for this cluster. */
15
+ coordinatedIdentify: boolean;
16
+ }
17
+ /** Set to `1` in every process `ClusterManager` forks, so a worker can tell it is one. */
18
+ export declare const WORKER_MARKER = "LUNIBEE_CLUSTER_WORKER";
19
+ /** Whether this process was forked by a `ClusterManager`. */
20
+ export declare function isClusterWorker(env?: Record<string, string | undefined>): boolean;
21
+ /** Checks that shard IDs are unique integers in `[0, total)`. @throws {RangeError} Otherwise. */
22
+ export declare function validateShardIds(shardIds: readonly number[], total: number): void;
23
+ /**
24
+ * Splits shard IDs across clusters round-robin: the i-th ID goes to cluster
25
+ * `i % clusterCount`. The same input always gives the same output.
26
+ * @returns One list per cluster (some may be empty when there are more clusters than shards).
27
+ * @throws {RangeError} If `clusterCount` is not a positive integer.
28
+ */
29
+ export declare function allocateShards(shardIds: readonly number[], clusterCount: number): number[][];
30
+ /**
31
+ * Proves an allocation covers `expected` exactly once.
32
+ * @throws {Error} Naming the missing, duplicate or unexpected shard IDs.
33
+ */
34
+ export declare function assertCompleteAllocation(chunks: readonly (readonly number[])[], expected: readonly number[]): void;
35
+ /**
36
+ * Reads the worker contract from the environment: `CLUSTER_ID`, `CLUSTER_COUNT`,
37
+ * `SHARD_COUNT`, `SHARD_LIST` (comma separated) and `LUNIBEE_IDENTIFY`.
38
+ * @returns The context, or `null` when this process is not a cluster worker.
39
+ * @throws {RangeError} If the variables are present but malformed.
40
+ */
41
+ export declare function readClusterContext(env?: Record<string, string | undefined>): ClusterContext | null;
@@ -16,3 +16,23 @@ export interface GatewayBotInfo {
16
16
  * @throws {Error} If the request fails or the answer is not usable.
17
17
  */
18
18
  export declare function fetchGatewayBot(token: string): Promise<GatewayBotInfo>;
19
+ /** Discord's hard limit: a shard may serve at most this many guilds. */
20
+ export declare const MAX_GUILDS_PER_SHARD = 2500;
21
+ /** Checks a `guildsPerShard` option. @throws {RangeError} Unless it is an integer from 1 to 2500. */
22
+ export declare function assertGuildsPerShard(value: number): void;
23
+ /** How many shards keep each at or under `guildsPerShard` guilds (at least one). */
24
+ export declare function shardsForGuilds(guilds: number, guildsPerShard: number): number;
25
+ /**
26
+ * Asks Discord how many guilds the bot is in (`approximate_guild_count` of the application).
27
+ * @throws {Error} If the request fails or the answer is not usable.
28
+ */
29
+ export declare function fetchGuildCount(token: string): Promise<number>;
30
+ /**
31
+ * The shard count to use for `"auto"`: Discord's recommendation, or, with
32
+ * `guildsPerShard`, enough shards for the bot's guild count (`guildCount`, or
33
+ * what Discord reports).
34
+ */
35
+ export declare function resolveShardCount(token: string, info: GatewayBotInfo, options: {
36
+ guildsPerShard?: number;
37
+ guildCount?: number;
38
+ }): Promise<number>;
@@ -0,0 +1,94 @@
1
+ import type { Gateway } from "@lunibee/ws";
2
+ /** Where a shard is in its life, apart from the state of its Gateway socket. */
3
+ export type ShardStatus =
4
+ /** Created, never connected. */
5
+ "idle"
6
+ /** Connecting or waiting for its IDENTIFY slot. */
7
+ | "connecting"
8
+ /** Handshake finished; events are flowing. */
9
+ | "ready"
10
+ /** The socket closed; the Gateway may be reconnecting by itself. */
11
+ | "disconnected"
12
+ /** The Gateway reported repeated reconnects and is backing off. */
13
+ | "unstable"
14
+ /** Closed with `destroy()`. */
15
+ | "destroyed";
16
+ /** Health snapshot for one shard. */
17
+ export interface ShardHealth {
18
+ id: number;
19
+ /** Gateway connection state. */
20
+ state: Gateway["state"];
21
+ /** Last heartbeat round-trip in ms, or -1 before the first ACK. */
22
+ ping: number;
23
+ /** Shard lifecycle, separate from the Gateway `state`. */
24
+ status: ShardStatus;
25
+ /** When the shard last became ready (epoch ms), if it ever did. */
26
+ readyAt?: number;
27
+ /** When the socket last closed (epoch ms), if it ever did. */
28
+ disconnectedAt?: number;
29
+ }
30
+ /** Process numbers a worker adds to a health report. Opt-in: they cost a syscall each. */
31
+ export interface WorkerMetrics {
32
+ /** Resident set size in bytes. */
33
+ rssBytes: number;
34
+ /** V8 heap in use in bytes. */
35
+ heapUsedBytes: number;
36
+ /** CPU time used by this process, in microseconds. */
37
+ cpuUserMicros: number;
38
+ cpuSystemMicros: number;
39
+ }
40
+ /** What a cluster worker reports about itself. */
41
+ export interface WorkerReport {
42
+ shards: ShardHealth[];
43
+ metrics?: WorkerMetrics;
44
+ /** When the worker built the report (epoch ms). */
45
+ reportedAt: number;
46
+ }
47
+ /** Lifecycle of a cluster process. */
48
+ export type ClusterState =
49
+ /** Forked; has not said it is ready. */
50
+ "starting"
51
+ /** Every shard in the cluster finished its handshake. */
52
+ | "ready"
53
+ /** Exited unexpectedly; a restart is scheduled. */
54
+ | "restarting"
55
+ /** A deliberate shutdown is in progress. */
56
+ | "stopping"
57
+ /** Stopped on purpose. */
58
+ | "stopped"
59
+ /** Crashed too often and will not be restarted. */
60
+ | "failed";
61
+ /** Health of one cluster. */
62
+ export interface ClusterHealth {
63
+ id: number;
64
+ state: ClusterState;
65
+ pid?: number;
66
+ /** Where it runs: `"local"` or the ID of a host. */
67
+ host: string;
68
+ /** Shards this cluster owns right now. */
69
+ shardIds: number[];
70
+ /** Milliseconds since this process was forked; 0 when it is not running. */
71
+ uptimeMs: number;
72
+ /** How many times it has been restarted after a crash. */
73
+ restarts: number;
74
+ startedAt?: number;
75
+ /** When the current process reported ready. */
76
+ readyAt?: number;
77
+ /** When any process of this cluster last reported ready. */
78
+ lastReadyAt?: number;
79
+ /** Per-shard health from the worker's last report. Empty until it reports. */
80
+ shards: ShardHealth[];
81
+ /** Present when requested with `health({ metrics: true })` and the worker answered. */
82
+ metrics?: WorkerMetrics;
83
+ /** When `shards` and `metrics` were reported (epoch ms). */
84
+ reportedAt?: number;
85
+ }
86
+ /** Health of the whole deployment. */
87
+ export interface ClusterManagerHealth {
88
+ state: "idle" | "starting" | "ready" | "degraded" | "stopping" | "stopped";
89
+ totalShards: number;
90
+ clusters: ClusterHealth[];
91
+ readyClusters: number;
92
+ /** Shards with no ready owner right now. */
93
+ missingShards: number[];
94
+ }
@@ -0,0 +1,70 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ /** Options for a {@link ClusterHost}. */
3
+ export interface ClusterHostOptions {
4
+ /** The coordinator's address: `wss://host:port`. `ws://` is accepted for this machine, or anywhere with `allowInsecure`. */
5
+ url: string;
6
+ /** Connect with `ws://` to a coordinator on another machine, over a network you fully trust. Default false. */
7
+ allowInsecure?: boolean;
8
+ /** The coordinator's shared secret. */
9
+ secret: string;
10
+ /** This host's ID: letters, digits, `.`, `_`, `-`. Must be unique in the deployment. */
11
+ id: string;
12
+ /**
13
+ * The worker script this host runs for every cluster. A coordinator cannot
14
+ * choose it: remote workers run only what the host owner configured.
15
+ */
16
+ script: string;
17
+ /** Starts a worker process. Defaults to `child_process.fork()`. */
18
+ fork?: (script: string, env: Record<string, string | undefined>) => ChildProcess;
19
+ /** First delay before reconnecting, in ms. Default 1000. */
20
+ reconnectDelay?: number;
21
+ /** Longest delay between reconnect attempts, in ms. Default 30,000. */
22
+ maxReconnectDelay?: number;
23
+ }
24
+ /** What a {@link ClusterHost} emits. */
25
+ export interface ClusterHostEvents {
26
+ /** The coordinator accepted this host. */
27
+ connected: [];
28
+ /** The connection ended. Every worker was killed first. */
29
+ disconnected: [info: {
30
+ code: number;
31
+ reason: string;
32
+ }];
33
+ /** A worker started for a cluster. */
34
+ clusterSpawn: [cluster: number];
35
+ /** A worker exited. */
36
+ clusterExit: [cluster: number, code: number | null, signal: string | null];
37
+ /** A refused frame, a failed start or a rejected connection. */
38
+ error: [error: Error];
39
+ }
40
+ /**
41
+ * The agent that makes a machine part of a deployment. It connects to the
42
+ * coordinator, starts the worker script for the clusters the coordinator
43
+ * places here, and relays their IPC frames both ways. The coordinator's
44
+ * `ClusterManager` supervises those workers exactly like local ones.
45
+ *
46
+ * Fencing: when the connection closes, or the coordinator stays silent for a
47
+ * whole lease, the host kills every worker at once. The coordinator waits
48
+ * longer than that before it restarts the clusters elsewhere, so a shard is
49
+ * never owned in two places.
50
+ */
51
+ export declare class ClusterHost {
52
+ #private;
53
+ constructor(options: ClusterHostOptions);
54
+ /** Whether the coordinator has accepted this host and the link is open. */
55
+ get connected(): boolean;
56
+ /** Cluster IDs running on this host. */
57
+ clusters(): number[];
58
+ /** Listens for a host event. @returns This host. */
59
+ on<E extends keyof ClusterHostEvents>(event: E, listener: (...args: ClusterHostEvents[E]) => void): this;
60
+ /** Stops listening for a host event. @returns This host. */
61
+ off<E extends keyof ClusterHostEvents>(event: E, listener: (...args: ClusterHostEvents[E]) => void): this;
62
+ /**
63
+ * Connects and resolves once the coordinator accepts this host. After
64
+ * that, a lost connection is retried with backoff until {@link close}.
65
+ * @throws {Error} If the first attempt is refused (bad secret, unknown or duplicate ID).
66
+ */
67
+ connect(): Promise<void>;
68
+ /** Kills every worker, closes the connection and stops reconnecting. */
69
+ close(): Promise<void>;
70
+ }
@@ -0,0 +1,62 @@
1
+ /** Discord allows one IDENTIFY per rate-limit key every five seconds. */
2
+ export declare const IDENTIFY_INTERVAL = 5000;
3
+ /** What `/gateway/bot` says about the session budget. */
4
+ export interface IdentifyBudget {
5
+ total: number;
6
+ remaining: number;
7
+ /** Milliseconds until `remaining` resets, counted from construction. */
8
+ resetAfter: number;
9
+ }
10
+ /** Options for an {@link IdentifyCoordinator}. */
11
+ export interface IdentifyCoordinatorOptions {
12
+ /** Discord's `max_concurrency`: shards with the same `id % maxConcurrency` share a key. Default 1. */
13
+ maxConcurrency?: number;
14
+ /** Milliseconds between two IDENTIFYs on one key. Default 5000. */
15
+ interval?: number;
16
+ /** The session-start budget, when known. Without it the coordinator paces but never blocks. */
17
+ budget?: IdentifyBudget;
18
+ /** A granted slot that is not released within this many ms is released for the holder. Default 60,000. */
19
+ holdTimeout?: number;
20
+ /** Clock, injectable for tests. */
21
+ now?: () => number;
22
+ /** Timer, injectable for tests. Must return a handle `cancel` understands. */
23
+ schedule?: (callback: () => void, ms: number) => unknown;
24
+ cancel?: (handle: unknown) => void;
25
+ /** Called when a request has to wait for the budget to reset. */
26
+ onBudgetWait?: (info: {
27
+ shardId: number;
28
+ waitMs: number;
29
+ }) => void;
30
+ }
31
+ /**
32
+ * Hands out IDENTIFY slots for a whole deployment. Every cluster asks the
33
+ * parent, so the limit holds across processes: one IDENTIFY per rate-limit key
34
+ * per interval, and no more IDENTIFYs than `session_start_limit.remaining`.
35
+ * It only does arithmetic on timestamps and holds one timer, so it can be
36
+ * tested with a fake clock.
37
+ */
38
+ export declare class IdentifyCoordinator {
39
+ #private;
40
+ readonly maxConcurrency: number;
41
+ constructor(options?: IdentifyCoordinatorOptions);
42
+ /** Slots left in the session budget, or `undefined` when it is unknown. */
43
+ get remaining(): number | undefined;
44
+ /** Requests waiting for a slot. */
45
+ get waiting(): number;
46
+ /** Slots granted and not yet released. */
47
+ get holding(): number;
48
+ /**
49
+ * Waits for the shard's turn. Resolves when the shard may IDENTIFY now;
50
+ * call {@link release} once it has. Rejects if `owner` is dropped or the
51
+ * coordinator closes first.
52
+ */
53
+ acquire(owner: number, shardId: number): Promise<void>;
54
+ /** The shard has sent its IDENTIFY: its key rests for one interval. Ignored unless `owner` holds the slot. */
55
+ release(owner: number, shardId: number): void;
56
+ /** A cluster is gone: forget its waiting requests and free the slots it held. */
57
+ drop(owner: number, reason?: string): void;
58
+ /** Rejects every waiter and stops the timer. */
59
+ close(): void;
60
+ /** Grants every slot that is due and arms one timer for the next. Called internally and by tests after moving the clock. */
61
+ pump(): void;
62
+ }