@einaraglen/quorum 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,13 +18,13 @@ When a pod starts up it runs an election: it contacts every peer with a higher n
18
18
 
19
19
  All coordination travels over a single internal HTTP port (default `4001`). Each pod runs a small Express server on that port with five endpoints:
20
20
 
21
- | Route | Purpose |
22
- |---|---|
23
- | `GET /health` | Heartbeat probe |
24
- | `POST /bully/election` | Incoming election challenge from a lower peer |
25
- | `POST /bully/coordinator` | Incoming leader announcement |
26
- | `POST /bully/message` | Incoming application-level event |
27
- | `GET /channel/stream/:event` | SSE stream for live subscriptions |
21
+ | Route | Purpose |
22
+ | ---------------------------- | --------------------------------------------- |
23
+ | `GET /health` | Heartbeat probe |
24
+ | `POST /bully/election` | Incoming election challenge from a lower peer |
25
+ | `POST /bully/coordinator` | Incoming leader announcement |
26
+ | `POST /bully/message` | Incoming application-level event |
27
+ | `GET /channel/stream/:event` | SSE stream for live subscriptions |
28
28
 
29
29
  The Transport class owns both sides of this: the server that listens on those routes, and the HTTP client (`postToPeer`, `pingPeer`) that sends to them.
30
30
 
@@ -32,12 +32,12 @@ The Transport class owns both sides of this: the server that listens on those ro
32
32
 
33
33
  Four messaging patterns are available, each with a different routing model:
34
34
 
35
- | Method | Who can call | Who receives |
36
- |---|---|---|
37
- | `broadcast(event, payload)` | Leader only | All followers + self |
38
- | `send(event, payload)` | Any follower | Current leader |
39
- | `tell(peer, event, payload)` | Leader only | One specific named peer |
40
- | `messagePeer(peer, event, payload)` | Anyone | One specific named peer |
35
+ | Method | Who can call | Who receives |
36
+ | ----------------------------------- | ------------ | ----------------------- |
37
+ | `broadcast(event, payload)` | Leader only | All followers + self |
38
+ | `send(event, payload)` | Any follower | Current leader |
39
+ | `tell(peer, event, payload)` | Leader only | One specific named peer |
40
+ | `messagePeer(peer, event, payload)` | Anyone | One specific named peer |
41
41
 
42
42
  All four deliver to the pod's local `channel` EventEmitter when the target happens to be self, with no network round-trip.
43
43
 
@@ -45,7 +45,7 @@ All four deliver to the pod's local `channel` EventEmitter when the target happe
45
45
 
46
46
  `subscribeToPeer(peer, event, onData)` opens a live SSE connection to a named peer and delivers every event it emits under that name. Returns an unsubscribe function. Targets itself locally — no loopback HTTP connection.
47
47
 
48
- ### ShardManager — work distribution
48
+ ### Sharding — work distribution
49
49
 
50
50
  The leader periodically reconciles who owns what. Reconciliation works in four steps:
51
51
 
@@ -60,20 +60,20 @@ Ownership changes drive subscriptions: `subscribe(id, onEvent)` listens to the c
60
60
 
61
61
  ### Quorum — the wrapper
62
62
 
63
- `Quorum` creates and wires all three layers (Transport, Bully, Forum, ShardManager) from a single options object. It is the primary entry point for production use. The individual classes are exported for testing and advanced composition.
63
+ `Quorum` creates and wires all four layers (Transport, Bully, Forum, Sharding) from a single options object. It is the primary entry point for production use. The individual classes are exported for testing and advanced composition.
64
64
 
65
65
  ---
66
66
 
67
67
  ## Quick start
68
68
 
69
69
  ```typescript
70
- import { Quorum } from "quorum";
71
- import { getCluster } from "./my-discovery.js"; // returns { self, cluster }
70
+ import { Quorum } from "@einaraglen/quorum";
71
+ import { discovery } from "./my-discovery.js"; // returns { self, cluster }
72
72
 
73
73
  const q = new Quorum({
74
- getCluster,
74
+ discovery,
75
75
  ids: ["uuid-1", "uuid-2", "uuid-3"], // the full set of work items to distribute
76
- expectedClusterSize: 4, // enables quorum protection
76
+ expectedClusterSize: 4, // enables quorum protection
77
77
  });
78
78
 
79
79
  q.start(); // starts the HTTP server, runs the first election, begins reconciling
@@ -89,6 +89,10 @@ q.publish("uuid-1", { reading: 42 });
89
89
  // Look up who owns an id without a network call:
90
90
  console.log(q.getOwner("uuid-1")); // "pod-name-c"
91
91
 
92
+ // React the instant this pod gains or loses ids — no polling getHeldIds():
93
+ q.onAssigned((ids) => console.log("now holding:", ids));
94
+ q.onReleased((ids) => console.log("no longer holding:", ids));
95
+
92
96
  // Clean shutdown (or use `using q = new Quorum(...)` for automatic teardown):
93
97
  q.stop();
94
98
  ```
@@ -97,7 +101,7 @@ q.stop();
97
101
 
98
102
  ```typescript
99
103
  async function main() {
100
- using q = new Quorum({ getCluster, ids });
104
+ using q = new Quorum({ discovery, ids });
101
105
  q.start();
102
106
 
103
107
  await new Promise<void>((resolve) => {
@@ -114,22 +118,22 @@ async function main() {
114
118
 
115
119
  ### `new Quorum(opts)`
116
120
 
117
- | Option | Type | Default | Description |
118
- |---|---|---|---|
119
- | `getCluster` | `() => Promise<{ self, cluster }>` | required | Peer discovery. Return this pod as `self` and the full peer list (including self) as `cluster`. |
120
- | `ids` | `TId[]` | required | The complete set of work IDs to distribute. |
121
- | `fetchFn` | `typeof fetch` | `globalThis.fetch` | Swap in a custom fetch for testing. |
122
- | `internalPort` | `number` | `4001` or `$INTERNAL_PORT` | Port for the internal coordination server. |
123
- | `internalHost` | `string` | all interfaces | Bind address for the internal server. Useful in tests running multiple real instances on one machine. |
124
- | `requestTimeoutMs` | `number` | `2000` | Timeout for outgoing HTTP requests to peers. |
125
- | `coordinatorWaitMs` | `number` | `4000` | How long a pod waits for a coordinator announcement before restarting the election. |
126
- | `heartbeatIntervalMs` | `number` | `5000` | How often followers ping the leader to check it is still alive. |
127
- | `reportTimeoutMs` | `number` | `2000` | How long the leader waits for holdings reports before reconciling with whoever responded. |
128
- | `rebalanceIntervalMs` | `number` | `10000` | How often the leader runs a full reconcile. |
129
- | `expectedClusterSize` | `number` | none | Target pod count for quorum checks. |
130
- | `quorumFailureThreshold` | `number` | `3` | Consecutive quorum failures before `onSustainedQuorumLoss` fires. |
131
- | `onSustainedQuorumLoss` | `() => void` | `process.exit(1)` | Called once when the failure threshold is reached. |
132
- | `logger` | `Logger` | `console` | Log sink, must implement `info`/`warn`/`error`/`debug`. `console` satisfies this as-is. |
121
+ | Option | Type | Default | Description |
122
+ | ------------------------ | ---------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------- |
123
+ | `discovery` | `() => Promise<{ self, cluster }>` | required | Peer discovery. Return this pod as `self` and the full peer list (including self) as `cluster`. |
124
+ | `ids` | `TId[]` | required | The complete set of work IDs to distribute. |
125
+ | `fetchFn` | `typeof fetch` | `globalThis.fetch` | Swap in a custom fetch for testing. |
126
+ | `internalPort` | `number` | `4001` or `$INTERNAL_PORT` | Port for the internal coordination server. |
127
+ | `internalHost` | `string` | all interfaces | Bind address for the internal server. Useful in tests running multiple real instances on one machine. |
128
+ | `requestTimeoutMs` | `number` | `2000` | Timeout for outgoing HTTP requests to peers. |
129
+ | `coordinatorWaitMs` | `number` | `4000` | How long a pod waits for a coordinator announcement before restarting the election. |
130
+ | `heartbeatIntervalMs` | `number` | `5000` | How often followers ping the leader to check it is still alive. |
131
+ | `reportTimeoutMs` | `number` | `2000` | How long the leader waits for holdings reports before reconciling with whoever responded. |
132
+ | `rebalanceIntervalMs` | `number` | `10000` | How often the leader runs a full reconcile. |
133
+ | `expectedClusterSize` | `number` | none | Target pod count for quorum checks. |
134
+ | `quorumFailureThreshold` | `number` | `3` | Consecutive quorum failures before `onSustainedQuorumLoss` fires. |
135
+ | `onSustainedQuorumLoss` | `() => void` | `process.exit(1)` | Called once when the failure threshold is reached. |
136
+ | `logger` | `Logger` | `console` | Log sink, must implement `info`/`warn`/`error`/`debug`. `console` satisfies this as-is. |
133
137
 
134
138
  **Methods:**
135
139
 
@@ -146,6 +150,9 @@ q.getOwner(id): string | undefined // local lookup — no network call
146
150
  q.getOwnership(): Map<TId, string> // full id→peer map as of last reconcile
147
151
  q.getHeldIds(): TId[] // ids currently assigned to this pod
148
152
  q.updateIds(ids): Promise<void> // leader-only: replace the full id set and reconcile
153
+
154
+ q.onAssigned(onEvent): () => void // fires with the ids just gained when held ids grow
155
+ q.onReleased(onEvent): () => void // fires with the ids just lost when held ids shrink
149
156
  ```
150
157
 
151
158
  ---
@@ -155,12 +162,12 @@ q.updateIds(ids): Promise<void> // leader-only: replace the full id set a
155
162
  For testing or custom wiring you can create the layers individually:
156
163
 
157
164
  ```typescript
158
- import { Transport, Bully, Forum, ShardManager } from "quorum";
165
+ import { Transport, Bully, Forum, Sharding } from "@einaraglen/quorum";
159
166
 
160
167
  const transport = new Transport({ fetchFn: mockFetch, internalPort: 9000 });
161
- const bully = new Bully({ getCluster, transport });
162
- const forum = new Forum({ bully, transport, getCluster });
163
- const shard = new ShardManager({ bully, forum, ids: [] });
168
+ const bully = new Bully({ discovery, transport });
169
+ const forum = new Forum({ bully, transport, discovery });
170
+ const shard = new Sharding({ bully, forum, ids: [] });
164
171
 
165
172
  // Wire the transport callbacks manually:
166
173
  transport.start({
@@ -183,12 +190,15 @@ await bully.startElection();
183
190
  bully.channel.emit("assign-connections", [7, 12, 44]);
184
191
 
185
192
  // Simulate the leader publishing an updated ownership map:
186
- bully.channel.emit("ownership-map", [[7, "pod-c"], [12, "pod-a"]]);
193
+ bully.channel.emit("ownership-map", [
194
+ [7, "pod-c"],
195
+ [12, "pod-a"],
196
+ ]);
187
197
  ```
188
198
 
189
199
  ### Lifecycle events
190
200
 
191
- `bully.lifecycle` emits `"elected"` when this pod becomes leader (after peers have been notified), and `"demoted"` when it steps down. ShardManager uses these internally; you can also listen directly:
201
+ `bully.lifecycle` emits `"elected"` when this pod becomes leader (after peers have been notified), and `"demoted"` when it steps down. Sharding uses these internally; you can also listen directly:
192
202
 
193
203
  ```typescript
194
204
  bully.lifecycle.on("elected", () => {
@@ -199,11 +209,22 @@ bully.lifecycle.on("demoted", () => {
199
209
  });
200
210
  ```
201
211
 
212
+ `shard.lifecycle` similarly emits `"assigned"` and `"released"`, each with the delta of ids that just changed (not the full held set) — this is what `Quorum.onAssigned`/`onReleased` wrap:
213
+
214
+ ```typescript
215
+ shard.lifecycle.on("assigned", (ids) => {
216
+ console.log("now holding:", ids);
217
+ });
218
+ shard.lifecycle.on("released", (ids) => {
219
+ console.log("no longer holding:", ids);
220
+ });
221
+ ```
222
+
202
223
  ---
203
224
 
204
225
  ## Peer discovery
205
226
 
206
- `getCluster` is the only application-specific piece. It must return:
227
+ `discovery` is the only application-specific piece. It must return:
207
228
 
208
229
  ```typescript
209
230
  {
@@ -216,7 +237,7 @@ bully.lifecycle.on("demoted", () => {
216
237
 
217
238
  ```typescript
218
239
  // In your application layer (not in quorum itself):
219
- const getCluster = async () => {
240
+ const discovery = async () => {
220
241
  const pods = await k8s.listNamespacedPod({
221
242
  namespace: "default",
222
243
  labelSelector: "app=my-service",
@@ -255,5 +276,5 @@ src/
255
276
  Every class (and `Quorum` itself) accepts an optional `logger` implementing `info`/`warn`/`error`/`debug` — `console` satisfies this shape as-is and is the default. Pass your own (winston, pino, a wrapper around your APM, etc.) to route quorum's election/reconcile/messaging logs wherever the rest of your app's logs go:
256
277
 
257
278
  ```typescript
258
- const q = new Quorum({ getCluster, ids, logger: myWinstonLogger });
279
+ const q = new Quorum({ discovery, ids, logger: myWinstonLogger });
259
280
  ```
@@ -0,0 +1,56 @@
1
+ import { EventEmitter } from "events";
2
+ import type { Logger } from "./logger";
3
+ import type { Transport } from "./transport";
4
+ export type BullyPeer = {
5
+ name?: string;
6
+ host?: string;
7
+ };
8
+ export type BullyCluster = {
9
+ self: BullyPeer;
10
+ cluster: BullyPeer[];
11
+ };
12
+ export type Discovery = () => Promise<BullyCluster>;
13
+ export type BullyOptions = {
14
+ discovery: Discovery;
15
+ transport: Transport;
16
+ coordinatorWaitMs?: number;
17
+ heartbeatIntervalMs?: number;
18
+ logger?: Logger;
19
+ };
20
+ export declare class Bully implements Disposable {
21
+ readonly lifecycle: EventEmitter<[never]>;
22
+ readonly channel: EventEmitter<[never]>;
23
+ private readonly discovery;
24
+ private readonly transport;
25
+ private readonly coordinatorWaitMs;
26
+ private readonly heartbeatIntervalMs;
27
+ private readonly logger;
28
+ private selfId?;
29
+ private leaderId?;
30
+ private electionInFlight;
31
+ private coordinatorWaitTimeout?;
32
+ private heartbeatInterval?;
33
+ constructor(opts: BullyOptions);
34
+ isLeader(): boolean;
35
+ getStatus(): {
36
+ self: string | undefined;
37
+ leader: string | undefined;
38
+ isLeader: boolean;
39
+ };
40
+ getPodRoles(): Promise<{
41
+ name: string | undefined;
42
+ host: string | undefined;
43
+ role: string;
44
+ }[]>;
45
+ /** Returns all cluster peers except self. Side-effect: populates selfId. */
46
+ getPeers(): Promise<BullyPeer[]>;
47
+ private becomeLeader;
48
+ startElection(): Promise<void>;
49
+ onElectionMessage(fromId: string): void;
50
+ onCoordinatorMessage(id: string): void;
51
+ onMessage(event: string, payload: unknown): void;
52
+ start(): void;
53
+ stop(): void;
54
+ [Symbol.dispose](): void;
55
+ }
56
+ //# sourceMappingURL=bully.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bully.d.ts","sourceRoot":"","sources":["../../src/core/bully.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACzD,MAAM,MAAM,YAAY,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,CAAC;AACrE,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;AAEpD,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,SAAS,EAAE,SAAS,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,KAAM,YAAW,UAAU;IACtC,SAAgB,SAAS,wBAAsB;IAC/C,SAAgB,OAAO,wBAAsB;IAE7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,OAAO,CAAC,MAAM,CAAC,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,CAAS;IAC1B,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,sBAAsB,CAAC,CAAiB;IAChD,OAAO,CAAC,iBAAiB,CAAC,CAAiB;IAE3C,YAAY,IAAI,EAAE,YAAY,EAM7B;IAEM,QAAQ,IAAI,OAAO,CAEzB;IAEM,SAAS;QACL,IAAI;QAAe,MAAM;QAAiB,QAAQ;MAC5D;IAEY,WAAW;;;;SAOvB;IAED,4EAA4E;IAC/D,QAAQ,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAI5C;YAEa,YAAY;IAeb,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAkC1C;IAEM,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAG7C;IAEM,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAa5C;IAEM,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAEtD;IAEM,KAAK,IAAI,IAAI,CAuBnB;IAEM,IAAI,IAAI,IAAI,CAIlB;IAEM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAE9B;CACF"}
@@ -0,0 +1,140 @@
1
+ import { EventEmitter } from "events";
2
+ export class Bully {
3
+ lifecycle = new EventEmitter();
4
+ channel = new EventEmitter();
5
+ discovery;
6
+ transport;
7
+ coordinatorWaitMs;
8
+ heartbeatIntervalMs;
9
+ logger;
10
+ selfId;
11
+ leaderId;
12
+ electionInFlight = false;
13
+ coordinatorWaitTimeout;
14
+ heartbeatInterval;
15
+ constructor(opts) {
16
+ this.discovery = opts.discovery;
17
+ this.transport = opts.transport;
18
+ this.coordinatorWaitMs = opts.coordinatorWaitMs ?? 4000;
19
+ this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 5000;
20
+ this.logger = opts.logger ?? console;
21
+ }
22
+ isLeader() {
23
+ return !!this.selfId && this.selfId === this.leaderId;
24
+ }
25
+ getStatus() {
26
+ return { self: this.selfId, leader: this.leaderId, isLeader: this.isLeader() };
27
+ }
28
+ async getPodRoles() {
29
+ const { cluster } = await this.discovery();
30
+ return cluster.map((pod) => ({
31
+ name: pod.name,
32
+ host: pod.host,
33
+ role: pod.name === this.leaderId ? "leader" : "follower",
34
+ }));
35
+ }
36
+ /** Returns all cluster peers except self. Side-effect: populates selfId. */
37
+ async getPeers() {
38
+ const { self, cluster } = await this.discovery();
39
+ this.selfId = self.name;
40
+ return cluster.filter((pod) => pod.name !== self.name && pod.host);
41
+ }
42
+ async becomeLeader(peers) {
43
+ const wasLeader = this.leaderId === this.selfId;
44
+ this.leaderId = this.selfId;
45
+ if (!wasLeader)
46
+ this.logger.info(`Became leader (id=${this.selfId})`);
47
+ await Promise.all(peers.map((peer) => this.transport.postToPeer(peer.host, "/bully/coordinator", { id: this.selfId })));
48
+ // Emitted only after peers have been told, so an "elected" listener that immediately talks
49
+ // to peers (e.g. Sharding.reconcile()) doesn't race ahead of them learning who's leader.
50
+ if (!wasLeader)
51
+ this.lifecycle.emit("elected");
52
+ }
53
+ async startElection() {
54
+ if (this.electionInFlight)
55
+ return;
56
+ this.electionInFlight = true;
57
+ if (this.coordinatorWaitTimeout)
58
+ clearTimeout(this.coordinatorWaitTimeout);
59
+ this.logger.info("Starting election");
60
+ try {
61
+ const peers = await this.getPeers();
62
+ const higherPeers = peers.filter((peer) => peer.name > this.selfId);
63
+ if (higherPeers.length === 0) {
64
+ await this.becomeLeader(peers);
65
+ return;
66
+ }
67
+ const acked = await Promise.all(higherPeers.map((peer) => this.transport.postToPeer(peer.host, "/bully/election", { id: this.selfId })));
68
+ if (acked.some(Boolean)) {
69
+ this.logger.debug("Higher peer(s) acknowledged, waiting for coordinator announcement");
70
+ this.coordinatorWaitTimeout = setTimeout(() => {
71
+ this.logger.debug("Timed out waiting for coordinator, restarting election");
72
+ this.startElection();
73
+ }, this.coordinatorWaitMs);
74
+ }
75
+ else {
76
+ await this.becomeLeader(peers);
77
+ }
78
+ }
79
+ catch (err) {
80
+ this.logger.error(`Election error: ${err.message}`);
81
+ }
82
+ finally {
83
+ this.electionInFlight = false;
84
+ }
85
+ }
86
+ onElectionMessage(fromId) {
87
+ this.logger.debug(`Received election message from ${fromId}, asserting alive and starting own election`);
88
+ this.startElection();
89
+ }
90
+ onCoordinatorMessage(id) {
91
+ if (this.coordinatorWaitTimeout)
92
+ clearTimeout(this.coordinatorWaitTimeout);
93
+ const wasLeader = this.isLeader();
94
+ const changed = this.leaderId !== id;
95
+ this.leaderId = id;
96
+ if (changed)
97
+ this.logger.debug(`New leader announced: ${id}`);
98
+ if (wasLeader && id !== this.selfId) {
99
+ this.logger.info("Demoted to follower");
100
+ this.lifecycle.emit("demoted");
101
+ }
102
+ }
103
+ onMessage(event, payload) {
104
+ this.channel.emit(event, payload);
105
+ }
106
+ start() {
107
+ this.heartbeatInterval = setInterval(async () => {
108
+ try {
109
+ if (this.electionInFlight)
110
+ return;
111
+ if (this.leaderId && this.leaderId === this.selfId)
112
+ return;
113
+ if (!this.leaderId) {
114
+ this.startElection();
115
+ return;
116
+ }
117
+ const peers = await this.getPeers();
118
+ const leader = peers.find((peer) => peer.name === this.leaderId);
119
+ if (!leader?.host || !(await this.transport.pingPeer(leader.host))) {
120
+ this.logger.info(`Leader ${this.leaderId} unreachable, starting election`);
121
+ this.leaderId = undefined;
122
+ this.startElection();
123
+ }
124
+ }
125
+ catch (err) {
126
+ this.logger.error(`Heartbeat error: ${err.message}`);
127
+ }
128
+ }, this.heartbeatIntervalMs);
129
+ }
130
+ stop() {
131
+ this.logger.info("Stopping Bully Coordinator...");
132
+ if (this.heartbeatInterval)
133
+ clearInterval(this.heartbeatInterval);
134
+ if (this.coordinatorWaitTimeout)
135
+ clearTimeout(this.coordinatorWaitTimeout);
136
+ }
137
+ [Symbol.dispose]() {
138
+ this.stop();
139
+ }
140
+ }
@@ -0,0 +1,41 @@
1
+ import type { Logger } from "./logger";
2
+ import type { Bully, BullyPeer, Discovery } from "./bully";
3
+ import type { Transport } from "./transport";
4
+ export type ForumOptions = {
5
+ bully: Bully;
6
+ transport: Transport;
7
+ discovery: Discovery;
8
+ fetchFn?: typeof fetch;
9
+ logger?: Logger;
10
+ };
11
+ export declare class Forum {
12
+ private readonly bully;
13
+ private readonly transport;
14
+ private readonly discovery;
15
+ private readonly fetchFn;
16
+ private readonly logger;
17
+ constructor(opts: ForumOptions);
18
+ /** Leader-only: fan a custom event out to every follower, and fire it locally too. */
19
+ broadcast(event: string, payload?: unknown): Promise<void>;
20
+ /** Follower-only: send a custom event to whichever pod is currently leader. */
21
+ send(event: string, payload?: unknown): Promise<void>;
22
+ /** Leader-only: send a custom event to exactly one named peer (self included). */
23
+ tell(peerName: string, event: string, payload?: unknown): Promise<void>;
24
+ /**
25
+ * Peer-to-peer: message one specific named peer directly (self included), regardless of
26
+ * leadership. Unlike broadcast/send/tell, this isn't leader-mediated.
27
+ */
28
+ messagePeer(peerName: string, event: string, payload?: unknown): Promise<void>;
29
+ /**
30
+ * Leader-only: partition work across the whole cluster (self included). `resolvePayload` is
31
+ * called once per participant, in a stable order, and gets back that pod's own share to send.
32
+ */
33
+ distribute<T>(event: string, resolvePayload: (peer: BullyPeer, index: number, allPeers: BullyPeer[]) => T): Promise<void>;
34
+ /**
35
+ * Peer-to-peer: open a live subscription to everything a specific peer emits under one
36
+ * channel event name (self included, via a local listener — no loopback HTTP call needed).
37
+ * Returns an unsubscribe function.
38
+ */
39
+ subscribeToPeer(peerName: string, event: string, onData: (payload: unknown) => void): () => void;
40
+ }
41
+ //# sourceMappingURL=forum.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"forum.d.ts","sourceRoot":"","sources":["../../src/core/forum.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAC3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,SAAS,CAAC;IACrB,SAAS,EAAE,SAAS,CAAC;IACrB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,KAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,YAAY,IAAI,EAAE,YAAY,EAM7B;IAED,sFAAsF;IACzE,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAQtE;IAED,+EAA+E;IAClE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBjE;IAED,kFAAkF;IACrE,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAMnF;IAED;;;OAGG;IACU,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAa1F;IAED;;;OAGG;IACU,UAAU,CAAC,CAAC,EACvB,KAAK,EAAE,MAAM,EACb,cAAc,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,CAAC,GAC3E,OAAO,CAAC,IAAI,CAAC,CAoBf;IAED;;;;OAIG;IACI,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CA+DtG;CACF"}
@@ -0,0 +1,152 @@
1
+ export class Forum {
2
+ bully;
3
+ transport;
4
+ discovery;
5
+ fetchFn;
6
+ logger;
7
+ constructor(opts) {
8
+ this.bully = opts.bully;
9
+ this.transport = opts.transport;
10
+ this.discovery = opts.discovery;
11
+ this.fetchFn = opts.fetchFn ?? this.transport.fetchFn;
12
+ this.logger = opts.logger ?? console;
13
+ }
14
+ /** Leader-only: fan a custom event out to every follower, and fire it locally too. */
15
+ async broadcast(event, payload) {
16
+ if (!this.bully.isLeader()) {
17
+ this.logger.warn(`broadcast('${event}') called while not leader, ignoring`);
18
+ return;
19
+ }
20
+ this.bully.channel.emit(event, payload);
21
+ const peers = await this.bully.getPeers();
22
+ await Promise.all(peers.map((peer) => this.transport.postToPeer(peer.host, "/bully/message", { event, payload })));
23
+ }
24
+ /** Follower-only: send a custom event to whichever pod is currently leader. */
25
+ async send(event, payload) {
26
+ if (this.bully.isLeader()) {
27
+ this.bully.channel.emit(event, payload);
28
+ return;
29
+ }
30
+ const { leader } = this.bully.getStatus();
31
+ if (!leader) {
32
+ this.logger.warn(`send('${event}') called with no known leader, dropping`);
33
+ return;
34
+ }
35
+ const peers = await this.bully.getPeers();
36
+ const leaderPeer = peers.find((peer) => peer.name === leader);
37
+ if (!leaderPeer?.host) {
38
+ this.logger.warn(`send('${event}') could not resolve leader host, dropping`);
39
+ return;
40
+ }
41
+ await this.transport.postToPeer(leaderPeer.host, "/bully/message", { event, payload });
42
+ }
43
+ /** Leader-only: send a custom event to exactly one named peer (self included). */
44
+ async tell(peerName, event, payload) {
45
+ if (!this.bully.isLeader()) {
46
+ this.logger.warn(`tell('${event}') called while not leader, ignoring`);
47
+ return;
48
+ }
49
+ await this.messagePeer(peerName, event, payload);
50
+ }
51
+ /**
52
+ * Peer-to-peer: message one specific named peer directly (self included), regardless of
53
+ * leadership. Unlike broadcast/send/tell, this isn't leader-mediated.
54
+ */
55
+ async messagePeer(peerName, event, payload) {
56
+ const { self } = this.bully.getStatus();
57
+ if (peerName === self) {
58
+ this.bully.channel.emit(event, payload);
59
+ return;
60
+ }
61
+ const peers = await this.bully.getPeers();
62
+ const target = peers.find((peer) => peer.name === peerName);
63
+ if (!target?.host) {
64
+ this.logger.warn(`messagePeer('${event}') could not resolve peer '${peerName}', dropping`);
65
+ return;
66
+ }
67
+ await this.transport.postToPeer(target.host, "/bully/message", { event, payload });
68
+ }
69
+ /**
70
+ * Leader-only: partition work across the whole cluster (self included). `resolvePayload` is
71
+ * called once per participant, in a stable order, and gets back that pod's own share to send.
72
+ */
73
+ async distribute(event, resolvePayload) {
74
+ if (!this.bully.isLeader()) {
75
+ this.logger.warn(`distribute('${event}') called while not leader, ignoring`);
76
+ return;
77
+ }
78
+ const { self, cluster } = await this.discovery();
79
+ const allPeers = [self, ...cluster.filter((pod) => pod.name !== self.name && pod.host)].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
80
+ await Promise.all(allPeers.map((peer, index) => {
81
+ const payload = resolvePayload(peer, index, allPeers);
82
+ if (peer.name === self.name) {
83
+ this.bully.channel.emit(event, payload);
84
+ return;
85
+ }
86
+ return this.transport.postToPeer(peer.host, "/bully/message", { event, payload });
87
+ }));
88
+ }
89
+ /**
90
+ * Peer-to-peer: open a live subscription to everything a specific peer emits under one
91
+ * channel event name (self included, via a local listener — no loopback HTTP call needed).
92
+ * Returns an unsubscribe function.
93
+ */
94
+ subscribeToPeer(peerName, event, onData) {
95
+ const { self } = this.bully.getStatus();
96
+ if (peerName === self) {
97
+ this.bully.channel.on(event, onData);
98
+ return () => this.bully.channel.off(event, onData);
99
+ }
100
+ const controller = new AbortController();
101
+ let stopped = false;
102
+ (async () => {
103
+ const peers = await this.bully.getPeers();
104
+ const target = peers.find((peer) => peer.name === peerName);
105
+ if (!target?.host) {
106
+ this.logger.warn(`subscribeToPeer('${event}') could not resolve peer '${peerName}'`);
107
+ return;
108
+ }
109
+ try {
110
+ const res = await this.fetchFn(`http://${target.host}:${this.transport.port}/channel/stream/${event}`, {
111
+ signal: controller.signal,
112
+ });
113
+ if (!res.ok || !res.body) {
114
+ this.logger.warn(`subscribeToPeer('${event}') to '${peerName}' failed to connect`);
115
+ return;
116
+ }
117
+ const reader = res.body.getReader();
118
+ const decoder = new TextDecoder();
119
+ let buffer = "";
120
+ while (!stopped) {
121
+ const { done, value } = await reader.read();
122
+ if (done)
123
+ break;
124
+ buffer += decoder.decode(value, { stream: true });
125
+ let boundary;
126
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
127
+ const frame = buffer.slice(0, boundary);
128
+ buffer = buffer.slice(boundary + 2);
129
+ const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
130
+ if (!dataLine)
131
+ continue;
132
+ try {
133
+ onData(JSON.parse(dataLine.slice(6)));
134
+ }
135
+ catch {
136
+ this.logger.warn(`subscribeToPeer('${event}') received a malformed frame from '${peerName}'`);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ catch (err) {
142
+ if (err.name !== "AbortError") {
143
+ this.logger.error(`subscribeToPeer('${event}') stream to '${peerName}' failed: ${err.message}`);
144
+ }
145
+ }
146
+ })();
147
+ return () => {
148
+ stopped = true;
149
+ controller.abort();
150
+ };
151
+ }
152
+ }
@@ -0,0 +1,3 @@
1
+ /** Minimal logging shape every class accepts as `opts.logger`. `console` satisfies this as-is. */
2
+ export type Logger = Pick<Console, "info" | "warn" | "error" | "debug">;
3
+ //# sourceMappingURL=logger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/core/logger.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,MAAM,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,CAAC"}
File without changes