@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
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@license
|
|
3
|
+
Copyright (c) 2025 Rljson
|
|
4
|
+
|
|
5
|
+
Use of this source code is governed by terms that can be
|
|
6
|
+
found in the LICENSE file in the root of this package.
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
# @rljson/network
|
|
10
|
+
|
|
11
|
+
Self-organizing network topology for the RLJSON ecosystem. Handles peer
|
|
12
|
+
discovery, hub election, and topology formation — enabling nodes to
|
|
13
|
+
automatically form star-topology networks without pre-assigned roles or
|
|
14
|
+
hardcoded IPs.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @rljson/network
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Key Concepts
|
|
23
|
+
|
|
24
|
+
| Concept | Description |
|
|
25
|
+
| ---------------- | --------------------------------------------------------------- |
|
|
26
|
+
| **Domain** | Groups nodes that should discover each other (not a DNS domain) |
|
|
27
|
+
| **Hub** | Central node elected automatically — all others are clients |
|
|
28
|
+
| **Discovery** | Fallback cascade: Broadcast → Cloud → Static + Manual override |
|
|
29
|
+
| **Hub Election** | Incumbent advantage + earliest `startedAt` timestamp wins |
|
|
30
|
+
|
|
31
|
+
## Types
|
|
32
|
+
|
|
33
|
+
### NodeInfo
|
|
34
|
+
|
|
35
|
+
Describes a node in the network:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import type { NodeInfo } from '@rljson/network';
|
|
39
|
+
|
|
40
|
+
const node: NodeInfo = {
|
|
41
|
+
nodeId: 'a1b2c3d4-...', // Persistent UUID
|
|
42
|
+
hostname: 'WORKSTATION-7', // os.hostname()
|
|
43
|
+
localIps: ['192.168.1.42'], // Non-internal IPv4 addresses
|
|
44
|
+
domain: 'office-sync', // Network group
|
|
45
|
+
port: 3000, // Listen port when hub
|
|
46
|
+
startedAt: 1741123456789, // Startup timestamp
|
|
47
|
+
};
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### PeerProbe
|
|
51
|
+
|
|
52
|
+
Result of probing a peer's reachability:
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import type { PeerProbe } from '@rljson/network';
|
|
56
|
+
|
|
57
|
+
const probe: PeerProbe = {
|
|
58
|
+
fromNodeId: 'node-a',
|
|
59
|
+
toNodeId: 'node-b',
|
|
60
|
+
reachable: true,
|
|
61
|
+
latencyMs: 12,
|
|
62
|
+
measuredAt: 1741123456800,
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### NetworkTopology
|
|
67
|
+
|
|
68
|
+
Snapshot of the current network layout:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import type { NetworkTopology } from '@rljson/network';
|
|
72
|
+
|
|
73
|
+
const topo: NetworkTopology = {
|
|
74
|
+
domain: 'office-sync',
|
|
75
|
+
hubNodeId: 'a1b2c3d4-...',
|
|
76
|
+
hubAddress: '192.168.1.42:3000',
|
|
77
|
+
formedBy: 'broadcast', // 'broadcast' | 'cloud' | 'election' | 'static' | 'manual'
|
|
78
|
+
formedAt: 1741123456800,
|
|
79
|
+
nodes: { /* NodeInfo by nodeId */ },
|
|
80
|
+
probes: [ /* PeerProbe[] */ ],
|
|
81
|
+
myRole: 'hub', // 'hub' | 'client' | 'unassigned'
|
|
82
|
+
};
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### NetworkConfig
|
|
86
|
+
|
|
87
|
+
Configuration for the discovery layers:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { defaultNetworkConfig } from '@rljson/network';
|
|
91
|
+
|
|
92
|
+
// Quick start — broadcast enabled, cloud/static off
|
|
93
|
+
const config = defaultNetworkConfig('office-sync', 3000);
|
|
94
|
+
|
|
95
|
+
// Full configuration
|
|
96
|
+
import type { NetworkConfig } from '@rljson/network';
|
|
97
|
+
|
|
98
|
+
const fullConfig: NetworkConfig = {
|
|
99
|
+
domain: 'office-sync',
|
|
100
|
+
port: 3000,
|
|
101
|
+
identityDir: '/opt/myapp/identity', // Default: ~/.rljson-network/
|
|
102
|
+
broadcast: { enabled: true, port: 41234, intervalMs: 5000 },
|
|
103
|
+
cloud: { enabled: true, endpoint: 'https://cloud.example.com', apiKey: '...' },
|
|
104
|
+
static: { hubAddress: '192.168.1.100:3000' },
|
|
105
|
+
probing: { enabled: true, intervalMs: 10000, timeoutMs: 2000 },
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Network Events
|
|
110
|
+
|
|
111
|
+
Events emitted by the network manager:
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
import type { NetworkEventMap } from '@rljson/network';
|
|
115
|
+
import { networkEventNames } from '@rljson/network';
|
|
116
|
+
|
|
117
|
+
// Event names: 'topology-changed', 'role-changed', 'hub-changed',
|
|
118
|
+
// 'peer-joined', 'peer-left'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## NodeIdentity
|
|
122
|
+
|
|
123
|
+
Persistent node identity — generates a UUID on first run, reads the same
|
|
124
|
+
UUID on subsequent runs (same machine = same identity):
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { NodeIdentity } from '@rljson/network';
|
|
128
|
+
|
|
129
|
+
const identity = await NodeIdentity.create({
|
|
130
|
+
domain: 'office-sync',
|
|
131
|
+
port: 3000,
|
|
132
|
+
// identityDir: '/custom/path', // Default: ~/.rljson-network/
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
console.log(identity.nodeId); // Persistent UUID
|
|
136
|
+
console.log(identity.hostname); // Machine hostname
|
|
137
|
+
console.log(identity.localIps); // ['192.168.1.42']
|
|
138
|
+
|
|
139
|
+
const info = identity.toNodeInfo(); // Plain NodeInfo object
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Discovery Layers
|
|
143
|
+
|
|
144
|
+
All layers implement the `DiscoveryLayer` interface:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
import type { DiscoveryLayer } from '@rljson/network';
|
|
148
|
+
// Methods: start(), stop(), isActive(), getPeers(), getAssignedHub(), on(), off()
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### ManualLayer
|
|
152
|
+
|
|
153
|
+
Always-present manual override. Cannot be disabled:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { ManualLayer } from '@rljson/network';
|
|
157
|
+
|
|
158
|
+
const manual = new ManualLayer();
|
|
159
|
+
await manual.start(identity);
|
|
160
|
+
|
|
161
|
+
manual.assignHub('specific-node-id'); // Force a hub
|
|
162
|
+
manual.clearOverride(); // Return to cascade
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### BroadcastLayer (Try 1)
|
|
166
|
+
|
|
167
|
+
UDP broadcast discovery — zero-config LAN peer detection:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { BroadcastLayer, defaultCreateUdpSocket } from '@rljson/network';
|
|
171
|
+
import type { BroadcastConfig, BroadcastLayerDeps } from '@rljson/network';
|
|
172
|
+
|
|
173
|
+
const config: BroadcastConfig = {
|
|
174
|
+
enabled: true,
|
|
175
|
+
port: 41234,
|
|
176
|
+
intervalMs: 5000, // How often to broadcast
|
|
177
|
+
timeoutMs: 15000, // Remove peer after silence
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const layer = new BroadcastLayer(config);
|
|
181
|
+
const started = await layer.start(identity);
|
|
182
|
+
// started = true if UDP broadcast is available (self-test passed)
|
|
183
|
+
// started = false if broadcast blocked, disabled, or bind failed
|
|
184
|
+
|
|
185
|
+
layer.on('peer-discovered', (peer) => console.log('Found:', peer.nodeId));
|
|
186
|
+
layer.on('peer-lost', (nodeId) => console.log('Lost:', nodeId));
|
|
187
|
+
|
|
188
|
+
console.log(layer.getPeers()); // Currently discovered peers
|
|
189
|
+
console.log(layer.getAssignedHub()); // Always null (broadcast doesn't assign)
|
|
190
|
+
|
|
191
|
+
await layer.stop();
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
For testing, inject a mock socket factory:
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import type { UdpSocket, CreateUdpSocket } from '@rljson/network';
|
|
198
|
+
|
|
199
|
+
const deps: BroadcastLayerDeps = {
|
|
200
|
+
createSocket: myMockSocketFactory,
|
|
201
|
+
selfTestTimeoutMs: 50,
|
|
202
|
+
};
|
|
203
|
+
const layer = new BroadcastLayer(config, deps);
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### CloudLayer (Try 2)
|
|
207
|
+
|
|
208
|
+
REST-based cloud discovery fallback. The cloud service has the full picture
|
|
209
|
+
and **dictates** the hub (unlike broadcast, which uses local election):
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { CloudLayer, defaultCreateCloudHttpClient } from '@rljson/network';
|
|
213
|
+
import type { CloudConfig, CloudLayerDeps, CloudHttpClient } from '@rljson/network';
|
|
214
|
+
|
|
215
|
+
const config: CloudConfig = {
|
|
216
|
+
enabled: true,
|
|
217
|
+
endpoint: 'https://cloud.example.com',
|
|
218
|
+
apiKey: 'my-api-key', // Optional Bearer token
|
|
219
|
+
pollIntervalMs: 30000, // How often to poll (default: 30000)
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const layer = new CloudLayer(config);
|
|
223
|
+
const started = await layer.start(identity);
|
|
224
|
+
// started = true if enabled + endpoint present + registration succeeded
|
|
225
|
+
// started = false if disabled or no endpoint
|
|
226
|
+
|
|
227
|
+
layer.on('peer-discovered', (peer) => console.log('Cloud peer:', peer.nodeId));
|
|
228
|
+
layer.on('hub-assigned', (hubId) => console.log('Cloud assigned hub:', hubId));
|
|
229
|
+
|
|
230
|
+
console.log(layer.getPeers()); // Peers from cloud
|
|
231
|
+
console.log(layer.getAssignedHub()); // Hub dictated by cloud, or null
|
|
232
|
+
|
|
233
|
+
// Report local probe results to cloud (cloud uses these for hub decisions)
|
|
234
|
+
await layer.reportProbes(probes);
|
|
235
|
+
|
|
236
|
+
await layer.stop();
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
For testing, inject a mock HTTP client:
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
import type { CloudHttpClient, CloudLayerDeps } from '@rljson/network';
|
|
243
|
+
|
|
244
|
+
const mockClient: CloudHttpClient = {
|
|
245
|
+
register: async () => ({ peers: [], assignedHub: null }),
|
|
246
|
+
poll: async () => ({ peers: [], assignedHub: null }),
|
|
247
|
+
reportProbes: async () => {},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const deps: CloudLayerDeps = { createHttpClient: () => mockClient };
|
|
251
|
+
const layer = new CloudLayer(config, deps);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### StaticLayer
|
|
255
|
+
|
|
256
|
+
Last-resort fallback — reads a hardcoded hub address from config:
|
|
257
|
+
|
|
258
|
+
```typescript
|
|
259
|
+
import { StaticLayer } from '@rljson/network';
|
|
260
|
+
|
|
261
|
+
const staticLayer = new StaticLayer({ hubAddress: '192.168.1.100:3000' });
|
|
262
|
+
const started = await staticLayer.start(identity);
|
|
263
|
+
// started = true (has config), creates synthetic peer for hub
|
|
264
|
+
|
|
265
|
+
const noConfig = new StaticLayer();
|
|
266
|
+
const started2 = await noConfig.start(identity);
|
|
267
|
+
// started2 = false (no hubAddress configured)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## PeerTable
|
|
271
|
+
|
|
272
|
+
Merged view of all peers from all discovery layers. Deduplicates by nodeId:
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { PeerTable } from '@rljson/network';
|
|
276
|
+
|
|
277
|
+
const table = new PeerTable();
|
|
278
|
+
table.setSelfId(identity.nodeId);
|
|
279
|
+
|
|
280
|
+
table.attachLayer(staticLayer); // Import peers + subscribe to events
|
|
281
|
+
table.attachLayer(manualLayer);
|
|
282
|
+
|
|
283
|
+
table.on('peer-joined', (peer) => console.log('New peer:', peer.nodeId));
|
|
284
|
+
table.on('peer-left', (nodeId) => console.log('Lost peer:', nodeId));
|
|
285
|
+
|
|
286
|
+
console.log(table.getPeers()); // All known peers
|
|
287
|
+
console.log(table.size); // Number of peers
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## NetworkManager
|
|
291
|
+
|
|
292
|
+
Central orchestrator — starts layers, merges peer tables, applies cascade
|
|
293
|
+
logic, and emits topology events:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
import { NetworkManager, defaultNetworkConfig } from '@rljson/network';
|
|
297
|
+
|
|
298
|
+
const config = {
|
|
299
|
+
...defaultNetworkConfig('office-sync', 3000),
|
|
300
|
+
static: { hubAddress: '192.168.1.100:3000' },
|
|
301
|
+
};
|
|
302
|
+
const manager = new NetworkManager(config);
|
|
303
|
+
|
|
304
|
+
manager.on('topology-changed', (e) => {
|
|
305
|
+
console.log('Topology:', e.topology.myRole, e.topology.formedBy);
|
|
306
|
+
});
|
|
307
|
+
manager.on('role-changed', (e) => {
|
|
308
|
+
console.log(`Role: ${e.previous} → ${e.current}`);
|
|
309
|
+
});
|
|
310
|
+
manager.on('hub-changed', (e) => {
|
|
311
|
+
console.log(`Hub: ${e.previousHub} → ${e.currentHub}`);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await manager.start();
|
|
315
|
+
|
|
316
|
+
const topology = manager.getTopology();
|
|
317
|
+
// { myRole: 'client', formedBy: 'static', hubAddress: '192.168.1.100:3000', ... }
|
|
318
|
+
|
|
319
|
+
// Manual override supersedes cascade
|
|
320
|
+
manager.assignHub('custom-hub-id');
|
|
321
|
+
// Now formedBy: 'manual'
|
|
322
|
+
|
|
323
|
+
// Revert to cascade
|
|
324
|
+
manager.clearOverride();
|
|
325
|
+
// Back to formedBy: 'static'
|
|
326
|
+
|
|
327
|
+
await manager.stop();
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Hub Decision Cascade
|
|
331
|
+
|
|
332
|
+
The `NetworkManager` evaluates hub assignment in this order:
|
|
333
|
+
|
|
334
|
+
1. **Manual override** → human knows best
|
|
335
|
+
2. **Election via probing** → probes determine reachable peers, election picks hub
|
|
336
|
+
- `formedBy: 'broadcast'` when broadcast layer is active with peers
|
|
337
|
+
- `formedBy: 'election'` otherwise
|
|
338
|
+
3. **Cloud assignment** → cloud dictates hub (has the full picture)
|
|
339
|
+
4. **Static config** → last resort
|
|
340
|
+
5. **Nothing** → `myRole = 'unassigned'`
|
|
341
|
+
|
|
342
|
+
## Hub Election
|
|
343
|
+
|
|
344
|
+
Pure deterministic election algorithm — no I/O, no side effects:
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { electHub } from '@rljson/network';
|
|
348
|
+
import type { ElectionResult } from '@rljson/network';
|
|
349
|
+
|
|
350
|
+
const candidates = [
|
|
351
|
+
{ nodeId: 'node-a', host: '10.0.0.1', port: 3000, ... startedAt: 1000 },
|
|
352
|
+
{ nodeId: 'node-b', host: '10.0.0.2', port: 3000, ... startedAt: 900 },
|
|
353
|
+
];
|
|
354
|
+
const probes = [
|
|
355
|
+
{ fromNodeId: 'self', toNodeId: 'node-a', reachable: true, latencyMs: 5, measuredAt: Date.now() },
|
|
356
|
+
{ fromNodeId: 'self', toNodeId: 'node-b', reachable: true, latencyMs: 3, measuredAt: Date.now() },
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
const result: ElectionResult = electHub(candidates, probes, null, 'self');
|
|
360
|
+
// result.hubId = 'node-b' (earliest startedAt)
|
|
361
|
+
// result.reason = 'earliest-start'
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Election rules:
|
|
365
|
+
1. Filter candidates to reachable peers only (self is always reachable)
|
|
366
|
+
2. **Incumbent advantage**: keep current hub if still reachable
|
|
367
|
+
3. **Earliest `startedAt`** wins among reachable candidates
|
|
368
|
+
4. **Tiebreaker**: lexicographic `nodeId` comparison
|
|
369
|
+
|
|
370
|
+
## Probing
|
|
371
|
+
|
|
372
|
+
### PeerProber
|
|
373
|
+
|
|
374
|
+
Real TCP connect probe — tests whether a peer is reachable:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
import { probePeer } from '@rljson/network';
|
|
378
|
+
|
|
379
|
+
const probe = await probePeer('192.168.1.42', 3000, 'my-node', 'peer-node');
|
|
380
|
+
// { reachable: true, latencyMs: 2.45, fromNodeId: 'my-node', toNodeId: 'peer-node', ... }
|
|
381
|
+
|
|
382
|
+
// With custom timeout
|
|
383
|
+
const probe2 = await probePeer('10.0.0.1', 3000, 'my-node', 'remote', { timeoutMs: 500 });
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### ProbeScheduler
|
|
387
|
+
|
|
388
|
+
Periodically probes all known peers and detects state changes:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
import { ProbeScheduler } from '@rljson/network';
|
|
392
|
+
|
|
393
|
+
const scheduler = new ProbeScheduler({
|
|
394
|
+
intervalMs: 5000,
|
|
395
|
+
timeoutMs: 2000,
|
|
396
|
+
failThreshold: 3, // Consecutive failures before 'unreachable' (default: 3)
|
|
397
|
+
});
|
|
398
|
+
scheduler.start('my-node-id');
|
|
399
|
+
scheduler.setPeers([peerA, peerB]); // NodeInfo[]
|
|
400
|
+
|
|
401
|
+
scheduler.on('probes-updated', (probes) => {
|
|
402
|
+
console.log('All probe results:', probes);
|
|
403
|
+
});
|
|
404
|
+
scheduler.on('peer-unreachable', (nodeId, probe) => {
|
|
405
|
+
console.log(`${nodeId} went down!`);
|
|
406
|
+
});
|
|
407
|
+
scheduler.on('peer-reachable', (nodeId, probe) => {
|
|
408
|
+
console.log(`${nodeId} came back!`);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Manual single cycle (useful for tests)
|
|
412
|
+
const results = await scheduler.runOnce();
|
|
413
|
+
|
|
414
|
+
scheduler.stop();
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The `NetworkManager` creates and manages a `ProbeScheduler` internally.
|
|
418
|
+
Access it via `manager.getProbeScheduler()` for advanced use.
|
|
419
|
+
|
|
420
|
+
**Flap dampening**: A peer must fail `failThreshold` consecutive probes
|
|
421
|
+
(default: 3) before being declared unreachable. A single success resets
|
|
422
|
+
the counter immediately. This prevents flapping on transient network glitches.
|
|
423
|
+
|
|
424
|
+
## Example
|
|
425
|
+
|
|
426
|
+
[src/example.ts](src/example.ts)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@license
|
|
3
|
+
Copyright (c) 2025 Rljson
|
|
4
|
+
|
|
5
|
+
Use of this source code is governed by terms that can be
|
|
6
|
+
found in the LICENSE file in the root of this package.
|
|
7
|
+
-->
|
|
8
|
+
|
|
9
|
+
# Trouble shooting
|
|
10
|
+
|
|
11
|
+
## Table of contents <!-- omit in toc -->
|
|
12
|
+
|
|
13
|
+
- [Vscode Windows: Debugging is not working](#vscode-windows-debugging-is-not-working)
|
|
14
|
+
|
|
15
|
+
## Vscode Windows: Debugging is not working
|
|
16
|
+
|
|
17
|
+
Date: 2025-03-08
|
|
18
|
+
|
|
19
|
+
⚠️ IMPORTANT: On Windows, please check out the repo on drive C. There is a bug
|
|
20
|
+
in the VS Code Vitest extension (v1.14.4), which prevents test debugging from
|
|
21
|
+
working: <https://github.com/vitest-dev/vscode/issues/548> Please check from
|
|
22
|
+
time to time if the issue has been fixed and remove this note once it is
|
|
23
|
+
resolved.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NodeId, NodeInfo } from '../types/node-info.ts';
|
|
2
|
+
import { PeerProbe } from '../types/peer-probe.ts';
|
|
3
|
+
/** Result of a hub election */
|
|
4
|
+
export interface ElectionResult {
|
|
5
|
+
/** The elected hub's nodeId, or null if no candidate qualifies */
|
|
6
|
+
hubId: NodeId | null;
|
|
7
|
+
/** Why this hub was chosen */
|
|
8
|
+
reason: ElectionReason;
|
|
9
|
+
}
|
|
10
|
+
/** Reason for the election outcome */
|
|
11
|
+
export type ElectionReason = 'incumbent' | 'earliest-start' | 'tiebreaker' | 'no-candidates';
|
|
12
|
+
/**
|
|
13
|
+
* Deterministic hub election algorithm.
|
|
14
|
+
*
|
|
15
|
+
* Rules (in priority order):
|
|
16
|
+
* 1. Filter to reachable peers only (those with a passing probe)
|
|
17
|
+
* 2. Incumbent advantage — if the current hub is reachable, keep it
|
|
18
|
+
* 3. Earliest `startedAt` wins
|
|
19
|
+
* 4. Lexicographic `nodeId` tiebreaker (astronomically rare)
|
|
20
|
+
*
|
|
21
|
+
* This is a pure function — no I/O, no side effects.
|
|
22
|
+
* @param candidates - All known peers (including self)
|
|
23
|
+
* @param probes - Latest probe results for reachability
|
|
24
|
+
* @param currentHubId - The current hub's nodeId (null if none)
|
|
25
|
+
* @param selfId - This node's own nodeId (always considered reachable)
|
|
26
|
+
* @returns The election result with hubId and reason
|
|
27
|
+
*/
|
|
28
|
+
export declare function electHub(candidates: NodeInfo[], probes: PeerProbe[], currentHubId: NodeId | null, selfId: NodeId): ElectionResult;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const example: () => Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NetworkInterfaceInfo } from 'node:os';
|
|
2
|
+
import { NodeInfo } from '../types/node-info.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Parse IPv4 non-internal addresses from network interface data.
|
|
5
|
+
* @param interfaces - OS network interface data (from os.networkInterfaces())
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseLocalIps(interfaces: NodeJS.Dict<NetworkInterfaceInfo[]>): string[];
|
|
8
|
+
/** Injectable dependencies for NodeIdentity (testing) */
|
|
9
|
+
export interface NodeIdentityDeps {
|
|
10
|
+
readNodeId: (filePath: string) => Promise<string | null>;
|
|
11
|
+
writeNodeId: (filePath: string, nodeId: string) => Promise<void>;
|
|
12
|
+
hostname: () => string;
|
|
13
|
+
localIps: () => string[];
|
|
14
|
+
randomUUID: () => string;
|
|
15
|
+
now: () => number;
|
|
16
|
+
homedir: () => string;
|
|
17
|
+
}
|
|
18
|
+
/** Default deps using real Node.js APIs */
|
|
19
|
+
export declare function defaultNodeIdentityDeps(): NodeIdentityDeps;
|
|
20
|
+
/** Options for creating a NodeIdentity */
|
|
21
|
+
export interface CreateNodeIdentityOptions {
|
|
22
|
+
/** Network domain — which group of nodes discover each other */
|
|
23
|
+
domain: string;
|
|
24
|
+
/** Port this node listens on when hub */
|
|
25
|
+
port: number;
|
|
26
|
+
/** Where to persist nodeId (default: ~/.rljson-network/) */
|
|
27
|
+
identityDir?: string;
|
|
28
|
+
/** Override dependencies for testing */
|
|
29
|
+
deps?: Partial<NodeIdentityDeps>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Represents this node's identity in the network.
|
|
33
|
+
*
|
|
34
|
+
* On first run, generates a persistent UUID stored on disk.
|
|
35
|
+
* On subsequent runs, reads the same UUID — same machine = same identity.
|
|
36
|
+
*/
|
|
37
|
+
export declare class NodeIdentity {
|
|
38
|
+
readonly nodeId: string;
|
|
39
|
+
readonly hostname: string;
|
|
40
|
+
readonly localIps: string[];
|
|
41
|
+
readonly domain: string;
|
|
42
|
+
readonly port: number;
|
|
43
|
+
readonly startedAt: number;
|
|
44
|
+
constructor(info: NodeInfo);
|
|
45
|
+
/** Convert to a plain NodeInfo data object */
|
|
46
|
+
toNodeInfo(): NodeInfo;
|
|
47
|
+
/**
|
|
48
|
+
* Create a NodeIdentity, loading or generating a persistent UUID.
|
|
49
|
+
* @param options - Configuration and optional dependency overrides
|
|
50
|
+
*/
|
|
51
|
+
static create(options: CreateNodeIdentityOptions): Promise<NodeIdentity>;
|
|
52
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type { NodeId, NodeInfo } from './types/node-info.ts';
|
|
2
|
+
export { exampleNodeInfo } from './types/node-info.ts';
|
|
3
|
+
export type { PeerProbe } from './types/peer-probe.ts';
|
|
4
|
+
export { examplePeerProbe } from './types/peer-probe.ts';
|
|
5
|
+
export type { NodeRole, FormedBy, NetworkTopology, } from './types/network-topology.ts';
|
|
6
|
+
export { nodeRoles, formedByValues, exampleNetworkTopology, } from './types/network-topology.ts';
|
|
7
|
+
export type { BroadcastConfig, CloudConfig, StaticConfig, ProbingConfig, NetworkConfig, } from './types/network-config.ts';
|
|
8
|
+
export { defaultNetworkConfig } from './types/network-config.ts';
|
|
9
|
+
export type { TopologyChangedEvent, RoleChangedEvent, HubChangedEvent, NetworkEventMap, NetworkEventName, } from './types/network-events.ts';
|
|
10
|
+
export { networkEventNames, exampleTopologyChangedEvent, exampleRoleChangedEvent, exampleHubChangedEvent, } from './types/network-events.ts';
|
|
11
|
+
export type { NodeIdentityDeps, CreateNodeIdentityOptions, } from './identity/node-identity.ts';
|
|
12
|
+
export { NodeIdentity, parseLocalIps, defaultNodeIdentityDeps, } from './identity/node-identity.ts';
|
|
13
|
+
export type { DiscoveryLayer, DiscoveryLayerEvents, DiscoveryLayerEventName, } from './layers/discovery-layer.ts';
|
|
14
|
+
export type { UdpSocket, RemoteInfo, CreateUdpSocket, BroadcastLayerDeps, } from './layers/broadcast-layer.ts';
|
|
15
|
+
export { BroadcastLayer, defaultCreateUdpSocket, } from './layers/broadcast-layer.ts';
|
|
16
|
+
export type { CloudHttpClient, CloudPeerListResponse, CreateCloudHttpClient, CloudLayerDeps, } from './layers/cloud-layer.ts';
|
|
17
|
+
export { CloudLayer, defaultCreateCloudHttpClient, } from './layers/cloud-layer.ts';
|
|
18
|
+
export { ManualLayer } from './layers/manual-layer.ts';
|
|
19
|
+
export { StaticLayer } from './layers/static-layer.ts';
|
|
20
|
+
export type { PeerTableEvents } from './peer-table.ts';
|
|
21
|
+
export { PeerTable } from './peer-table.ts';
|
|
22
|
+
export type { ElectionResult, ElectionReason, } from './election/hub-election.ts';
|
|
23
|
+
export { electHub } from './election/hub-election.ts';
|
|
24
|
+
export type { ProbeOptions } from './probing/peer-prober.ts';
|
|
25
|
+
export { probePeer } from './probing/peer-prober.ts';
|
|
26
|
+
export type { ProbeFn, ProbeSchedulerEvents, ProbeSchedulerEventName, ProbeSchedulerOptions, } from './probing/probe-scheduler.ts';
|
|
27
|
+
export { ProbeScheduler } from './probing/probe-scheduler.ts';
|
|
28
|
+
export type { NetworkManagerEvents, NetworkManagerEventName, NetworkManagerOptions, } from './network-manager.ts';
|
|
29
|
+
export { NetworkManager } from './network-manager.ts';
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { NodeId, NodeInfo } from '../types/node-info.ts';
|
|
2
|
+
import { BroadcastConfig } from '../types/network-config.ts';
|
|
3
|
+
import { NodeIdentity } from '../identity/node-identity.ts';
|
|
4
|
+
import { DiscoveryLayer, DiscoveryLayerEventName, DiscoveryLayerEvents } from './discovery-layer.ts';
|
|
5
|
+
/** Remote info about a received UDP packet */
|
|
6
|
+
export interface RemoteInfo {
|
|
7
|
+
address: string;
|
|
8
|
+
port: number;
|
|
9
|
+
}
|
|
10
|
+
/** Abstraction over a UDP socket for testability */
|
|
11
|
+
export interface UdpSocket {
|
|
12
|
+
/** Bind the socket to a port */
|
|
13
|
+
bind(port: number): Promise<void>;
|
|
14
|
+
/** Send data via UDP to a target port and address */
|
|
15
|
+
send(data: Buffer, port: number, address: string): Promise<void>;
|
|
16
|
+
/** Register a message handler */
|
|
17
|
+
onMessage(handler: (msg: Buffer, rinfo: RemoteInfo) => void): void;
|
|
18
|
+
/** Enable broadcasting on this socket */
|
|
19
|
+
setBroadcast(flag: boolean): void;
|
|
20
|
+
/** Close the socket */
|
|
21
|
+
close(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** Factory function to create a UDP socket */
|
|
24
|
+
export type CreateUdpSocket = () => UdpSocket;
|
|
25
|
+
/**
|
|
26
|
+
* Create a real UDP socket using node:dgram.
|
|
27
|
+
* @returns A UdpSocket backed by a real dgram socket
|
|
28
|
+
*/
|
|
29
|
+
export declare function defaultCreateUdpSocket(): UdpSocket;
|
|
30
|
+
/** Injectable dependencies for BroadcastLayer (testing) */
|
|
31
|
+
export interface BroadcastLayerDeps {
|
|
32
|
+
/** Custom socket factory — defaults to real dgram */
|
|
33
|
+
createSocket?: CreateUdpSocket;
|
|
34
|
+
/** Self-test timeout in ms — defaults to 2000 */
|
|
35
|
+
selfTestTimeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* UDP broadcast discovery layer — primary automatic discovery (Try 1).
|
|
39
|
+
*
|
|
40
|
+
* Periodically broadcasts this node's info as a JSON packet on a configurable
|
|
41
|
+
* UDP port. Listens for other nodes' broadcasts and maintains a peer table
|
|
42
|
+
* with timeout-based cleanup.
|
|
43
|
+
*
|
|
44
|
+
* On startup, performs a self-test by sending a broadcast and checking for
|
|
45
|
+
* loopback reception. If broadcast is blocked on the network, start() returns
|
|
46
|
+
* false and the NetworkManager falls through to the next layer.
|
|
47
|
+
*
|
|
48
|
+
* BroadcastLayer does NOT assign a hub — it only discovers peers.
|
|
49
|
+
* Hub election is handled by the NetworkManager via the election algorithm.
|
|
50
|
+
*/
|
|
51
|
+
export declare class BroadcastLayer implements DiscoveryLayer {
|
|
52
|
+
private readonly _config?;
|
|
53
|
+
readonly name = "broadcast";
|
|
54
|
+
private _active;
|
|
55
|
+
private _socket;
|
|
56
|
+
private _identity;
|
|
57
|
+
private _broadcastTimer;
|
|
58
|
+
private _timeoutTimer;
|
|
59
|
+
private _selfTestCallback;
|
|
60
|
+
private _peers;
|
|
61
|
+
private _listeners;
|
|
62
|
+
private readonly _createSocket;
|
|
63
|
+
private readonly _selfTestTimeoutMs;
|
|
64
|
+
/**
|
|
65
|
+
* Create a BroadcastLayer.
|
|
66
|
+
* @param _config - Broadcast configuration (port, interval, timeout)
|
|
67
|
+
* @param deps - Injectable dependencies for testing
|
|
68
|
+
*/
|
|
69
|
+
constructor(_config?: BroadcastConfig | undefined, deps?: BroadcastLayerDeps);
|
|
70
|
+
/**
|
|
71
|
+
* Start the broadcast layer.
|
|
72
|
+
*
|
|
73
|
+
* 1. Bind UDP socket to configured port
|
|
74
|
+
* 2. Set up message handler
|
|
75
|
+
* 3. Perform self-test (send broadcast, listen for loopback)
|
|
76
|
+
* 4. If self-test passes: start periodic broadcasting + timeout checker
|
|
77
|
+
* @param identity - This node's identity
|
|
78
|
+
* @returns true if broadcast is available, false otherwise
|
|
79
|
+
*/
|
|
80
|
+
start(identity: NodeIdentity): Promise<boolean>;
|
|
81
|
+
/** Stop the layer and clean up resources */
|
|
82
|
+
stop(): Promise<void>;
|
|
83
|
+
/** Whether this layer is currently active */
|
|
84
|
+
isActive(): boolean;
|
|
85
|
+
/** Get all currently known peers from broadcast discovery */
|
|
86
|
+
getPeers(): NodeInfo[];
|
|
87
|
+
/**
|
|
88
|
+
* Broadcast does NOT assign a hub — hub is elected by NetworkManager.
|
|
89
|
+
* Always returns null.
|
|
90
|
+
*/
|
|
91
|
+
getAssignedHub(): NodeId | null;
|
|
92
|
+
/**
|
|
93
|
+
* Subscribe to layer events.
|
|
94
|
+
* @param event - Event name
|
|
95
|
+
* @param cb - Callback
|
|
96
|
+
*/
|
|
97
|
+
on<E extends DiscoveryLayerEventName>(event: E, cb: DiscoveryLayerEvents[E]): void;
|
|
98
|
+
/**
|
|
99
|
+
* Unsubscribe from layer events.
|
|
100
|
+
* @param event - Event name
|
|
101
|
+
* @param cb - Callback
|
|
102
|
+
*/
|
|
103
|
+
off<E extends DiscoveryLayerEventName>(event: E, cb: DiscoveryLayerEvents[E]): void;
|
|
104
|
+
/**
|
|
105
|
+
* Self-test: send a broadcast and listen for loopback reception.
|
|
106
|
+
* @param port - The UDP port to broadcast on
|
|
107
|
+
* @returns true if own packet was received, false on timeout
|
|
108
|
+
*/
|
|
109
|
+
private _selfTest;
|
|
110
|
+
/**
|
|
111
|
+
* Send a broadcast packet containing this node's info.
|
|
112
|
+
* @param port - The UDP port to broadcast on
|
|
113
|
+
*/
|
|
114
|
+
private _sendBroadcast;
|
|
115
|
+
/**
|
|
116
|
+
* Handle an incoming broadcast message.
|
|
117
|
+
* @param msg - Raw UDP message
|
|
118
|
+
* @param _rinfo - Remote address info (unused — we use packet content)
|
|
119
|
+
*/
|
|
120
|
+
private _handleMessage;
|
|
121
|
+
/**
|
|
122
|
+
* Check for timed-out peers and remove them.
|
|
123
|
+
* @param timeoutMs - Maximum silence period before declaring peer lost
|
|
124
|
+
*/
|
|
125
|
+
private _checkTimeouts;
|
|
126
|
+
/**
|
|
127
|
+
* Emit a typed event to all registered listeners.
|
|
128
|
+
* @param event - Event name
|
|
129
|
+
* @param args - Event arguments
|
|
130
|
+
*/
|
|
131
|
+
private _emit;
|
|
132
|
+
}
|