@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,79 @@
1
+ import { NodeId, NodeInfo } from './types/node-info.ts';
2
+ import { DiscoveryLayer } from './layers/discovery-layer.ts';
3
+ /** Events emitted by PeerTable */
4
+ export interface PeerTableEvents {
5
+ 'peer-joined': (peer: NodeInfo) => void;
6
+ 'peer-left': (nodeId: string) => void;
7
+ }
8
+ type PeerTableEventName = keyof PeerTableEvents;
9
+ /**
10
+ * Merged view of all peers from all discovery layers.
11
+ *
12
+ * Deduplicates by nodeId — a peer known by multiple layers appears once.
13
+ * Emits `peer-joined`/`peer-left` when the merged set changes.
14
+ */
15
+ export declare class PeerTable {
16
+ /** All known peers, keyed by nodeId */
17
+ private _peers;
18
+ /** Per-layer peer sets, for deduplication tracking */
19
+ private _layerPeers;
20
+ /** Event listeners */
21
+ private _listeners;
22
+ /** Self nodeId — excluded from the peer table */
23
+ private _selfId;
24
+ /**
25
+ * Set the self nodeId so it's excluded from the peer table.
26
+ * @param nodeId - This node's own ID
27
+ */
28
+ setSelfId(nodeId: NodeId): void;
29
+ /**
30
+ * Attach a discovery layer — subscribes to its peer events.
31
+ * Also imports any peers the layer already knows about.
32
+ * @param layer - The discovery layer to attach
33
+ */
34
+ attachLayer(layer: DiscoveryLayer): void;
35
+ /** Get all known peers as an array */
36
+ getPeers(): NodeInfo[];
37
+ /**
38
+ * Get a specific peer by nodeId.
39
+ * @param nodeId - The peer's nodeId
40
+ */
41
+ getPeer(nodeId: NodeId): NodeInfo | undefined;
42
+ /** Get the number of known peers */
43
+ get size(): number;
44
+ /** Clear all peers and layer tracking */
45
+ clear(): void;
46
+ /**
47
+ * Subscribe to peer table events.
48
+ * @param event - Event name
49
+ * @param cb - Callback
50
+ */
51
+ on<E extends PeerTableEventName>(event: E, cb: PeerTableEvents[E]): void;
52
+ /**
53
+ * Unsubscribe from peer table events.
54
+ * @param event - Event name
55
+ * @param cb - Callback
56
+ */
57
+ off<E extends PeerTableEventName>(event: E, cb: PeerTableEvents[E]): void;
58
+ /**
59
+ * Add a peer from a specific layer.
60
+ * Only emits peer-joined if this is a genuinely new peer.
61
+ * @param layerName - Name of the source layer
62
+ * @param peer - The peer to add
63
+ */
64
+ private _addPeerFromLayer;
65
+ /**
66
+ * Remove a peer from a specific layer.
67
+ * Only emits peer-left if no other layer still knows about this peer.
68
+ * @param layerName - Name of the source layer
69
+ * @param nodeId - The peer's nodeId
70
+ */
71
+ private _removePeerFromLayer;
72
+ /**
73
+ * Emit a typed event.
74
+ * @param event - Event name
75
+ * @param args - Event arguments
76
+ */
77
+ private _emit;
78
+ }
79
+ export {};
@@ -0,0 +1,21 @@
1
+ import { PeerProbe } from '../types/peer-probe.ts';
2
+ import { NodeId } from '../types/node-info.ts';
3
+ /** Options for a single probe */
4
+ export interface ProbeOptions {
5
+ /** TCP connect timeout in ms (default: 2000) */
6
+ timeoutMs?: number;
7
+ }
8
+ /**
9
+ * Probes a peer's reachability via real TCP connect.
10
+ *
11
+ * Opens a TCP socket to host:port, measures the time until the
12
+ * connection is established (or fails), then closes the socket.
13
+ * This is the same technique used by `tcping` and similar tools.
14
+ * @param host - The peer's IP address or hostname
15
+ * @param port - The peer's port
16
+ * @param fromNodeId - This node's ID (for the probe result)
17
+ * @param toNodeId - The peer's node ID (for the probe result)
18
+ * @param options - Probe options (timeout, etc.)
19
+ * @returns A PeerProbe with reachability and latency
20
+ */
21
+ export declare function probePeer(host: string, port: number, fromNodeId: NodeId, toNodeId: NodeId, options?: ProbeOptions): Promise<PeerProbe>;
@@ -0,0 +1,119 @@
1
+ import { NodeId, NodeInfo } from '../types/node-info.ts';
2
+ import { PeerProbe } from '../types/peer-probe.ts';
3
+ import { ProbeOptions } from './peer-prober.ts';
4
+ /** Function signature for probing a single peer */
5
+ export type ProbeFn = (host: string, port: number, fromNodeId: NodeId, toNodeId: NodeId, options?: ProbeOptions) => Promise<PeerProbe>;
6
+ /** Events emitted by ProbeScheduler */
7
+ export interface ProbeSchedulerEvents {
8
+ /** Emitted after a full probe cycle completes */
9
+ 'probes-updated': (probes: PeerProbe[]) => void;
10
+ /** Emitted when a peer becomes unreachable */
11
+ 'peer-unreachable': (nodeId: NodeId, probe: PeerProbe) => void;
12
+ /** Emitted when a peer becomes reachable (again) */
13
+ 'peer-reachable': (nodeId: NodeId, probe: PeerProbe) => void;
14
+ }
15
+ /** Valid event names for ProbeScheduler */
16
+ export type ProbeSchedulerEventName = keyof ProbeSchedulerEvents;
17
+ /** Options for ProbeScheduler constructor */
18
+ export interface ProbeSchedulerOptions {
19
+ /** Interval between probe cycles in ms (default: 10000) */
20
+ intervalMs?: number;
21
+ /** Timeout per individual probe in ms (default: 2000) */
22
+ timeoutMs?: number;
23
+ /** Custom probe function — real TCP by default */
24
+ probeFn?: ProbeFn;
25
+ /**
26
+ * Number of consecutive probe failures before declaring a peer
27
+ * unreachable (default: 3). Prevents flapping on transient failures.
28
+ * A single success resets the counter immediately.
29
+ */
30
+ failThreshold?: number;
31
+ }
32
+ /**
33
+ * Periodically probes all known peers for reachability.
34
+ *
35
+ * Uses real TCP connect probes by default, but accepts an injectable
36
+ * probe function for unit testing.
37
+ *
38
+ * Emits events when probe results change (peer went down / came back up).
39
+ */
40
+ export declare class ProbeScheduler {
41
+ private _intervalMs;
42
+ private _timeoutMs;
43
+ private _failThreshold;
44
+ private _selfId;
45
+ private _running;
46
+ private _timer;
47
+ /** Latest probe results, keyed by toNodeId */
48
+ private _probes;
49
+ /** Previous reachability state, for change detection */
50
+ private _wasReachable;
51
+ /** Consecutive failure count per peer, for flap dampening */
52
+ private _failCount;
53
+ /** Event listeners */
54
+ private _listeners;
55
+ /** The probe function — real TCP by default, injectable for tests */
56
+ private readonly _probeFn;
57
+ /** Peers to probe — updated externally via setPeers() */
58
+ private _peers;
59
+ /**
60
+ * Create a ProbeScheduler.
61
+ * @param options - Configuration options
62
+ */
63
+ constructor(options?: ProbeSchedulerOptions);
64
+ /**
65
+ * Start the scheduler.
66
+ * @param selfId - This node's ID (excluded from probing)
67
+ */
68
+ start(selfId: NodeId): void;
69
+ /**
70
+ * Schedule the next probe cycle using setTimeout.
71
+ * Chaining (instead of setInterval) prevents overlapping cycles.
72
+ */
73
+ private _scheduleNext;
74
+ /** Stop the scheduler and clear state */
75
+ stop(): void;
76
+ /** Whether the scheduler is currently running */
77
+ isRunning(): boolean;
78
+ /**
79
+ * Update the list of peers to probe.
80
+ * Call this when the peer table changes.
81
+ * Self is automatically excluded at probe time.
82
+ * @param peers - The current peer list
83
+ */
84
+ setPeers(peers: NodeInfo[]): void;
85
+ /** Get all latest probe results */
86
+ getProbes(): PeerProbe[];
87
+ /**
88
+ * Get the latest probe result for a specific peer.
89
+ * @param nodeId - The peer's nodeId
90
+ */
91
+ getProbe(nodeId: NodeId): PeerProbe | undefined;
92
+ /**
93
+ * Run a single probe cycle manually.
94
+ * Useful for tests that need immediate results without waiting.
95
+ */
96
+ runOnce(): Promise<PeerProbe[]>;
97
+ /**
98
+ * Subscribe to scheduler events.
99
+ * @param event - Event name
100
+ * @param cb - Callback
101
+ */
102
+ on<E extends ProbeSchedulerEventName>(event: E, cb: ProbeSchedulerEvents[E]): void;
103
+ /**
104
+ * Unsubscribe from scheduler events.
105
+ * @param event - Event name
106
+ * @param cb - Callback
107
+ */
108
+ off<E extends ProbeSchedulerEventName>(event: E, cb: ProbeSchedulerEvents[E]): void;
109
+ /**
110
+ * Run one probe cycle: probe all peers in parallel.
111
+ */
112
+ private _runCycle;
113
+ /**
114
+ * Emit a typed event.
115
+ * @param event - Event name
116
+ * @param args - Event arguments
117
+ */
118
+ private _emit;
119
+ }
@@ -0,0 +1,105 @@
1
+ // @license
2
+ // Copyright (c) 2025 Rljson
3
+ //
4
+ // Use of this source code is governed by terms that can be
5
+ // found in the LICENSE file in the root of this package.
6
+
7
+ import { exampleNodeInfo } from './types/node-info.ts';
8
+ import type { NodeInfo } from './types/node-info.ts';
9
+ import { examplePeerProbe } from './types/peer-probe.ts';
10
+ import { exampleNetworkTopology } from './types/network-topology.ts';
11
+ import { defaultNetworkConfig } from './types/network-config.ts';
12
+ import { NetworkManager } from './network-manager.ts';
13
+ import { electHub } from './election/hub-election.ts';
14
+
15
+ export const example = async () => {
16
+ const l = console.log;
17
+ const h1 = (text: string) => l(`${text}`);
18
+ const h2 = (text: string) => l(` ${text}`);
19
+ const p = (text: string) => l(` ${text}`);
20
+
21
+ h1('NodeInfo');
22
+ h2('Describes a node in the network');
23
+ p(JSON.stringify(exampleNodeInfo, null, 2));
24
+
25
+ h1('PeerProbe');
26
+ h2('Result of probing a peer');
27
+ p(JSON.stringify(examplePeerProbe, null, 2));
28
+
29
+ h1('NetworkTopology');
30
+ h2('Snapshot of the current network topology');
31
+ p(JSON.stringify(exampleNetworkTopology, null, 2));
32
+
33
+ h1('NetworkConfig');
34
+ h2('Default configuration with broadcast enabled');
35
+ p(JSON.stringify(defaultNetworkConfig('office-sync', 3000), null, 2));
36
+
37
+ h1('HubElection');
38
+ h2('Deterministic hub election from candidates + probes');
39
+ const candidates: NodeInfo[] = [
40
+ {
41
+ nodeId: 'node-a',
42
+ hostname: 'ws-a',
43
+ localIps: ['10.0.0.1'],
44
+ domain: 'test',
45
+ port: 3000,
46
+ startedAt: 1000,
47
+ },
48
+ {
49
+ nodeId: 'node-b',
50
+ hostname: 'ws-b',
51
+ localIps: ['10.0.0.2'],
52
+ domain: 'test',
53
+ port: 3000,
54
+ startedAt: 900,
55
+ },
56
+ {
57
+ nodeId: 'node-c',
58
+ hostname: 'ws-c',
59
+ localIps: ['10.0.0.3'],
60
+ domain: 'test',
61
+ port: 3000,
62
+ startedAt: 1100,
63
+ },
64
+ ];
65
+ const probes = [
66
+ { ...examplePeerProbe, toNodeId: 'node-a', reachable: true },
67
+ { ...examplePeerProbe, toNodeId: 'node-b', reachable: true },
68
+ { ...examplePeerProbe, toNodeId: 'node-c', reachable: false },
69
+ ];
70
+ const result = electHub(candidates, probes, null, 'node-a');
71
+ p(`Winner: ${result.hubId}, reason: ${result.reason}`);
72
+ p('(node-b wins: earliest startedAt among reachable peers)');
73
+
74
+ h1('NetworkManager');
75
+ h2('Start with static hub → manual override → revert');
76
+
77
+ const config = {
78
+ ...defaultNetworkConfig('office-sync', 3000),
79
+ static: { hubAddress: '192.168.1.100:3000' },
80
+ };
81
+ const manager = new NetworkManager(config);
82
+
83
+ manager.on('role-changed', (e) => {
84
+ p(`Role changed: ${e.previous} → ${e.current}`);
85
+ });
86
+
87
+ await manager.start();
88
+ p(
89
+ `Topology: role=${manager.getTopology().myRole}, formedBy=${manager.getTopology().formedBy}`,
90
+ );
91
+
92
+ manager.assignHub('custom-hub');
93
+ p(`After manual override: formedBy=${manager.getTopology().formedBy}`);
94
+
95
+ manager.clearOverride();
96
+ p(`After clearing override: formedBy=${manager.getTopology().formedBy}`);
97
+
98
+ await manager.stop();
99
+ p('Manager stopped');
100
+ };
101
+
102
+ /*
103
+ // Run via "npx vite-node src/example.ts"
104
+ example();
105
+ */
@@ -0,0 +1,63 @@
1
+ /** Configuration for the UDP broadcast discovery layer (Try 1) */
2
+ export interface BroadcastConfig {
3
+ /** Whether broadcast discovery is enabled (default: true) */
4
+ enabled: boolean;
5
+ /** UDP port for announcements (default: 41234) */
6
+ port: number;
7
+ /** How often to announce in ms (default: 5000) */
8
+ intervalMs?: number;
9
+ /** Remove peer after N ms silence (default: 15000) */
10
+ timeoutMs?: number;
11
+ }
12
+ /** Configuration for the cloud discovery layer (Try 2) */
13
+ export interface CloudConfig {
14
+ /** Whether cloud discovery is enabled (default: false) */
15
+ enabled: boolean;
16
+ /** Cloud service URL */
17
+ endpoint: string;
18
+ /** Authentication key */
19
+ apiKey?: string;
20
+ /** How often to poll in ms (default: 30000) */
21
+ pollIntervalMs?: number;
22
+ /** Maximum backoff interval when cloud is unreachable in ms (default: 300000 = 5 min) */
23
+ maxBackoffMs?: number;
24
+ /** Attempt re-registration after this many consecutive poll failures (default: 10) */
25
+ reRegisterAfterFailures?: number;
26
+ }
27
+ /** Configuration for the static discovery layer (Try 3) */
28
+ export interface StaticConfig {
29
+ /** Hardcoded hub address — "ip:port" */
30
+ hubAddress?: string;
31
+ }
32
+ /** Configuration for peer probing */
33
+ export interface ProbingConfig {
34
+ /** Whether probing is enabled (default: true) */
35
+ enabled: boolean;
36
+ /** How often to probe all peers in ms (default: 10000) */
37
+ intervalMs?: number;
38
+ /** Probe timeout in ms (default: 2000) */
39
+ timeoutMs?: number;
40
+ }
41
+ /** Full network configuration */
42
+ export interface NetworkConfig {
43
+ /** Network domain — which group of nodes discover each other */
44
+ domain: string;
45
+ /** Port this node listens on when hub */
46
+ port: number;
47
+ /** Where to persist nodeId (default: ~/.rljson-network/) */
48
+ identityDir?: string;
49
+ /** Try 1: Broadcast — primary automatic discovery */
50
+ broadcast?: BroadcastConfig;
51
+ /** Try 2: Cloud — first fallback (optional, must be explicitly configured) */
52
+ cloud?: CloudConfig;
53
+ /** Try 3: Static — last resort fallback (optional) */
54
+ static?: StaticConfig;
55
+ /** Peer probing configuration */
56
+ probing?: ProbingConfig;
57
+ }
58
+ /**
59
+ * Create a default NetworkConfig with broadcast enabled.
60
+ * @param domain - Network domain name
61
+ * @param port - Port this node listens on
62
+ */
63
+ export declare function defaultNetworkConfig(domain: string, port: number): NetworkConfig;
@@ -0,0 +1,33 @@
1
+ import { NodeInfo } from './node-info.ts';
2
+ import { NetworkTopology, NodeRole } from './network-topology.ts';
3
+ /** Emitted when the network topology changes */
4
+ export interface TopologyChangedEvent {
5
+ topology: NetworkTopology;
6
+ }
7
+ /** Emitted when this node's role changes */
8
+ export interface RoleChangedEvent {
9
+ previous: NodeRole;
10
+ current: NodeRole;
11
+ }
12
+ /** Emitted when the hub node changes */
13
+ export interface HubChangedEvent {
14
+ previousHub: string | null;
15
+ currentHub: string | null;
16
+ }
17
+ /** Map of all events emitted by NetworkManager */
18
+ export interface NetworkEventMap {
19
+ 'topology-changed': TopologyChangedEvent;
20
+ 'role-changed': RoleChangedEvent;
21
+ 'hub-changed': HubChangedEvent;
22
+ 'peer-joined': NodeInfo;
23
+ 'peer-left': string;
24
+ }
25
+ /** All valid network event names */
26
+ export declare const networkEventNames: readonly ["topology-changed", "role-changed", "hub-changed", "peer-joined", "peer-left"];
27
+ export type NetworkEventName = (typeof networkEventNames)[number];
28
+ /** Example TopologyChangedEvent for tests and documentation */
29
+ export declare const exampleTopologyChangedEvent: TopologyChangedEvent;
30
+ /** Example RoleChangedEvent for tests and documentation */
31
+ export declare const exampleRoleChangedEvent: RoleChangedEvent;
32
+ /** Example HubChangedEvent for tests and documentation */
33
+ export declare const exampleHubChangedEvent: HubChangedEvent;
@@ -0,0 +1,29 @@
1
+ import { NodeInfo } from './node-info.ts';
2
+ import { PeerProbe } from './peer-probe.ts';
3
+ /** Possible roles a node can have in the network */
4
+ export declare const nodeRoles: readonly ["hub", "client", "unassigned"];
5
+ export type NodeRole = (typeof nodeRoles)[number];
6
+ /** Which discovery layer formed the current topology */
7
+ export declare const formedByValues: readonly ["broadcast", "cloud", "election", "manual", "static"];
8
+ export type FormedBy = (typeof formedByValues)[number];
9
+ /** Snapshot of the current network topology */
10
+ export interface NetworkTopology {
11
+ /** Network domain */
12
+ domain: string;
13
+ /** NodeId of the current hub, or null if unassigned */
14
+ hubNodeId: string | null;
15
+ /** "ip:port" of the hub, ready to pass to Socket.IO */
16
+ hubAddress: string | null;
17
+ /** Which discovery layer produced this topology */
18
+ formedBy: FormedBy;
19
+ /** Timestamp when this topology was formed */
20
+ formedAt: number;
21
+ /** All known nodes, keyed by nodeId */
22
+ nodes: Record<string, NodeInfo>;
23
+ /** Latest probe results */
24
+ probes: PeerProbe[];
25
+ /** This node's role in the topology */
26
+ myRole: NodeRole;
27
+ }
28
+ /** Example NetworkTopology for tests and documentation */
29
+ export declare const exampleNetworkTopology: NetworkTopology;
@@ -0,0 +1,19 @@
1
+ /** Unique identifier for a node in the network */
2
+ export type NodeId = string;
3
+ /** Information about a node in the network */
4
+ export interface NodeInfo {
5
+ /** Persistent UUID, generated once, stored on disk */
6
+ nodeId: NodeId;
7
+ /** Machine name (os.hostname()) */
8
+ hostname: string;
9
+ /** All non-internal IPv4 addresses */
10
+ localIps: string[];
11
+ /** Network domain — which group of nodes discover each other */
12
+ domain: string;
13
+ /** Port this node listens on when hub */
14
+ port: number;
15
+ /** Timestamp of node start */
16
+ startedAt: number;
17
+ }
18
+ /** Example NodeInfo for tests and documentation */
19
+ export declare const exampleNodeInfo: NodeInfo;
@@ -0,0 +1,15 @@
1
+ /** Result of probing a peer's reachability */
2
+ export interface PeerProbe {
3
+ /** Node that initiated the probe */
4
+ fromNodeId: string;
5
+ /** Node that was probed */
6
+ toNodeId: string;
7
+ /** Whether the peer was reachable */
8
+ reachable: boolean;
9
+ /** TCP round-trip in ms, -1 if unreachable */
10
+ latencyMs: number;
11
+ /** Timestamp of measurement */
12
+ measuredAt: number;
13
+ }
14
+ /** Example PeerProbe for tests and documentation */
15
+ export declare const examplePeerProbe: PeerProbe;
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@rljson/network",
3
+ "version": "0.0.1",
4
+ "description": "Networking middle layer providing basic advertising and discovery as well as cloud fall backs and basic config.",
5
+ "homepage": "https://github.com/rljson/network",
6
+ "bugs": "https://github.com/rljson/network/issues",
7
+ "private": false,
8
+ "license": "MIT",
9
+ "engines": {
10
+ "node": ">=22.14.0"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/rljson/network.git"
15
+ },
16
+ "main": "dist/network.js",
17
+ "types": "dist/index.d.ts",
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "devDependencies": {
23
+ "@types/node": "^24.10.0",
24
+ "@typescript-eslint/eslint-plugin": "^8.46.3",
25
+ "@typescript-eslint/parser": "^8.46.3",
26
+ "@vitest/coverage-v8": "^4.0.6",
27
+ "cross-env": "^10.1.0",
28
+ "eslint": "~9.39.1",
29
+ "eslint-plugin-jsdoc": "^61.1.12",
30
+ "eslint-plugin-tsdoc": "^0.4.0",
31
+ "globals": "^16.5.0",
32
+ "jsdoc": "^4.0.5",
33
+ "read-pkg": "^9.0.1",
34
+ "typescript": "~5.9.3",
35
+ "typescript-eslint": "^8.46.3",
36
+ "vite": "^7.1.12",
37
+ "vite-node": "^3.2.4",
38
+ "vite-plugin-dts": "^4.5.4",
39
+ "vite-tsconfig-paths": "^5.1.4",
40
+ "vitest": "^4.0.6",
41
+ "vitest-dom": "^0.1.1"
42
+ },
43
+ "dependencies": {},
44
+ "scripts": {
45
+ "build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
46
+ "test": "pnpm exec vitest run --coverage && pnpm run lint",
47
+ "prebuild": "npm run test",
48
+ "lint": "pnpm exec eslint",
49
+ "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
50
+ }
51
+ }