@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.
- package/LICENSE +21 -0
- package/README.architecture.md +290 -0
- package/README.blog.md +11 -0
- package/README.contributors.md +32 -0
- package/README.md +24 -0
- package/README.public.md +426 -0
- package/README.trouble.md +23 -0
- package/dist/README.architecture.md +290 -0
- package/dist/README.blog.md +11 -0
- package/dist/README.contributors.md +32 -0
- package/dist/README.md +24 -0
- package/dist/README.public.md +426 -0
- package/dist/README.trouble.md +23 -0
- package/dist/election/hub-election.d.ts +28 -0
- package/dist/example.d.ts +1 -0
- package/dist/identity/node-identity.d.ts +52 -0
- package/dist/index.d.ts +29 -0
- package/dist/layers/broadcast-layer.d.ts +132 -0
- package/dist/layers/cloud-layer.d.ts +156 -0
- package/dist/layers/discovery-layer.d.ts +45 -0
- package/dist/layers/manual-layer.d.ts +56 -0
- package/dist/layers/static-layer.d.ts +61 -0
- package/dist/network-manager.d.ts +149 -0
- package/dist/network.js +1691 -0
- package/dist/peer-table.d.ts +79 -0
- package/dist/probing/peer-prober.d.ts +21 -0
- package/dist/probing/probe-scheduler.d.ts +119 -0
- package/dist/src/example.ts +105 -0
- package/dist/types/network-config.d.ts +63 -0
- package/dist/types/network-events.d.ts +33 -0
- package/dist/types/network-topology.d.ts +29 -0
- package/dist/types/node-info.d.ts +19 -0
- package/dist/types/peer-probe.d.ts +15 -0
- package/package.json +51 -0
package/dist/network.js
ADDED
|
@@ -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
|
+
};
|