@rljson/network 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.