cairn-p2p 0.2.0
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/README.md +43 -0
- package/dist/index.cjs +1883 -0
- package/dist/index.d.cts +572 -0
- package/dist/index.d.ts +572 -0
- package/dist/index.js +1827 -0
- package/eslint.config.js +24 -0
- package/package.json +54 -0
- package/src/channel.ts +277 -0
- package/src/config.ts +161 -0
- package/src/crypto/aead.ts +80 -0
- package/src/crypto/double-ratchet.ts +355 -0
- package/src/crypto/exchange.ts +51 -0
- package/src/crypto/hkdf.ts +33 -0
- package/src/crypto/identity.ts +84 -0
- package/src/crypto/index.ts +20 -0
- package/src/crypto/noise.ts +415 -0
- package/src/crypto/sas.ts +36 -0
- package/src/crypto/spake2.ts +169 -0
- package/src/discovery/index.ts +38 -0
- package/src/discovery/manager.ts +138 -0
- package/src/discovery/rendezvous.ts +189 -0
- package/src/discovery/tracker.ts +251 -0
- package/src/errors.ts +166 -0
- package/src/index.ts +57 -0
- package/src/mesh/index.ts +48 -0
- package/src/mesh/relay.ts +100 -0
- package/src/mesh/routing-table.ts +196 -0
- package/src/node.ts +619 -0
- package/src/pairing/adapter.ts +51 -0
- package/src/pairing/index.ts +40 -0
- package/src/pairing/link.ts +127 -0
- package/src/pairing/payload.ts +98 -0
- package/src/pairing/pin.ts +115 -0
- package/src/pairing/psk.ts +49 -0
- package/src/pairing/qr.ts +52 -0
- package/src/pairing/rate-limit.ts +134 -0
- package/src/pairing/sas-flow.ts +45 -0
- package/src/pairing/state-machine.ts +438 -0
- package/src/pairing/unpairing.ts +50 -0
- package/src/protocol/custom-handler.ts +52 -0
- package/src/protocol/envelope.ts +138 -0
- package/src/protocol/index.ts +36 -0
- package/src/protocol/message-types.ts +74 -0
- package/src/protocol/version.ts +98 -0
- package/src/server/index.ts +67 -0
- package/src/server/management.ts +285 -0
- package/src/server/store-forward.ts +266 -0
- package/src/session/backoff.ts +58 -0
- package/src/session/heartbeat.ts +79 -0
- package/src/session/index.ts +26 -0
- package/src/session/message-queue.ts +133 -0
- package/src/session/network-monitor.ts +130 -0
- package/src/session/state-machine.ts +122 -0
- package/src/session.ts +223 -0
- package/src/transport/fallback.ts +475 -0
- package/src/transport/index.ts +46 -0
- package/src/transport/libp2p-node.ts +158 -0
- package/src/transport/nat.ts +348 -0
- package/tests/conformance/cbor-vectors.test.ts +250 -0
- package/tests/integration/pairing-session.test.ts +317 -0
- package/tests/unit/config-api.test.ts +310 -0
- package/tests/unit/crypto.test.ts +407 -0
- package/tests/unit/discovery.test.ts +618 -0
- package/tests/unit/double-ratchet.test.ts +185 -0
- package/tests/unit/mesh.test.ts +349 -0
- package/tests/unit/noise.test.ts +346 -0
- package/tests/unit/pairing-extras.test.ts +402 -0
- package/tests/unit/pairing.test.ts +572 -0
- package/tests/unit/protocol.test.ts +438 -0
- package/tests/unit/reconnection.test.ts +402 -0
- package/tests/unit/scaffolding.test.ts +142 -0
- package/tests/unit/server.test.ts +492 -0
- package/tests/unit/sessions.test.ts +595 -0
- package/tests/unit/transport.test.ts +604 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recommended recovery action for a given error.
|
|
3
|
+
*/
|
|
4
|
+
export enum ErrorBehavior {
|
|
5
|
+
Retry = 'retry',
|
|
6
|
+
Reconnect = 'reconnect',
|
|
7
|
+
Abort = 'abort',
|
|
8
|
+
ReGenerate = 'regenerate',
|
|
9
|
+
Wait = 'wait',
|
|
10
|
+
Inform = 'inform',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Base error class for all cairn errors.
|
|
15
|
+
*
|
|
16
|
+
* Every error carries a machine-readable `code` and optional structured
|
|
17
|
+
* `details` for diagnostic context.
|
|
18
|
+
*/
|
|
19
|
+
export class CairnError extends Error {
|
|
20
|
+
readonly code: string;
|
|
21
|
+
readonly details?: Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
constructor(code: string, message: string, details?: Record<string, unknown>) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'CairnError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.details = details;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
errorBehavior(): ErrorBehavior {
|
|
31
|
+
return ErrorBehavior.Abort;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* All transports in the fallback chain failed.
|
|
37
|
+
*
|
|
38
|
+
* Details include per-transport failures and suggestions (e.g., deploy a
|
|
39
|
+
* signaling server and/or TURN relay).
|
|
40
|
+
*/
|
|
41
|
+
export class TransportExhaustedError extends CairnError {
|
|
42
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
43
|
+
super('TRANSPORT_EXHAUSTED', message, {
|
|
44
|
+
suggestion: 'deploy the cairn signaling server and/or TURN relay',
|
|
45
|
+
...details,
|
|
46
|
+
});
|
|
47
|
+
this.name = 'TransportExhaustedError';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
errorBehavior(): ErrorBehavior {
|
|
51
|
+
return ErrorBehavior.Retry;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Session exceeded the expiry window.
|
|
57
|
+
*
|
|
58
|
+
* Re-establishment via Noise XX handshake is needed; no re-pairing required.
|
|
59
|
+
*/
|
|
60
|
+
export class SessionExpiredError extends CairnError {
|
|
61
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
62
|
+
super('SESSION_EXPIRED', message, details);
|
|
63
|
+
this.name = 'SessionExpiredError';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
errorBehavior(): ErrorBehavior {
|
|
67
|
+
return ErrorBehavior.Reconnect;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Peer not found at any rendezvous point within timeout.
|
|
73
|
+
*/
|
|
74
|
+
export class PeerUnreachableError extends CairnError {
|
|
75
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
76
|
+
super('PEER_UNREACHABLE', message, details);
|
|
77
|
+
this.name = 'PeerUnreachableError';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
errorBehavior(): ErrorBehavior {
|
|
81
|
+
return ErrorBehavior.Wait;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Session resumption crypto verification failed.
|
|
87
|
+
*
|
|
88
|
+
* Possible key compromise — reject connection and alert application.
|
|
89
|
+
*/
|
|
90
|
+
export class AuthenticationFailedError extends CairnError {
|
|
91
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
92
|
+
super('AUTHENTICATION_FAILED', message, details);
|
|
93
|
+
this.name = 'AuthenticationFailedError';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
errorBehavior(): ErrorBehavior {
|
|
97
|
+
return ErrorBehavior.Abort;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remote peer rejected pairing request.
|
|
103
|
+
*/
|
|
104
|
+
export class PairingRejectedError extends CairnError {
|
|
105
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
106
|
+
super('PAIRING_REJECTED', message, details);
|
|
107
|
+
this.name = 'PairingRejectedError';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
errorBehavior(): ErrorBehavior {
|
|
111
|
+
return ErrorBehavior.Inform;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pairing payload (pin, QR, link) has expired.
|
|
117
|
+
*
|
|
118
|
+
* Generate a new payload to retry.
|
|
119
|
+
*/
|
|
120
|
+
export class PairingExpiredError extends CairnError {
|
|
121
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
122
|
+
super('PAIRING_EXPIRED', message, details);
|
|
123
|
+
this.name = 'PairingExpiredError';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
errorBehavior(): ErrorBehavior {
|
|
127
|
+
return ErrorBehavior.ReGenerate;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* No route to destination through mesh network.
|
|
133
|
+
*/
|
|
134
|
+
export class MeshRouteNotFoundError extends CairnError {
|
|
135
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
136
|
+
super('MESH_ROUTE_NOT_FOUND', message, {
|
|
137
|
+
suggestion: 'try a direct connection or wait for mesh route discovery',
|
|
138
|
+
...details,
|
|
139
|
+
});
|
|
140
|
+
this.name = 'MeshRouteNotFoundError';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
errorBehavior(): ErrorBehavior {
|
|
144
|
+
return ErrorBehavior.Wait;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* No common protocol version between peers.
|
|
150
|
+
*
|
|
151
|
+
* Details include the peer's supported version range so the application
|
|
152
|
+
* can inform the user which peer needs updating.
|
|
153
|
+
*/
|
|
154
|
+
export class VersionMismatchError extends CairnError {
|
|
155
|
+
constructor(message: string, details?: Record<string, unknown>) {
|
|
156
|
+
super('VERSION_MISMATCH', message, {
|
|
157
|
+
suggestion: 'peer needs to update to a compatible cairn version',
|
|
158
|
+
...details,
|
|
159
|
+
});
|
|
160
|
+
this.name = 'VersionMismatchError';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
errorBehavior(): ErrorBehavior {
|
|
164
|
+
return ErrorBehavior.Abort;
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Public API exports
|
|
2
|
+
|
|
3
|
+
// Node
|
|
4
|
+
export { Node, NodeSession, NodeChannel } from './node.js';
|
|
5
|
+
export type {
|
|
6
|
+
NodeEvents,
|
|
7
|
+
SessionEvents,
|
|
8
|
+
QrPairingData,
|
|
9
|
+
PinPairingData,
|
|
10
|
+
LinkPairingData,
|
|
11
|
+
ResolvedConfig,
|
|
12
|
+
} from './node.js';
|
|
13
|
+
|
|
14
|
+
// Configuration types
|
|
15
|
+
export type {
|
|
16
|
+
CairnConfig,
|
|
17
|
+
TurnServerConfig,
|
|
18
|
+
BackoffConfig,
|
|
19
|
+
ReconnectionPolicy,
|
|
20
|
+
MeshSettings,
|
|
21
|
+
StorageAdapter,
|
|
22
|
+
StorageBackend,
|
|
23
|
+
TransportType,
|
|
24
|
+
NatType,
|
|
25
|
+
ConnectionState,
|
|
26
|
+
CipherSuite,
|
|
27
|
+
PeerId,
|
|
28
|
+
} from './config.js';
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
DEFAULT_STUN_SERVERS,
|
|
32
|
+
DEFAULT_TRANSPORT_PREFERENCES,
|
|
33
|
+
DEFAULT_RECONNECTION_POLICY,
|
|
34
|
+
DEFAULT_MESH_SETTINGS,
|
|
35
|
+
} from './config.js';
|
|
36
|
+
|
|
37
|
+
// Error types
|
|
38
|
+
export {
|
|
39
|
+
ErrorBehavior,
|
|
40
|
+
CairnError,
|
|
41
|
+
TransportExhaustedError,
|
|
42
|
+
SessionExpiredError,
|
|
43
|
+
PeerUnreachableError,
|
|
44
|
+
AuthenticationFailedError,
|
|
45
|
+
PairingRejectedError,
|
|
46
|
+
PairingExpiredError,
|
|
47
|
+
MeshRouteNotFoundError,
|
|
48
|
+
VersionMismatchError,
|
|
49
|
+
} from './errors.js';
|
|
50
|
+
|
|
51
|
+
// Session state machine
|
|
52
|
+
export { SessionStateMachine, isValidTransition } from './session/index.js';
|
|
53
|
+
export type { StateChangedEvent, StateChangedListener } from './session/index.js';
|
|
54
|
+
|
|
55
|
+
// Server
|
|
56
|
+
export type { ServerConfig } from './server/index.js';
|
|
57
|
+
export { defaultServerConfig } from './server/index.js';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Mesh module — routing, relay, multi-hop networking
|
|
2
|
+
|
|
3
|
+
/** Mesh networking configuration (spec 9.4). */
|
|
4
|
+
export interface MeshConfig {
|
|
5
|
+
/** Enable/disable mesh routing. Default: false. */
|
|
6
|
+
meshEnabled: boolean;
|
|
7
|
+
/** Maximum relay hops allowed for any route. Default: 3. */
|
|
8
|
+
maxHops: number;
|
|
9
|
+
/** Whether this peer is willing to relay traffic for others. Default: false. */
|
|
10
|
+
relayWilling: boolean;
|
|
11
|
+
/** Maximum simultaneous relay connections this peer will serve. Default: 10. */
|
|
12
|
+
relayCapacity: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Default mesh config: disabled, 3 max hops, not willing, capacity 10. */
|
|
16
|
+
export function defaultMeshConfig(): MeshConfig {
|
|
17
|
+
return {
|
|
18
|
+
meshEnabled: false,
|
|
19
|
+
maxHops: 3,
|
|
20
|
+
relayWilling: false,
|
|
21
|
+
relayCapacity: 10,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Server-mode mesh config: enabled, willing, capacity 100. */
|
|
26
|
+
export function serverMeshConfig(): MeshConfig {
|
|
27
|
+
return {
|
|
28
|
+
meshEnabled: true,
|
|
29
|
+
maxHops: 3,
|
|
30
|
+
relayWilling: true,
|
|
31
|
+
relayCapacity: 100,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
Route,
|
|
37
|
+
ReachabilityEntry,
|
|
38
|
+
MeshTopologyUpdate,
|
|
39
|
+
} from './routing-table.js';
|
|
40
|
+
export {
|
|
41
|
+
RoutingTable,
|
|
42
|
+
directRoute,
|
|
43
|
+
relayedRoute,
|
|
44
|
+
hopCount,
|
|
45
|
+
} from './routing-table.js';
|
|
46
|
+
|
|
47
|
+
export type { RelaySessionId, RelaySession } from './relay.js';
|
|
48
|
+
export { RelayManager } from './relay.js';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Mesh relay forwarding (spec/09 section 9.3)
|
|
2
|
+
//
|
|
3
|
+
// Handles forwarding opaque encrypted bytes between peers. Relay peers
|
|
4
|
+
// cannot read, modify, or forge relayed content.
|
|
5
|
+
|
|
6
|
+
import { CairnError } from '../errors.js';
|
|
7
|
+
import type { MeshConfig } from './index.js';
|
|
8
|
+
|
|
9
|
+
/** Unique identifier for a relay session. */
|
|
10
|
+
export type RelaySessionId = number;
|
|
11
|
+
|
|
12
|
+
/** A relay session bridging two peers through this node. */
|
|
13
|
+
export interface RelaySession {
|
|
14
|
+
/** The unique session identifier. */
|
|
15
|
+
id: RelaySessionId;
|
|
16
|
+
/** The source peer ID (hex, requesting the relay). */
|
|
17
|
+
source: string;
|
|
18
|
+
/** The destination peer ID (hex, being relayed to). */
|
|
19
|
+
destination: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Manages relay sessions for this peer.
|
|
24
|
+
*
|
|
25
|
+
* Enforces relayWilling and relayCapacity from MeshConfig.
|
|
26
|
+
* Each relay session bridges two streams, forwarding opaque bytes.
|
|
27
|
+
*/
|
|
28
|
+
export class RelayManager {
|
|
29
|
+
private _config: MeshConfig;
|
|
30
|
+
private readonly _sessions = new Map<RelaySessionId, RelaySession>();
|
|
31
|
+
private _nextSessionId = 1;
|
|
32
|
+
|
|
33
|
+
constructor(config: MeshConfig) {
|
|
34
|
+
this._config = { ...config };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Request to start a new relay session.
|
|
39
|
+
*
|
|
40
|
+
* Validates that this peer is willing to relay, has capacity,
|
|
41
|
+
* and the destination is not the source.
|
|
42
|
+
*/
|
|
43
|
+
requestRelay(source: string, destination: string): RelaySessionId {
|
|
44
|
+
if (!this._config.meshEnabled) {
|
|
45
|
+
throw new CairnError('MESH_DISABLED', 'mesh routing disabled');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!this._config.relayWilling) {
|
|
49
|
+
throw new CairnError('RELAY_NOT_WILLING', 'relay not willing');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (this._sessions.size >= this._config.relayCapacity) {
|
|
53
|
+
throw new CairnError(
|
|
54
|
+
'RELAY_CAPACITY_FULL',
|
|
55
|
+
`relay capacity full (${this._sessions.size}/${this._config.relayCapacity})`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (source === destination) {
|
|
60
|
+
throw new CairnError(
|
|
61
|
+
'RELAY_CONNECTION_FAILED',
|
|
62
|
+
'source and destination are the same peer',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const id = this._nextSessionId++;
|
|
67
|
+
this._sessions.set(id, { id, source, destination });
|
|
68
|
+
return id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Close a relay session. Returns true if the session existed. */
|
|
72
|
+
closeSession(sessionId: RelaySessionId): boolean {
|
|
73
|
+
return this._sessions.delete(sessionId);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Get the number of active relay sessions. */
|
|
77
|
+
get activeSessionCount(): number {
|
|
78
|
+
return this._sessions.size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Get a relay session by ID. */
|
|
82
|
+
getSession(sessionId: RelaySessionId): RelaySession | undefined {
|
|
83
|
+
return this._sessions.get(sessionId);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Get the remaining relay capacity. */
|
|
87
|
+
get remainingCapacity(): number {
|
|
88
|
+
return Math.max(0, this._config.relayCapacity - this._sessions.size);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Check whether this peer is willing to relay. */
|
|
92
|
+
get isWilling(): boolean {
|
|
93
|
+
return this._config.relayWilling;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Update the mesh configuration. */
|
|
97
|
+
updateConfig(config: MeshConfig): void {
|
|
98
|
+
this._config = { ...config };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Mesh routing table and route selection (spec/09 section 9.2)
|
|
2
|
+
|
|
3
|
+
import { CairnError, MeshRouteNotFoundError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
/** A route to a destination peer, potentially through intermediate relay hops. */
|
|
6
|
+
export interface Route {
|
|
7
|
+
/** Ordered list of intermediate relay peer IDs (hex). Empty means direct. */
|
|
8
|
+
hops: string[];
|
|
9
|
+
/** Measured or estimated latency in milliseconds. */
|
|
10
|
+
latencyMs: number;
|
|
11
|
+
/** Estimated available bandwidth in bytes/sec. */
|
|
12
|
+
bandwidthBps: number;
|
|
13
|
+
/** When this route was last confirmed reachable (unix ms). */
|
|
14
|
+
lastSeen: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Create a direct route (zero hops). */
|
|
18
|
+
export function directRoute(latencyMs: number, bandwidthBps: number): Route {
|
|
19
|
+
return { hops: [], latencyMs, bandwidthBps, lastSeen: Date.now() };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Create a relayed route through intermediate hops. */
|
|
23
|
+
export function relayedRoute(hops: string[], latencyMs: number, bandwidthBps: number): Route {
|
|
24
|
+
return { hops: [...hops], latencyMs, bandwidthBps, lastSeen: Date.now() };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get the hop count for a route. */
|
|
28
|
+
export function hopCount(route: Route): number {
|
|
29
|
+
return route.hops.length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A single reachability entry in a topology update. */
|
|
33
|
+
export interface ReachabilityEntry {
|
|
34
|
+
/** The reachable peer (hex peer ID). */
|
|
35
|
+
peerId: string;
|
|
36
|
+
/** Intermediate hops to reach this peer from the sender (empty = direct). */
|
|
37
|
+
viaHops: string[];
|
|
38
|
+
/** Estimated latency in milliseconds. */
|
|
39
|
+
latencyMs: number;
|
|
40
|
+
/** Estimated bandwidth in bytes/sec. */
|
|
41
|
+
bandwidthBps: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A topology update message exchanged between mesh peers (distance-vector). */
|
|
45
|
+
export interface MeshTopologyUpdate {
|
|
46
|
+
/** Peers reachable from the sender. */
|
|
47
|
+
reachablePeers: ReachabilityEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Route selection comparison.
|
|
52
|
+
*
|
|
53
|
+
* Priority: shortest hops -> lowest latency -> highest bandwidth.
|
|
54
|
+
* Returns negative if a is better, positive if b is better, 0 if equal.
|
|
55
|
+
*/
|
|
56
|
+
function compareRoutes(a: Route, b: Route): number {
|
|
57
|
+
const hopDiff = hopCount(a) - hopCount(b);
|
|
58
|
+
if (hopDiff !== 0) return hopDiff;
|
|
59
|
+
|
|
60
|
+
const latDiff = a.latencyMs - b.latencyMs;
|
|
61
|
+
if (latDiff !== 0) return latDiff;
|
|
62
|
+
|
|
63
|
+
// Higher bandwidth is better, so reverse comparison
|
|
64
|
+
return b.bandwidthBps - a.bandwidthBps;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Routing table maintaining known peers and their reachability.
|
|
69
|
+
*/
|
|
70
|
+
export class RoutingTable {
|
|
71
|
+
private readonly _routes = new Map<string, Route[]>();
|
|
72
|
+
private readonly _maxHops: number;
|
|
73
|
+
|
|
74
|
+
constructor(maxHops: number) {
|
|
75
|
+
this._maxHops = maxHops;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Get the max hops limit. */
|
|
79
|
+
get maxHops(): number {
|
|
80
|
+
return this._maxHops;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add or update a route to a destination peer.
|
|
85
|
+
* Routes exceeding maxHops are rejected.
|
|
86
|
+
*/
|
|
87
|
+
addRoute(destination: string, route: Route): void {
|
|
88
|
+
const hops = hopCount(route);
|
|
89
|
+
if (hops > this._maxHops) {
|
|
90
|
+
throw new CairnError(
|
|
91
|
+
'MESH_MAX_HOPS_EXCEEDED',
|
|
92
|
+
`max hops exceeded: ${hops} > ${this._maxHops}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const existing = this._routes.get(destination);
|
|
97
|
+
if (existing) {
|
|
98
|
+
existing.push(route);
|
|
99
|
+
} else {
|
|
100
|
+
this._routes.set(destination, [route]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Select the best route to a destination peer.
|
|
106
|
+
*
|
|
107
|
+
* Priority order per spec 9.2:
|
|
108
|
+
* 1. Shortest hop count
|
|
109
|
+
* 2. Lowest latency
|
|
110
|
+
* 3. Highest bandwidth
|
|
111
|
+
*/
|
|
112
|
+
selectBestRoute(destination: string): Route {
|
|
113
|
+
const routes = this._routes.get(destination);
|
|
114
|
+
if (!routes || routes.length === 0) {
|
|
115
|
+
throw new MeshRouteNotFoundError(`no route to peer ${destination}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let best = routes[0];
|
|
119
|
+
for (let i = 1; i < routes.length; i++) {
|
|
120
|
+
if (compareRoutes(routes[i], best) < 0) {
|
|
121
|
+
best = routes[i];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return best;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Get all known routes to a destination peer. */
|
|
128
|
+
getRoutes(destination: string): Route[] | undefined {
|
|
129
|
+
return this._routes.get(destination);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remove all routes to a destination peer. */
|
|
133
|
+
removeRoutes(destination: string): void {
|
|
134
|
+
this._routes.delete(destination);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Remove stale routes older than the given age (ms). */
|
|
138
|
+
expireRoutes(maxAgeMs: number): void {
|
|
139
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
140
|
+
for (const [dest, routes] of this._routes) {
|
|
141
|
+
const fresh = routes.filter((r) => r.lastSeen >= cutoff);
|
|
142
|
+
if (fresh.length === 0) {
|
|
143
|
+
this._routes.delete(dest);
|
|
144
|
+
} else {
|
|
145
|
+
this._routes.set(dest, fresh);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Get the number of known destination peers. */
|
|
151
|
+
get peerCount(): number {
|
|
152
|
+
return this._routes.size;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Get the total number of routes across all destinations. */
|
|
156
|
+
get routeCount(): number {
|
|
157
|
+
let count = 0;
|
|
158
|
+
for (const routes of this._routes.values()) {
|
|
159
|
+
count += routes.length;
|
|
160
|
+
}
|
|
161
|
+
return count;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Get all known destination peer IDs. */
|
|
165
|
+
get destinations(): string[] {
|
|
166
|
+
return [...this._routes.keys()];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply a topology update from a neighboring peer.
|
|
171
|
+
*
|
|
172
|
+
* Merges the neighbor's reachability information, adding the neighbor
|
|
173
|
+
* as an additional hop to each advertised destination.
|
|
174
|
+
* Returns the number of routes successfully added.
|
|
175
|
+
*/
|
|
176
|
+
applyTopologyUpdate(neighbor: string, update: MeshTopologyUpdate): number {
|
|
177
|
+
let added = 0;
|
|
178
|
+
for (const entry of update.reachablePeers) {
|
|
179
|
+
const hops = [neighbor, ...entry.viaHops];
|
|
180
|
+
const route: Route = {
|
|
181
|
+
hops,
|
|
182
|
+
latencyMs: entry.latencyMs,
|
|
183
|
+
bandwidthBps: entry.bandwidthBps,
|
|
184
|
+
lastSeen: Date.now(),
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
this.addRoute(entry.peerId, route);
|
|
189
|
+
added++;
|
|
190
|
+
} catch {
|
|
191
|
+
// Skip routes exceeding max hops
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return added;
|
|
195
|
+
}
|
|
196
|
+
}
|