@rljson/network 0.0.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.
@@ -0,0 +1,1691 @@
1
+ import { mkdir, writeFile, readFile } from "node:fs/promises";
2
+ import { homedir, hostname, networkInterfaces } from "node:os";
3
+ import { randomUUID } from "node:crypto";
4
+ import { join, dirname } from "node:path";
5
+ import { createSocket } from "node:dgram";
6
+ import { connect } from "node:net";
7
+ // @license
8
+ const exampleNodeInfo = {
9
+ nodeId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
10
+ hostname: "WORKSTATION-7",
11
+ localIps: ["192.168.1.42"],
12
+ domain: "office-sync",
13
+ port: 3e3,
14
+ startedAt: 1741123456789
15
+ };
16
+ // @license
17
+ const examplePeerProbe = {
18
+ fromNodeId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
19
+ toNodeId: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
20
+ reachable: true,
21
+ latencyMs: 0.3,
22
+ measuredAt: 1741123456800
23
+ };
24
+ // @license
25
+ const nodeRoles = ["hub", "client", "unassigned"];
26
+ const formedByValues = [
27
+ "broadcast",
28
+ "cloud",
29
+ "election",
30
+ "manual",
31
+ "static"
32
+ ];
33
+ const exampleNetworkTopology = {
34
+ domain: "office-sync",
35
+ hubNodeId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
36
+ hubAddress: "192.168.1.42:3000",
37
+ formedBy: "broadcast",
38
+ formedAt: 1741123456800,
39
+ nodes: {
40
+ "a1b2c3d4-e5f6-7890-abcd-ef1234567890": {
41
+ nodeId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
42
+ hostname: "WORKSTATION-7",
43
+ localIps: ["192.168.1.42"],
44
+ domain: "office-sync",
45
+ port: 3e3,
46
+ startedAt: 1741123456789
47
+ }
48
+ },
49
+ probes: [],
50
+ myRole: "hub"
51
+ };
52
+ // @license
53
+ function defaultNetworkConfig(domain, port) {
54
+ return {
55
+ domain,
56
+ port,
57
+ broadcast: { enabled: true, port: 41234 },
58
+ probing: { enabled: true }
59
+ };
60
+ }
61
+ // @license
62
+ const networkEventNames = [
63
+ "topology-changed",
64
+ "role-changed",
65
+ "hub-changed",
66
+ "peer-joined",
67
+ "peer-left"
68
+ ];
69
+ const exampleTopologyChangedEvent = {
70
+ topology: exampleNetworkTopology
71
+ };
72
+ const exampleRoleChangedEvent = {
73
+ previous: "unassigned",
74
+ current: "hub"
75
+ };
76
+ const exampleHubChangedEvent = {
77
+ previousHub: null,
78
+ currentHub: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
79
+ };
80
+ // @license
81
+ function parseLocalIps(interfaces) {
82
+ const ips = [];
83
+ for (const infos of Object.values(interfaces)) {
84
+ /* v8 ignore if -- @preserve */
85
+ if (!infos) continue;
86
+ for (const info of infos) {
87
+ if (info.family === "IPv4" && !info.internal) {
88
+ ips.push(info.address);
89
+ }
90
+ }
91
+ }
92
+ return ips;
93
+ }
94
+ function defaultNodeIdentityDeps() {
95
+ return {
96
+ readNodeId: async (filePath) => {
97
+ try {
98
+ return (await readFile(filePath, "utf-8")).trim();
99
+ } catch {
100
+ return null;
101
+ }
102
+ },
103
+ writeNodeId: async (filePath, nodeId) => {
104
+ await mkdir(dirname(filePath), { recursive: true });
105
+ await writeFile(filePath, nodeId, "utf-8");
106
+ },
107
+ hostname: () => hostname(),
108
+ localIps: () => parseLocalIps(networkInterfaces()),
109
+ randomUUID: () => randomUUID(),
110
+ now: () => Date.now(),
111
+ homedir: () => homedir()
112
+ };
113
+ }
114
+ class NodeIdentity {
115
+ nodeId;
116
+ hostname;
117
+ localIps;
118
+ domain;
119
+ port;
120
+ startedAt;
121
+ constructor(info) {
122
+ this.nodeId = info.nodeId;
123
+ this.hostname = info.hostname;
124
+ this.localIps = [...info.localIps];
125
+ this.domain = info.domain;
126
+ this.port = info.port;
127
+ this.startedAt = info.startedAt;
128
+ }
129
+ /** Convert to a plain NodeInfo data object */
130
+ toNodeInfo() {
131
+ return {
132
+ nodeId: this.nodeId,
133
+ hostname: this.hostname,
134
+ localIps: [...this.localIps],
135
+ domain: this.domain,
136
+ port: this.port,
137
+ startedAt: this.startedAt
138
+ };
139
+ }
140
+ /**
141
+ * Create a NodeIdentity, loading or generating a persistent UUID.
142
+ * @param options - Configuration and optional dependency overrides
143
+ */
144
+ static async create(options) {
145
+ const deps = { ...defaultNodeIdentityDeps(), ...options.deps };
146
+ const identityDir = options.identityDir ?? join(deps.homedir(), ".rljson-network");
147
+ const nodeIdPath = join(identityDir, options.domain, "node-id");
148
+ let nodeId = await deps.readNodeId(nodeIdPath);
149
+ if (!nodeId) {
150
+ nodeId = deps.randomUUID();
151
+ await deps.writeNodeId(nodeIdPath, nodeId);
152
+ }
153
+ return new NodeIdentity({
154
+ nodeId,
155
+ hostname: deps.hostname(),
156
+ localIps: deps.localIps(),
157
+ domain: options.domain,
158
+ port: options.port,
159
+ startedAt: deps.now()
160
+ });
161
+ }
162
+ }
163
+ // @license
164
+ function defaultCreateUdpSocket() {
165
+ const raw = createSocket({ type: "udp4", reuseAddr: true });
166
+ return {
167
+ bind(port) {
168
+ return new Promise((resolve, reject) => {
169
+ raw.once("error", reject);
170
+ raw.bind(port, () => {
171
+ raw.removeListener("error", reject);
172
+ resolve();
173
+ });
174
+ });
175
+ },
176
+ send(data, port, address) {
177
+ return new Promise((resolve, reject) => {
178
+ raw.send(data, 0, data.length, port, address, (err) => {
179
+ /* v8 ignore if -- @preserve */
180
+ if (err) {
181
+ reject(err);
182
+ } else {
183
+ resolve();
184
+ }
185
+ });
186
+ });
187
+ },
188
+ onMessage(handler) {
189
+ raw.on("message", handler);
190
+ },
191
+ setBroadcast(flag) {
192
+ raw.setBroadcast(flag);
193
+ },
194
+ close() {
195
+ return new Promise((resolve) => {
196
+ raw.close(resolve);
197
+ });
198
+ }
199
+ };
200
+ }
201
+ class BroadcastLayer {
202
+ /**
203
+ * Create a BroadcastLayer.
204
+ * @param _config - Broadcast configuration (port, interval, timeout)
205
+ * @param deps - Injectable dependencies for testing
206
+ */
207
+ constructor(_config, deps) {
208
+ this._config = _config;
209
+ this._createSocket = deps?.createSocket ?? defaultCreateUdpSocket;
210
+ this._selfTestTimeoutMs = deps?.selfTestTimeoutMs ?? 2e3;
211
+ }
212
+ name = "broadcast";
213
+ _active = false;
214
+ _socket = null;
215
+ _identity = null;
216
+ _broadcastTimer = null;
217
+ _timeoutTimer = null;
218
+ _selfTestCallback = null;
219
+ _peers = /* @__PURE__ */ new Map();
220
+ _listeners = /* @__PURE__ */ new Map();
221
+ _createSocket;
222
+ _selfTestTimeoutMs;
223
+ // .........................................................................
224
+ // Lifecycle
225
+ // .........................................................................
226
+ /**
227
+ * Start the broadcast layer.
228
+ *
229
+ * 1. Bind UDP socket to configured port
230
+ * 2. Set up message handler
231
+ * 3. Perform self-test (send broadcast, listen for loopback)
232
+ * 4. If self-test passes: start periodic broadcasting + timeout checker
233
+ * @param identity - This node's identity
234
+ * @returns true if broadcast is available, false otherwise
235
+ */
236
+ async start(identity) {
237
+ if (this._config?.enabled === false) {
238
+ return false;
239
+ }
240
+ this._identity = identity;
241
+ const port = this._config?.port ?? 41234;
242
+ this._socket = this._createSocket();
243
+ try {
244
+ await this._socket.bind(port);
245
+ } catch {
246
+ await this._socket.close();
247
+ this._socket = null;
248
+ return false;
249
+ }
250
+ this._socket.setBroadcast(true);
251
+ this._socket.onMessage((msg, rinfo) => {
252
+ this._handleMessage(msg, rinfo);
253
+ });
254
+ const selfTestPassed = await this._selfTest(port);
255
+ if (!selfTestPassed) {
256
+ await this._socket.close();
257
+ this._socket = null;
258
+ return false;
259
+ }
260
+ this._active = true;
261
+ const intervalMs = this._config?.intervalMs ?? 5e3;
262
+ this._broadcastTimer = setInterval(() => {
263
+ void this._sendBroadcast(port);
264
+ }, intervalMs);
265
+ const timeoutMs = this._config?.timeoutMs ?? 15e3;
266
+ this._timeoutTimer = setInterval(() => {
267
+ this._checkTimeouts(timeoutMs);
268
+ }, intervalMs);
269
+ return true;
270
+ }
271
+ /** Stop the layer and clean up resources */
272
+ async stop() {
273
+ if (this._broadcastTimer) {
274
+ clearInterval(this._broadcastTimer);
275
+ this._broadcastTimer = null;
276
+ }
277
+ if (this._timeoutTimer) {
278
+ clearInterval(this._timeoutTimer);
279
+ this._timeoutTimer = null;
280
+ }
281
+ if (this._active) {
282
+ for (const [nodeId] of this._peers) {
283
+ this._emit("peer-lost", nodeId);
284
+ }
285
+ }
286
+ if (this._socket) {
287
+ await this._socket.close();
288
+ this._socket = null;
289
+ }
290
+ this._peers.clear();
291
+ this._active = false;
292
+ this._identity = null;
293
+ this._selfTestCallback = null;
294
+ this._listeners.clear();
295
+ }
296
+ /** Whether this layer is currently active */
297
+ isActive() {
298
+ return this._active;
299
+ }
300
+ // .........................................................................
301
+ // Peer access
302
+ // .........................................................................
303
+ /** Get all currently known peers from broadcast discovery */
304
+ getPeers() {
305
+ return [...this._peers.values()].map((e) => e.info);
306
+ }
307
+ /**
308
+ * Broadcast does NOT assign a hub — hub is elected by NetworkManager.
309
+ * Always returns null.
310
+ */
311
+ getAssignedHub() {
312
+ return null;
313
+ }
314
+ // .........................................................................
315
+ // Events
316
+ // .........................................................................
317
+ /**
318
+ * Subscribe to layer events.
319
+ * @param event - Event name
320
+ * @param cb - Callback
321
+ */
322
+ on(event, cb) {
323
+ let set = this._listeners.get(event);
324
+ if (!set) {
325
+ set = /* @__PURE__ */ new Set();
326
+ this._listeners.set(event, set);
327
+ }
328
+ set.add(cb);
329
+ }
330
+ /**
331
+ * Unsubscribe from layer events.
332
+ * @param event - Event name
333
+ * @param cb - Callback
334
+ */
335
+ off(event, cb) {
336
+ const set = this._listeners.get(event);
337
+ /* v8 ignore if -- @preserve */
338
+ if (!set) return;
339
+ set.delete(cb);
340
+ }
341
+ // .........................................................................
342
+ // Internal
343
+ // .........................................................................
344
+ /**
345
+ * Self-test: send a broadcast and listen for loopback reception.
346
+ * @param port - The UDP port to broadcast on
347
+ * @returns true if own packet was received, false on timeout
348
+ */
349
+ _selfTest(port) {
350
+ return new Promise((resolve) => {
351
+ let resolved = false;
352
+ this._selfTestCallback = () => {
353
+ /* v8 ignore if -- @preserve */
354
+ if (!resolved) {
355
+ resolved = true;
356
+ resolve(true);
357
+ }
358
+ };
359
+ void this._sendBroadcast(port);
360
+ setTimeout(() => {
361
+ if (!resolved) {
362
+ resolved = true;
363
+ this._selfTestCallback = null;
364
+ resolve(false);
365
+ }
366
+ }, this._selfTestTimeoutMs);
367
+ });
368
+ }
369
+ /**
370
+ * Send a broadcast packet containing this node's info.
371
+ * @param port - The UDP port to broadcast on
372
+ */
373
+ async _sendBroadcast(port) {
374
+ /* v8 ignore if -- @preserve */
375
+ if (!this._socket || !this._identity) return;
376
+ const info = this._identity.toNodeInfo();
377
+ const data = Buffer.from(JSON.stringify(info));
378
+ try {
379
+ await this._socket.send(data, port, "255.255.255.255");
380
+ } catch {
381
+ }
382
+ }
383
+ /**
384
+ * Handle an incoming broadcast message.
385
+ * @param msg - Raw UDP message
386
+ * @param _rinfo - Remote address info (unused — we use packet content)
387
+ */
388
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
389
+ _handleMessage(msg, _rinfo) {
390
+ let packet;
391
+ try {
392
+ packet = JSON.parse(msg.toString());
393
+ } catch {
394
+ return;
395
+ }
396
+ if (packet.domain !== this._identity?.domain) {
397
+ return;
398
+ }
399
+ if (packet.nodeId === this._identity?.nodeId) {
400
+ if (this._selfTestCallback) {
401
+ this._selfTestCallback();
402
+ this._selfTestCallback = null;
403
+ }
404
+ return;
405
+ }
406
+ const isNew = !this._peers.has(packet.nodeId);
407
+ this._peers.set(packet.nodeId, { info: packet, lastSeen: Date.now() });
408
+ if (isNew) {
409
+ this._emit("peer-discovered", packet);
410
+ }
411
+ }
412
+ /**
413
+ * Check for timed-out peers and remove them.
414
+ * @param timeoutMs - Maximum silence period before declaring peer lost
415
+ */
416
+ _checkTimeouts(timeoutMs) {
417
+ const now = Date.now();
418
+ for (const [nodeId, entry] of this._peers) {
419
+ if (now - entry.lastSeen > timeoutMs) {
420
+ this._peers.delete(nodeId);
421
+ this._emit("peer-lost", nodeId);
422
+ }
423
+ }
424
+ }
425
+ /**
426
+ * Emit a typed event to all registered listeners.
427
+ * @param event - Event name
428
+ * @param args - Event arguments
429
+ */
430
+ _emit(event, ...args) {
431
+ const set = this._listeners.get(event);
432
+ if (!set) return;
433
+ for (const cb of set) {
434
+ cb(...args);
435
+ }
436
+ }
437
+ }
438
+ // @license
439
+ function defaultCreateCloudHttpClient() {
440
+ return {
441
+ async register(endpoint, info, apiKey) {
442
+ const headers = {
443
+ "Content-Type": "application/json"
444
+ };
445
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
446
+ const res = await fetch(`${endpoint}/register`, {
447
+ method: "POST",
448
+ headers,
449
+ body: JSON.stringify(info)
450
+ });
451
+ if (!res.ok) {
452
+ throw new Error(`Cloud register failed: ${res.status}`);
453
+ }
454
+ return await res.json();
455
+ },
456
+ async poll(endpoint, nodeId, domain, apiKey) {
457
+ const headers = {};
458
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
459
+ const params = new URLSearchParams({ nodeId, domain });
460
+ const res = await fetch(`${endpoint}/peers?${params.toString()}`, {
461
+ method: "GET",
462
+ headers
463
+ });
464
+ if (!res.ok) {
465
+ throw new Error(`Cloud poll failed: ${res.status}`);
466
+ }
467
+ return await res.json();
468
+ },
469
+ async reportProbes(endpoint, nodeId, probes, apiKey) {
470
+ const headers = {
471
+ "Content-Type": "application/json"
472
+ };
473
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
474
+ const res = await fetch(`${endpoint}/probes`, {
475
+ method: "POST",
476
+ headers,
477
+ body: JSON.stringify({ nodeId, probes })
478
+ });
479
+ if (!res.ok) {
480
+ throw new Error(`Cloud reportProbes failed: ${res.status}`);
481
+ }
482
+ }
483
+ };
484
+ }
485
+ class CloudLayer {
486
+ /**
487
+ * Create a CloudLayer.
488
+ * @param _config - Cloud configuration (endpoint, apiKey, pollInterval)
489
+ * @param deps - Injectable dependencies for testing
490
+ */
491
+ constructor(_config, deps) {
492
+ this._config = _config;
493
+ this._httpClient = deps?.createHttpClient?.() ?? defaultCreateCloudHttpClient();
494
+ }
495
+ name = "cloud";
496
+ _active = false;
497
+ _identity = null;
498
+ _pollTimer = null;
499
+ _peers = /* @__PURE__ */ new Map();
500
+ _assignedHub = null;
501
+ _listeners = /* @__PURE__ */ new Map();
502
+ _httpClient;
503
+ // Backoff state
504
+ _consecutivePollFailures = 0;
505
+ _basePollIntervalMs = 3e4;
506
+ _currentPollIntervalMs = 3e4;
507
+ _maxBackoffMs = 3e5;
508
+ _reRegisterThreshold = 10;
509
+ // .........................................................................
510
+ // Lifecycle
511
+ // .........................................................................
512
+ /**
513
+ * Start the cloud layer.
514
+ *
515
+ * 1. Check if cloud is enabled and endpoint configured
516
+ * 2. Register this node with the cloud
517
+ * 3. Process initial peer list and hub assignment
518
+ * 4. Start periodic polling
519
+ * @param identity - This node's identity
520
+ * @returns true if cloud is available, false otherwise
521
+ */
522
+ async start(identity) {
523
+ if (this._active) return true;
524
+ if (this._config?.enabled !== true) {
525
+ return false;
526
+ }
527
+ if (!this._config.endpoint) {
528
+ return false;
529
+ }
530
+ this._identity = identity;
531
+ let response;
532
+ try {
533
+ response = await this._httpClient.register(
534
+ this._config.endpoint,
535
+ identity.toNodeInfo(),
536
+ this._config.apiKey
537
+ );
538
+ } catch {
539
+ return false;
540
+ }
541
+ this._active = true;
542
+ this._basePollIntervalMs = Math.max(
543
+ this._config.pollIntervalMs ?? 3e4,
544
+ 100
545
+ );
546
+ this._currentPollIntervalMs = this._basePollIntervalMs;
547
+ this._maxBackoffMs = Math.max(this._config.maxBackoffMs ?? 3e5, 100);
548
+ this._reRegisterThreshold = Math.max(
549
+ this._config.reRegisterAfterFailures ?? 10,
550
+ 1
551
+ );
552
+ this._consecutivePollFailures = 0;
553
+ this._processResponse(response);
554
+ this._schedulePoll();
555
+ return true;
556
+ }
557
+ /** Stop the layer and clean up resources */
558
+ async stop() {
559
+ if (this._pollTimer) {
560
+ clearTimeout(this._pollTimer);
561
+ this._pollTimer = null;
562
+ }
563
+ if (this._active) {
564
+ for (const [nodeId] of this._peers) {
565
+ this._emit("peer-lost", nodeId);
566
+ }
567
+ }
568
+ this._peers.clear();
569
+ this._assignedHub = null;
570
+ this._active = false;
571
+ this._identity = null;
572
+ this._listeners.clear();
573
+ this._consecutivePollFailures = 0;
574
+ this._currentPollIntervalMs = this._basePollIntervalMs;
575
+ }
576
+ /** Whether this layer is currently active */
577
+ isActive() {
578
+ return this._active;
579
+ }
580
+ // .........................................................................
581
+ // Peer access
582
+ // .........................................................................
583
+ /** Get all currently known peers from cloud discovery */
584
+ getPeers() {
585
+ return [...this._peers.values()];
586
+ }
587
+ /**
588
+ * Get the hub assigned by the cloud.
589
+ * The cloud **dictates** the hub — it has the full picture.
590
+ */
591
+ getAssignedHub() {
592
+ return this._assignedHub;
593
+ }
594
+ /** Get current consecutive poll failure count (for diagnostics/testing) */
595
+ getConsecutivePollFailures() {
596
+ return this._consecutivePollFailures;
597
+ }
598
+ /** Get current effective poll interval including backoff (for diagnostics/testing) */
599
+ getCurrentPollIntervalMs() {
600
+ return this._currentPollIntervalMs;
601
+ }
602
+ // .........................................................................
603
+ // Probe reporting
604
+ // .........................................................................
605
+ /**
606
+ * Report local probe results to the cloud.
607
+ * The cloud uses these to build a connectivity graph and assign hubs.
608
+ * @param probes - Probe results from the local ProbeScheduler
609
+ */
610
+ async reportProbes(probes) {
611
+ /* v8 ignore if -- @preserve */
612
+ if (!this._active || !this._identity || !this._config?.endpoint) return;
613
+ try {
614
+ await this._httpClient.reportProbes(
615
+ this._config.endpoint,
616
+ this._identity.nodeId,
617
+ probes,
618
+ this._config.apiKey
619
+ );
620
+ } catch {
621
+ }
622
+ }
623
+ // .........................................................................
624
+ // Events
625
+ // .........................................................................
626
+ /**
627
+ * Subscribe to layer events.
628
+ * @param event - Event name
629
+ * @param cb - Callback
630
+ */
631
+ on(event, cb) {
632
+ let set = this._listeners.get(event);
633
+ if (!set) {
634
+ set = /* @__PURE__ */ new Set();
635
+ this._listeners.set(event, set);
636
+ }
637
+ set.add(cb);
638
+ }
639
+ /**
640
+ * Unsubscribe from layer events.
641
+ * @param event - Event name
642
+ * @param cb - Callback
643
+ */
644
+ off(event, cb) {
645
+ const set = this._listeners.get(event);
646
+ /* v8 ignore if -- @preserve */
647
+ if (!set) return;
648
+ set.delete(cb);
649
+ }
650
+ // .........................................................................
651
+ // Internal
652
+ // .........................................................................
653
+ /**
654
+ * Schedule the next poll using setTimeout.
655
+ * Uses the current (possibly backed-off) interval.
656
+ */
657
+ _schedulePoll() {
658
+ this._pollTimer = setTimeout(() => {
659
+ void this._poll().catch(() => {
660
+ }).then(() => {
661
+ /* v8 ignore if -- @preserve */
662
+ if (this._active) this._schedulePoll();
663
+ });
664
+ }, this._currentPollIntervalMs);
665
+ }
666
+ /**
667
+ * Poll the cloud for latest peer list and hub assignment.
668
+ *
669
+ * After many consecutive failures, attempts re-registration instead
670
+ * of a regular poll (the cloud may have expired our registration).
671
+ *
672
+ * On success: resets failure counter and backoff interval.
673
+ * On failure: increments counter and doubles interval (capped at maxBackoffMs).
674
+ */
675
+ async _poll() {
676
+ /* v8 ignore if -- @preserve */
677
+ if (!this._identity || !this._config?.endpoint) return;
678
+ if (this._consecutivePollFailures >= this._reRegisterThreshold) {
679
+ let response2;
680
+ try {
681
+ response2 = await this._httpClient.register(
682
+ this._config.endpoint,
683
+ this._identity.toNodeInfo(),
684
+ this._config.apiKey
685
+ );
686
+ } catch {
687
+ this._consecutivePollFailures++;
688
+ this._currentPollIntervalMs = Math.min(
689
+ this._currentPollIntervalMs * 2,
690
+ this._maxBackoffMs
691
+ );
692
+ return;
693
+ }
694
+ this._consecutivePollFailures = 0;
695
+ this._currentPollIntervalMs = this._basePollIntervalMs;
696
+ this._processResponse(response2);
697
+ return;
698
+ }
699
+ let response;
700
+ try {
701
+ response = await this._httpClient.poll(
702
+ this._config.endpoint,
703
+ this._identity.nodeId,
704
+ this._identity.domain,
705
+ this._config.apiKey
706
+ );
707
+ } catch {
708
+ this._consecutivePollFailures++;
709
+ this._currentPollIntervalMs = Math.min(
710
+ this._currentPollIntervalMs * 2,
711
+ this._maxBackoffMs
712
+ );
713
+ return;
714
+ }
715
+ this._consecutivePollFailures = 0;
716
+ this._currentPollIntervalMs = this._basePollIntervalMs;
717
+ this._processResponse(response);
718
+ }
719
+ /**
720
+ * Process a cloud response: update peers and hub assignment.
721
+ * @param response - The cloud's peer list response
722
+ */
723
+ _processResponse(response) {
724
+ const currentPeerIds = new Set(this._peers.keys());
725
+ const newPeerIds = /* @__PURE__ */ new Set();
726
+ for (const peer of response.peers) {
727
+ if (peer.nodeId === this._identity?.nodeId) continue;
728
+ newPeerIds.add(peer.nodeId);
729
+ const isNew = !this._peers.has(peer.nodeId);
730
+ this._peers.set(peer.nodeId, peer);
731
+ if (isNew) {
732
+ this._emit("peer-discovered", peer);
733
+ }
734
+ }
735
+ for (const oldId of currentPeerIds) {
736
+ if (!newPeerIds.has(oldId)) {
737
+ this._peers.delete(oldId);
738
+ this._emit("peer-lost", oldId);
739
+ }
740
+ }
741
+ const previousHub = this._assignedHub;
742
+ this._assignedHub = response.assignedHub;
743
+ if (previousHub !== this._assignedHub) {
744
+ this._emit("hub-assigned", this._assignedHub);
745
+ }
746
+ }
747
+ /**
748
+ * Emit a typed event to all registered listeners.
749
+ * @param event - Event name
750
+ * @param args - Event arguments
751
+ */
752
+ _emit(event, ...args) {
753
+ const set = this._listeners.get(event);
754
+ if (!set) return;
755
+ for (const cb of set) {
756
+ cb(...args);
757
+ }
758
+ }
759
+ }
760
+ // @license
761
+ class ManualLayer {
762
+ name = "manual";
763
+ _active = false;
764
+ _assignedHub = null;
765
+ _listeners = /* @__PURE__ */ new Map();
766
+ /**
767
+ * Start always succeeds — manual layer cannot be disabled.
768
+ * @param _identity - Node identity (unused by manual layer)
769
+ */
770
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
771
+ async start(_identity) {
772
+ this._active = true;
773
+ return true;
774
+ }
775
+ /** Stop the layer */
776
+ async stop() {
777
+ this._active = false;
778
+ this._assignedHub = null;
779
+ this._listeners.clear();
780
+ }
781
+ /** Always active after start */
782
+ isActive() {
783
+ return this._active;
784
+ }
785
+ /** Manual layer does not discover peers */
786
+ getPeers() {
787
+ return [];
788
+ }
789
+ /** Get the manually assigned hub, or null if no override is set */
790
+ getAssignedHub() {
791
+ return this._assignedHub;
792
+ }
793
+ /**
794
+ * Force a specific node as the hub.
795
+ * @param nodeId - The nodeId to assign as hub
796
+ */
797
+ assignHub(nodeId) {
798
+ this._assignedHub = nodeId;
799
+ this._emit("hub-assigned", nodeId);
800
+ }
801
+ /** Clear the manual override — returns control to the automatic cascade */
802
+ clearOverride() {
803
+ this._assignedHub = null;
804
+ this._emit("hub-assigned", null);
805
+ }
806
+ /**
807
+ * Subscribe to layer events.
808
+ * @param event - Event name
809
+ * @param cb - Callback
810
+ */
811
+ on(event, cb) {
812
+ let set = this._listeners.get(event);
813
+ if (!set) {
814
+ set = /* @__PURE__ */ new Set();
815
+ this._listeners.set(event, set);
816
+ }
817
+ set.add(cb);
818
+ }
819
+ /**
820
+ * Unsubscribe from layer events.
821
+ * @param event - Event name
822
+ * @param cb - Callback
823
+ */
824
+ off(event, cb) {
825
+ const set = this._listeners.get(event);
826
+ /* v8 ignore if -- @preserve */
827
+ if (!set) return;
828
+ set.delete(cb);
829
+ }
830
+ // ...........................................................................
831
+ /**
832
+ * Emit a typed event to all registered listeners.
833
+ * @param event - Event name
834
+ * @param args - Event arguments
835
+ */
836
+ _emit(event, ...args) {
837
+ const set = this._listeners.get(event);
838
+ /* v8 ignore if -- @preserve */
839
+ if (!set) return;
840
+ for (const cb of set) {
841
+ cb(...args);
842
+ }
843
+ }
844
+ }
845
+ // @license
846
+ class StaticLayer {
847
+ /**
848
+ * Create a StaticLayer.
849
+ * @param _config - Static config with optional hubAddress
850
+ */
851
+ constructor(_config) {
852
+ this._config = _config;
853
+ }
854
+ name = "static";
855
+ _active = false;
856
+ _hubAddress = null;
857
+ _hubNodeId = null;
858
+ _syntheticPeer = null;
859
+ _listeners = /* @__PURE__ */ new Map();
860
+ /**
861
+ * Start the layer. Returns false if no hubAddress is configured.
862
+ * @param identity - This node's identity (used for domain info on synthetic peer)
863
+ */
864
+ async start(identity) {
865
+ const hubAddress = this._config?.hubAddress;
866
+ if (!hubAddress) {
867
+ return false;
868
+ }
869
+ this._hubAddress = hubAddress;
870
+ this._hubNodeId = `static-hub-${hubAddress}`;
871
+ const colonIdx = hubAddress.lastIndexOf(":");
872
+ const host = colonIdx >= 0 ? hubAddress.substring(0, colonIdx) : hubAddress;
873
+ const port = colonIdx >= 0 ? parseInt(hubAddress.substring(colonIdx + 1), 10) : 3e3;
874
+ this._syntheticPeer = {
875
+ nodeId: this._hubNodeId,
876
+ hostname: `static-${host}`,
877
+ localIps: [host],
878
+ domain: identity.domain,
879
+ port,
880
+ startedAt: 0
881
+ // unknown
882
+ };
883
+ this._active = true;
884
+ this._emit("peer-discovered", this._syntheticPeer);
885
+ this._emit("hub-assigned", this._hubNodeId);
886
+ return true;
887
+ }
888
+ /** Stop the layer */
889
+ async stop() {
890
+ if (this._active && this._hubNodeId) {
891
+ this._emit("peer-lost", this._hubNodeId);
892
+ }
893
+ this._active = false;
894
+ this._hubAddress = null;
895
+ this._hubNodeId = null;
896
+ this._syntheticPeer = null;
897
+ this._listeners.clear();
898
+ }
899
+ /** Whether this layer is currently active */
900
+ isActive() {
901
+ return this._active;
902
+ }
903
+ /** Get the synthetic peer for the configured hub (or empty array) */
904
+ getPeers() {
905
+ if (!this._syntheticPeer) return [];
906
+ return [this._syntheticPeer];
907
+ }
908
+ /** Get the statically configured hub nodeId */
909
+ getAssignedHub() {
910
+ return this._hubNodeId;
911
+ }
912
+ /** Get the raw hub address string ("ip:port") */
913
+ getHubAddress() {
914
+ return this._hubAddress;
915
+ }
916
+ /**
917
+ * Subscribe to layer events.
918
+ * @param event - Event name
919
+ * @param cb - Callback
920
+ */
921
+ on(event, cb) {
922
+ let set = this._listeners.get(event);
923
+ if (!set) {
924
+ set = /* @__PURE__ */ new Set();
925
+ this._listeners.set(event, set);
926
+ }
927
+ set.add(cb);
928
+ }
929
+ /**
930
+ * Unsubscribe from layer events.
931
+ * @param event - Event name
932
+ * @param cb - Callback
933
+ */
934
+ off(event, cb) {
935
+ const set = this._listeners.get(event);
936
+ /* v8 ignore if -- @preserve */
937
+ if (!set) return;
938
+ set.delete(cb);
939
+ }
940
+ // ...........................................................................
941
+ /**
942
+ * Emit a typed event to all registered listeners.
943
+ * @param event - Event name
944
+ * @param args - Event arguments
945
+ */
946
+ _emit(event, ...args) {
947
+ const set = this._listeners.get(event);
948
+ if (!set) return;
949
+ for (const cb of set) {
950
+ cb(...args);
951
+ }
952
+ }
953
+ }
954
+ // @license
955
+ class PeerTable {
956
+ /** All known peers, keyed by nodeId */
957
+ _peers = /* @__PURE__ */ new Map();
958
+ /** Per-layer peer sets, for deduplication tracking */
959
+ _layerPeers = /* @__PURE__ */ new Map();
960
+ /** Event listeners */
961
+ _listeners = /* @__PURE__ */ new Map();
962
+ /** Self nodeId — excluded from the peer table */
963
+ _selfId = null;
964
+ /**
965
+ * Set the self nodeId so it's excluded from the peer table.
966
+ * @param nodeId - This node's own ID
967
+ */
968
+ setSelfId(nodeId) {
969
+ this._selfId = nodeId;
970
+ }
971
+ /**
972
+ * Attach a discovery layer — subscribes to its peer events.
973
+ * Also imports any peers the layer already knows about.
974
+ * @param layer - The discovery layer to attach
975
+ */
976
+ attachLayer(layer) {
977
+ const layerName = layer.name;
978
+ if (!this._layerPeers.has(layerName)) {
979
+ this._layerPeers.set(layerName, /* @__PURE__ */ new Set());
980
+ }
981
+ for (const peer of layer.getPeers()) {
982
+ this._addPeerFromLayer(layerName, peer);
983
+ }
984
+ layer.on("peer-discovered", (peer) => {
985
+ this._addPeerFromLayer(layerName, peer);
986
+ });
987
+ layer.on("peer-lost", (nodeId) => {
988
+ this._removePeerFromLayer(layerName, nodeId);
989
+ });
990
+ }
991
+ /** Get all known peers as an array */
992
+ getPeers() {
993
+ return [...this._peers.values()];
994
+ }
995
+ /**
996
+ * Get a specific peer by nodeId.
997
+ * @param nodeId - The peer's nodeId
998
+ */
999
+ getPeer(nodeId) {
1000
+ return this._peers.get(nodeId);
1001
+ }
1002
+ /** Get the number of known peers */
1003
+ get size() {
1004
+ return this._peers.size;
1005
+ }
1006
+ /** Clear all peers and layer tracking */
1007
+ clear() {
1008
+ this._peers.clear();
1009
+ this._layerPeers.clear();
1010
+ this._listeners.clear();
1011
+ }
1012
+ /**
1013
+ * Subscribe to peer table events.
1014
+ * @param event - Event name
1015
+ * @param cb - Callback
1016
+ */
1017
+ on(event, cb) {
1018
+ let set = this._listeners.get(event);
1019
+ if (!set) {
1020
+ set = /* @__PURE__ */ new Set();
1021
+ this._listeners.set(event, set);
1022
+ }
1023
+ set.add(cb);
1024
+ }
1025
+ /**
1026
+ * Unsubscribe from peer table events.
1027
+ * @param event - Event name
1028
+ * @param cb - Callback
1029
+ */
1030
+ off(event, cb) {
1031
+ const set = this._listeners.get(event);
1032
+ /* v8 ignore if -- @preserve */
1033
+ if (!set) return;
1034
+ set.delete(cb);
1035
+ }
1036
+ // ...........................................................................
1037
+ /**
1038
+ * Add a peer from a specific layer.
1039
+ * Only emits peer-joined if this is a genuinely new peer.
1040
+ * @param layerName - Name of the source layer
1041
+ * @param peer - The peer to add
1042
+ */
1043
+ _addPeerFromLayer(layerName, peer) {
1044
+ if (this._selfId && peer.nodeId === this._selfId) return;
1045
+ const layerSet = this._layerPeers.get(layerName);
1046
+ layerSet.add(peer.nodeId);
1047
+ const isNew = !this._peers.has(peer.nodeId);
1048
+ this._peers.set(peer.nodeId, peer);
1049
+ if (isNew) {
1050
+ this._emit("peer-joined", peer);
1051
+ }
1052
+ }
1053
+ /**
1054
+ * Remove a peer from a specific layer.
1055
+ * Only emits peer-left if no other layer still knows about this peer.
1056
+ * @param layerName - Name of the source layer
1057
+ * @param nodeId - The peer's nodeId
1058
+ */
1059
+ _removePeerFromLayer(layerName, nodeId) {
1060
+ const layerSet = this._layerPeers.get(layerName);
1061
+ /* v8 ignore else -- @preserve */
1062
+ if (layerSet) {
1063
+ layerSet.delete(nodeId);
1064
+ }
1065
+ for (const [name, set] of this._layerPeers) {
1066
+ if (name !== layerName && set.has(nodeId)) {
1067
+ return;
1068
+ }
1069
+ }
1070
+ const peer = this._peers.get(nodeId);
1071
+ if (peer) {
1072
+ this._peers.delete(nodeId);
1073
+ this._emit("peer-left", nodeId);
1074
+ }
1075
+ }
1076
+ /**
1077
+ * Emit a typed event.
1078
+ * @param event - Event name
1079
+ * @param args - Event arguments
1080
+ */
1081
+ _emit(event, ...args) {
1082
+ const set = this._listeners.get(event);
1083
+ if (!set) return;
1084
+ for (const cb of set) {
1085
+ cb(...args);
1086
+ }
1087
+ }
1088
+ }
1089
+ // @license
1090
+ function electHub(candidates, probes, currentHubId, selfId) {
1091
+ if (candidates.length === 0) {
1092
+ return { hubId: null, reason: "no-candidates" };
1093
+ }
1094
+ const reachable = /* @__PURE__ */ new Set();
1095
+ reachable.add(selfId);
1096
+ for (const probe of probes) {
1097
+ if (probe.reachable) {
1098
+ reachable.add(probe.toNodeId);
1099
+ }
1100
+ }
1101
+ const reachableCandidates = candidates.filter((c) => reachable.has(c.nodeId));
1102
+ if (reachableCandidates.length === 0) {
1103
+ return { hubId: null, reason: "no-candidates" };
1104
+ }
1105
+ if (currentHubId !== null) {
1106
+ const incumbentStillReachable = reachableCandidates.some(
1107
+ (c) => c.nodeId === currentHubId
1108
+ );
1109
+ if (incumbentStillReachable) {
1110
+ return { hubId: currentHubId, reason: "incumbent" };
1111
+ }
1112
+ }
1113
+ reachableCandidates.sort((a, b) => {
1114
+ const timeDiff = a.startedAt - b.startedAt;
1115
+ if (timeDiff !== 0) return timeDiff;
1116
+ return a.nodeId.localeCompare(b.nodeId);
1117
+ });
1118
+ const winner = reachableCandidates[0];
1119
+ const reason = reachableCandidates.length > 1 && reachableCandidates[1].startedAt === winner.startedAt ? "tiebreaker" : "earliest-start";
1120
+ return { hubId: winner.nodeId, reason };
1121
+ }
1122
+ // @license
1123
+ async function probePeer(host, port, fromNodeId, toNodeId, options) {
1124
+ const timeoutMs = options?.timeoutMs ?? 2e3;
1125
+ const start = performance.now();
1126
+ return new Promise((resolve) => {
1127
+ const socket = connect({ host, port, timeout: timeoutMs });
1128
+ const finish = (reachable) => {
1129
+ const elapsed = performance.now() - start;
1130
+ socket.removeAllListeners();
1131
+ socket.destroy();
1132
+ resolve({
1133
+ fromNodeId,
1134
+ toNodeId,
1135
+ reachable,
1136
+ latencyMs: reachable ? Math.round(elapsed * 100) / 100 : -1,
1137
+ measuredAt: Date.now()
1138
+ });
1139
+ };
1140
+ socket.once("connect", () => finish(true));
1141
+ socket.once("timeout", () => finish(false));
1142
+ socket.once("error", () => finish(false));
1143
+ });
1144
+ }
1145
+ // @license
1146
+ class ProbeScheduler {
1147
+ _intervalMs;
1148
+ _timeoutMs;
1149
+ _failThreshold;
1150
+ _selfId = "";
1151
+ _running = false;
1152
+ _timer = null;
1153
+ /** Latest probe results, keyed by toNodeId */
1154
+ _probes = /* @__PURE__ */ new Map();
1155
+ /** Previous reachability state, for change detection */
1156
+ _wasReachable = /* @__PURE__ */ new Map();
1157
+ /** Consecutive failure count per peer, for flap dampening */
1158
+ _failCount = /* @__PURE__ */ new Map();
1159
+ /** Event listeners */
1160
+ _listeners = /* @__PURE__ */ new Map();
1161
+ /** The probe function — real TCP by default, injectable for tests */
1162
+ _probeFn;
1163
+ /** Peers to probe — updated externally via setPeers() */
1164
+ _peers = [];
1165
+ /**
1166
+ * Create a ProbeScheduler.
1167
+ * @param options - Configuration options
1168
+ */
1169
+ constructor(options) {
1170
+ this._intervalMs = options?.intervalMs ?? 1e4;
1171
+ this._timeoutMs = options?.timeoutMs ?? 2e3;
1172
+ this._probeFn = options?.probeFn ?? probePeer;
1173
+ this._failThreshold = options?.failThreshold ?? 3;
1174
+ }
1175
+ /**
1176
+ * Start the scheduler.
1177
+ * @param selfId - This node's ID (excluded from probing)
1178
+ */
1179
+ start(selfId) {
1180
+ if (this._running) return;
1181
+ this._selfId = selfId;
1182
+ this._running = true;
1183
+ void this._runCycle().then(() => {
1184
+ if (this._running) this._scheduleNext();
1185
+ });
1186
+ }
1187
+ /**
1188
+ * Schedule the next probe cycle using setTimeout.
1189
+ * Chaining (instead of setInterval) prevents overlapping cycles.
1190
+ */
1191
+ _scheduleNext() {
1192
+ this._timer = setTimeout(() => {
1193
+ void this._runCycle().then(() => {
1194
+ /* v8 ignore else -- @preserve */
1195
+ if (this._running) this._scheduleNext();
1196
+ });
1197
+ }, this._intervalMs);
1198
+ }
1199
+ /** Stop the scheduler and clear state */
1200
+ stop() {
1201
+ if (!this._running) return;
1202
+ this._running = false;
1203
+ /* v8 ignore if -- @preserve */
1204
+ if (this._timer) {
1205
+ clearTimeout(this._timer);
1206
+ this._timer = null;
1207
+ }
1208
+ this._probes.clear();
1209
+ this._wasReachable.clear();
1210
+ this._failCount.clear();
1211
+ this._peers = [];
1212
+ }
1213
+ /** Whether the scheduler is currently running */
1214
+ isRunning() {
1215
+ return this._running;
1216
+ }
1217
+ /**
1218
+ * Update the list of peers to probe.
1219
+ * Call this when the peer table changes.
1220
+ * Self is automatically excluded at probe time.
1221
+ * @param peers - The current peer list
1222
+ */
1223
+ setPeers(peers) {
1224
+ this._peers = [...peers];
1225
+ const currentPeerIds = new Set(peers.map((p) => p.nodeId));
1226
+ for (const [nodeId] of this._probes) {
1227
+ /* v8 ignore if -- @preserve */
1228
+ if (!currentPeerIds.has(nodeId)) {
1229
+ this._probes.delete(nodeId);
1230
+ this._wasReachable.delete(nodeId);
1231
+ this._failCount.delete(nodeId);
1232
+ }
1233
+ }
1234
+ }
1235
+ /** Get all latest probe results */
1236
+ getProbes() {
1237
+ return [...this._probes.values()];
1238
+ }
1239
+ /**
1240
+ * Get the latest probe result for a specific peer.
1241
+ * @param nodeId - The peer's nodeId
1242
+ */
1243
+ getProbe(nodeId) {
1244
+ return this._probes.get(nodeId);
1245
+ }
1246
+ /**
1247
+ * Run a single probe cycle manually.
1248
+ * Useful for tests that need immediate results without waiting.
1249
+ */
1250
+ async runOnce() {
1251
+ return this._runCycle();
1252
+ }
1253
+ // .........................................................................
1254
+ // Events
1255
+ // .........................................................................
1256
+ /**
1257
+ * Subscribe to scheduler events.
1258
+ * @param event - Event name
1259
+ * @param cb - Callback
1260
+ */
1261
+ on(event, cb) {
1262
+ let set = this._listeners.get(event);
1263
+ if (!set) {
1264
+ set = /* @__PURE__ */ new Set();
1265
+ this._listeners.set(event, set);
1266
+ }
1267
+ set.add(cb);
1268
+ }
1269
+ /**
1270
+ * Unsubscribe from scheduler events.
1271
+ * @param event - Event name
1272
+ * @param cb - Callback
1273
+ */
1274
+ off(event, cb) {
1275
+ const set = this._listeners.get(event);
1276
+ /* v8 ignore if -- @preserve */
1277
+ if (!set) return;
1278
+ set.delete(cb);
1279
+ }
1280
+ // .........................................................................
1281
+ // Internal
1282
+ // .........................................................................
1283
+ /**
1284
+ * Run one probe cycle: probe all peers in parallel.
1285
+ */
1286
+ async _runCycle() {
1287
+ const peers = this._peers.filter((p) => p.nodeId !== this._selfId);
1288
+ if (peers.length === 0) {
1289
+ const empty = [];
1290
+ this._emit("probes-updated", empty);
1291
+ return empty;
1292
+ }
1293
+ const results = await Promise.all(
1294
+ peers.map((peer) => {
1295
+ const host = peer.localIps[0] ?? "127.0.0.1";
1296
+ return this._probeFn(host, peer.port, this._selfId, peer.nodeId, {
1297
+ timeoutMs: this._timeoutMs
1298
+ });
1299
+ })
1300
+ );
1301
+ for (const probe of results) {
1302
+ const previous = this._wasReachable.get(probe.toNodeId);
1303
+ this._probes.set(probe.toNodeId, probe);
1304
+ if (probe.reachable) {
1305
+ this._failCount.set(probe.toNodeId, 0);
1306
+ this._wasReachable.set(probe.toNodeId, true);
1307
+ if (previous !== void 0 && !previous) {
1308
+ this._emit("peer-reachable", probe.toNodeId, probe);
1309
+ }
1310
+ } else {
1311
+ const count = (this._failCount.get(probe.toNodeId) ?? 0) + 1;
1312
+ this._failCount.set(probe.toNodeId, count);
1313
+ if (count >= this._failThreshold) {
1314
+ this._wasReachable.set(probe.toNodeId, false);
1315
+ if (previous !== void 0 && previous) {
1316
+ this._emit("peer-unreachable", probe.toNodeId, probe);
1317
+ }
1318
+ /* v8 ignore else -- @preserve */
1319
+ } else if (previous === void 0) {
1320
+ this._wasReachable.set(probe.toNodeId, false);
1321
+ }
1322
+ }
1323
+ }
1324
+ this._emit("probes-updated", results);
1325
+ return results;
1326
+ }
1327
+ /**
1328
+ * Emit a typed event.
1329
+ * @param event - Event name
1330
+ * @param args - Event arguments
1331
+ */
1332
+ _emit(event, ...args) {
1333
+ const set = this._listeners.get(event);
1334
+ if (!set) return;
1335
+ for (const cb of set) {
1336
+ cb(...args);
1337
+ }
1338
+ }
1339
+ }
1340
+ // @license
1341
+ class NetworkManager {
1342
+ /**
1343
+ * Create a NetworkManager.
1344
+ * @param _config - Network configuration
1345
+ * @param options - Optional overrides (e.g. custom probe function)
1346
+ */
1347
+ constructor(_config, options) {
1348
+ this._config = _config;
1349
+ this._broadcastLayer = new BroadcastLayer(
1350
+ this._config.broadcast,
1351
+ options?.broadcastDeps
1352
+ );
1353
+ this._cloudLayer = new CloudLayer(this._config.cloud, options?.cloudDeps);
1354
+ this._staticLayer = new StaticLayer(this._config.static);
1355
+ const probingConfig = this._config.probing;
1356
+ this._probeScheduler = new ProbeScheduler({
1357
+ intervalMs: probingConfig?.intervalMs ?? 1e4,
1358
+ timeoutMs: probingConfig?.timeoutMs ?? 2e3,
1359
+ probeFn: options?.probeFn,
1360
+ failThreshold: options?.failThreshold
1361
+ });
1362
+ }
1363
+ _identity = null;
1364
+ _running = false;
1365
+ /** Always-present manual override layer */
1366
+ _manualLayer = new ManualLayer();
1367
+ /** Try 1: UDP broadcast discovery */
1368
+ _broadcastLayer;
1369
+ /** Try 2: Cloud discovery fallback */
1370
+ _cloudLayer;
1371
+ /** Try 3: Static config fallback */
1372
+ _staticLayer;
1373
+ /** Merged peer table */
1374
+ _peerTable = new PeerTable();
1375
+ /** Probe scheduler for reachability checking */
1376
+ _probeScheduler;
1377
+ /** Event listeners */
1378
+ _listeners = /* @__PURE__ */ new Map();
1379
+ /** Current topology snapshot */
1380
+ _currentHubId = null;
1381
+ _currentRole = "unassigned";
1382
+ _formedBy = "static";
1383
+ // .........................................................................
1384
+ // Lifecycle
1385
+ // .........................................................................
1386
+ /**
1387
+ * Start the network manager.
1388
+ *
1389
+ * Creates node identity, starts all layers, attaches to peer table,
1390
+ * and performs initial hub computation.
1391
+ */
1392
+ async start() {
1393
+ if (this._running) return;
1394
+ this._identity = await NodeIdentity.create({
1395
+ domain: this._config.domain,
1396
+ port: this._config.port,
1397
+ identityDir: this._config.identityDir
1398
+ });
1399
+ this._peerTable.setSelfId(this._identity.nodeId);
1400
+ this._peerTable.attachLayer(this._manualLayer);
1401
+ this._peerTable.attachLayer(this._broadcastLayer);
1402
+ this._peerTable.attachLayer(this._cloudLayer);
1403
+ this._peerTable.attachLayer(this._staticLayer);
1404
+ this._peerTable.on("peer-joined", (peer) => {
1405
+ this._emit("peer-joined", peer);
1406
+ this._probeScheduler.setPeers(this._peerTable.getPeers());
1407
+ this._recomputeTopology();
1408
+ });
1409
+ this._peerTable.on("peer-left", (nodeId) => {
1410
+ this._emit("peer-left", nodeId);
1411
+ this._probeScheduler.setPeers(this._peerTable.getPeers());
1412
+ this._recomputeTopology();
1413
+ });
1414
+ this._manualLayer.on("hub-assigned", () => {
1415
+ this._recomputeTopology();
1416
+ });
1417
+ /* v8 ignore next -- @preserve */
1418
+ this._broadcastLayer.on("hub-assigned", () => this._recomputeTopology());
1419
+ this._cloudLayer.on("hub-assigned", () => {
1420
+ this._recomputeTopology();
1421
+ });
1422
+ this._staticLayer.on("hub-assigned", () => {
1423
+ this._recomputeTopology();
1424
+ });
1425
+ this._probeScheduler.on("probes-updated", () => {
1426
+ this._recomputeTopology();
1427
+ });
1428
+ await this._manualLayer.start(this._identity);
1429
+ await this._broadcastLayer.start(this._identity);
1430
+ await this._cloudLayer.start(this._identity);
1431
+ await this._staticLayer.start(this._identity);
1432
+ const probingEnabled = this._config.probing?.enabled !== false;
1433
+ if (probingEnabled) {
1434
+ this._probeScheduler.setPeers(this._peerTable.getPeers());
1435
+ this._probeScheduler.start(this._identity.nodeId);
1436
+ }
1437
+ this._running = true;
1438
+ this._recomputeTopology();
1439
+ }
1440
+ /**
1441
+ * Stop the network manager.
1442
+ *
1443
+ * Stops all layers and clears state.
1444
+ */
1445
+ async stop() {
1446
+ if (!this._running) return;
1447
+ this._probeScheduler.stop();
1448
+ await this._manualLayer.stop();
1449
+ await this._broadcastLayer.stop();
1450
+ await this._cloudLayer.stop();
1451
+ await this._staticLayer.stop();
1452
+ this._peerTable.clear();
1453
+ this._listeners.clear();
1454
+ this._currentHubId = null;
1455
+ this._currentRole = "unassigned";
1456
+ this._running = false;
1457
+ }
1458
+ /** Whether the manager is currently running */
1459
+ isRunning() {
1460
+ return this._running;
1461
+ }
1462
+ // .........................................................................
1463
+ // Topology access
1464
+ // .........................................................................
1465
+ /**
1466
+ * Get the current topology snapshot.
1467
+ * @returns The current network topology
1468
+ */
1469
+ getTopology() {
1470
+ const nodes = /* @__PURE__ */ new Map();
1471
+ for (const peer of this._peerTable.getPeers()) {
1472
+ nodes.set(peer.nodeId, peer);
1473
+ }
1474
+ /* v8 ignore else -- @preserve */
1475
+ if (this._identity) {
1476
+ const selfInfo = this._identity.toNodeInfo();
1477
+ nodes.set(selfInfo.nodeId, selfInfo);
1478
+ }
1479
+ return {
1480
+ domain: this._config.domain,
1481
+ hubNodeId: this._currentHubId,
1482
+ hubAddress: this._resolveHubAddress(),
1483
+ formedBy: this._formedBy,
1484
+ formedAt: Date.now(),
1485
+ nodes: Object.fromEntries(nodes),
1486
+ probes: this._probeScheduler.getProbes(),
1487
+ myRole: this._currentRole
1488
+ };
1489
+ }
1490
+ /**
1491
+ * Get the probe scheduler for direct access to probe results.
1492
+ * @returns The ProbeScheduler instance
1493
+ */
1494
+ getProbeScheduler() {
1495
+ return this._probeScheduler;
1496
+ }
1497
+ /**
1498
+ * Get this node's identity.
1499
+ * Throws if called before start().
1500
+ */
1501
+ getIdentity() {
1502
+ /* v8 ignore if -- @preserve */
1503
+ if (!this._identity) {
1504
+ throw new Error("NetworkManager not started");
1505
+ }
1506
+ return this._identity;
1507
+ }
1508
+ // .........................................................................
1509
+ // Manual override
1510
+ // .........................................................................
1511
+ /**
1512
+ * Manually assign a hub node, overriding the cascade.
1513
+ * @param nodeId - The node to designate as hub
1514
+ */
1515
+ assignHub(nodeId) {
1516
+ this._manualLayer.assignHub(nodeId);
1517
+ }
1518
+ /**
1519
+ * Clear the manual hub override, returning to cascade logic.
1520
+ */
1521
+ clearOverride() {
1522
+ this._manualLayer.clearOverride();
1523
+ }
1524
+ // .........................................................................
1525
+ // Events
1526
+ // .........................................................................
1527
+ /**
1528
+ * Subscribe to network manager events.
1529
+ * @param event - Event name
1530
+ * @param cb - Callback
1531
+ */
1532
+ on(event, cb) {
1533
+ let set = this._listeners.get(event);
1534
+ if (!set) {
1535
+ set = /* @__PURE__ */ new Set();
1536
+ this._listeners.set(event, set);
1537
+ }
1538
+ set.add(cb);
1539
+ }
1540
+ /**
1541
+ * Unsubscribe from network manager events.
1542
+ * @param event - Event name
1543
+ * @param cb - Callback
1544
+ */
1545
+ off(event, cb) {
1546
+ const set = this._listeners.get(event);
1547
+ /* v8 ignore if -- @preserve */
1548
+ if (!set) return;
1549
+ set.delete(cb);
1550
+ }
1551
+ // .........................................................................
1552
+ // Internal
1553
+ // .........................................................................
1554
+ /**
1555
+ * Compute the hub using the fallback cascade.
1556
+ *
1557
+ * Priority:
1558
+ * 1. Manual override (human knows best)
1559
+ * 2. Election among probed peers (most autonomous)
1560
+ * - formedBy 'broadcast' if broadcast layer provided peers
1561
+ * - formedBy 'election' otherwise
1562
+ * 3. Cloud assignment (sees full picture, dictates hub)
1563
+ * 4. Static config (last resort)
1564
+ * 5. Nothing → unassigned
1565
+ */
1566
+ _computeHub() {
1567
+ const manualHub = this._manualLayer.getAssignedHub();
1568
+ if (manualHub) {
1569
+ return { hubId: manualHub, formedBy: "manual" };
1570
+ }
1571
+ const probes = this._probeScheduler.getProbes();
1572
+ if (probes.length > 0 && this._identity) {
1573
+ const candidates = [
1574
+ this._identity.toNodeInfo(),
1575
+ ...this._peerTable.getPeers()
1576
+ ];
1577
+ const result = electHub(
1578
+ candidates,
1579
+ probes,
1580
+ this._currentHubId,
1581
+ this._identity.nodeId
1582
+ );
1583
+ /* v8 ignore else -- @preserve */
1584
+ if (result.hubId) {
1585
+ const formedBy = this._broadcastLayer.isActive() && this._broadcastLayer.getPeers().length > 0 ? "broadcast" : "election";
1586
+ return { hubId: result.hubId, formedBy };
1587
+ }
1588
+ }
1589
+ if (this._cloudLayer.isActive()) {
1590
+ const cloudHub = this._cloudLayer.getAssignedHub();
1591
+ if (cloudHub) {
1592
+ return { hubId: cloudHub, formedBy: "cloud" };
1593
+ }
1594
+ }
1595
+ if (this._staticLayer.isActive()) {
1596
+ const staticHub = this._staticLayer.getAssignedHub();
1597
+ /* v8 ignore else -- @preserve */
1598
+ if (staticHub) {
1599
+ return { hubId: staticHub, formedBy: "static" };
1600
+ }
1601
+ }
1602
+ return { hubId: null, formedBy: "static" };
1603
+ }
1604
+ /**
1605
+ * Recompute topology and emit events if anything changed.
1606
+ */
1607
+ _recomputeTopology() {
1608
+ const { hubId, formedBy } = this._computeHub();
1609
+ const previousHub = this._currentHubId;
1610
+ const previousRole = this._currentRole;
1611
+ this._currentHubId = hubId;
1612
+ this._formedBy = formedBy;
1613
+ if (!hubId) {
1614
+ this._currentRole = "unassigned";
1615
+ } else if (this._identity && hubId === this._identity.nodeId) {
1616
+ this._currentRole = "hub";
1617
+ } else {
1618
+ this._currentRole = "client";
1619
+ }
1620
+ if (previousHub !== this._currentHubId) {
1621
+ this._emit("hub-changed", {
1622
+ previousHub,
1623
+ currentHub: this._currentHubId
1624
+ });
1625
+ }
1626
+ if (previousRole !== this._currentRole) {
1627
+ this._emit("role-changed", {
1628
+ previous: previousRole,
1629
+ current: this._currentRole
1630
+ });
1631
+ }
1632
+ this._emit("topology-changed", {
1633
+ topology: this.getTopology()
1634
+ });
1635
+ }
1636
+ /**
1637
+ * Resolve the hub address ("ip:port") from the current hub.
1638
+ * Uses static config's hubAddress if the hub is from static layer.
1639
+ */
1640
+ _resolveHubAddress() {
1641
+ if (!this._currentHubId) return null;
1642
+ if (this._formedBy === "static" && this._staticLayer.getHubAddress()) {
1643
+ return this._staticLayer.getHubAddress();
1644
+ }
1645
+ const peer = this._peerTable.getPeer(this._currentHubId);
1646
+ if (peer) {
1647
+ /* v8 ignore next -- @preserve */
1648
+ const ip = peer.localIps[0] ?? "unknown";
1649
+ return `${ip}:${peer.port}`;
1650
+ }
1651
+ return null;
1652
+ }
1653
+ /**
1654
+ * Emit a typed event.
1655
+ * @param event - Event name
1656
+ * @param args - Event arguments
1657
+ */
1658
+ _emit(event, ...args) {
1659
+ const set = this._listeners.get(event);
1660
+ if (!set) return;
1661
+ for (const cb of set) {
1662
+ cb(...args);
1663
+ }
1664
+ }
1665
+ }
1666
+ export {
1667
+ BroadcastLayer,
1668
+ CloudLayer,
1669
+ ManualLayer,
1670
+ NetworkManager,
1671
+ NodeIdentity,
1672
+ PeerTable,
1673
+ ProbeScheduler,
1674
+ StaticLayer,
1675
+ defaultCreateCloudHttpClient,
1676
+ defaultCreateUdpSocket,
1677
+ defaultNetworkConfig,
1678
+ defaultNodeIdentityDeps,
1679
+ electHub,
1680
+ exampleHubChangedEvent,
1681
+ exampleNetworkTopology,
1682
+ exampleNodeInfo,
1683
+ examplePeerProbe,
1684
+ exampleRoleChangedEvent,
1685
+ exampleTopologyChangedEvent,
1686
+ formedByValues,
1687
+ networkEventNames,
1688
+ nodeRoles,
1689
+ parseLocalIps,
1690
+ probePeer
1691
+ };