@meshwhisper/sdk 0.1.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 +138 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +19 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/chaff/index.d.ts +91 -0
- package/dist/chaff/index.d.ts.map +1 -0
- package/dist/chaff/index.js +268 -0
- package/dist/chaff/index.js.map +1 -0
- package/dist/cluster/index.d.ts +159 -0
- package/dist/cluster/index.d.ts.map +1 -0
- package/dist/cluster/index.js +393 -0
- package/dist/cluster/index.js.map +1 -0
- package/dist/compliance/index.d.ts +129 -0
- package/dist/compliance/index.d.ts.map +1 -0
- package/dist/compliance/index.js +315 -0
- package/dist/compliance/index.js.map +1 -0
- package/dist/crypto/index.d.ts +65 -0
- package/dist/crypto/index.d.ts.map +1 -0
- package/dist/crypto/index.js +146 -0
- package/dist/crypto/index.js.map +1 -0
- package/dist/group/index.d.ts +155 -0
- package/dist/group/index.d.ts.map +1 -0
- package/dist/group/index.js +560 -0
- package/dist/group/index.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/namespace/index.d.ts +155 -0
- package/dist/namespace/index.d.ts.map +1 -0
- package/dist/namespace/index.js +278 -0
- package/dist/namespace/index.js.map +1 -0
- package/dist/node/index.d.ts +4 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +19 -0
- package/dist/node/index.js.map +1 -0
- package/dist/packet/index.d.ts +63 -0
- package/dist/packet/index.d.ts.map +1 -0
- package/dist/packet/index.js +244 -0
- package/dist/packet/index.js.map +1 -0
- package/dist/permissions/index.d.ts +107 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +282 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/persistence/idb-storage.d.ts +27 -0
- package/dist/persistence/idb-storage.d.ts.map +1 -0
- package/dist/persistence/idb-storage.js +75 -0
- package/dist/persistence/idb-storage.js.map +1 -0
- package/dist/persistence/index.d.ts +4 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +3 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/node-storage.d.ts +33 -0
- package/dist/persistence/node-storage.d.ts.map +1 -0
- package/dist/persistence/node-storage.js +90 -0
- package/dist/persistence/node-storage.js.map +1 -0
- package/dist/persistence/serialization.d.ts +4 -0
- package/dist/persistence/serialization.d.ts.map +1 -0
- package/dist/persistence/serialization.js +49 -0
- package/dist/persistence/serialization.js.map +1 -0
- package/dist/persistence/types.d.ts +29 -0
- package/dist/persistence/types.d.ts.map +1 -0
- package/dist/persistence/types.js +5 -0
- package/dist/persistence/types.js.map +1 -0
- package/dist/ratchet/index.d.ts +80 -0
- package/dist/ratchet/index.d.ts.map +1 -0
- package/dist/ratchet/index.js +259 -0
- package/dist/ratchet/index.js.map +1 -0
- package/dist/reciprocity/index.d.ts +109 -0
- package/dist/reciprocity/index.d.ts.map +1 -0
- package/dist/reciprocity/index.js +311 -0
- package/dist/reciprocity/index.js.map +1 -0
- package/dist/relay/index.d.ts +87 -0
- package/dist/relay/index.d.ts.map +1 -0
- package/dist/relay/index.js +286 -0
- package/dist/relay/index.js.map +1 -0
- package/dist/routing/index.d.ts +136 -0
- package/dist/routing/index.d.ts.map +1 -0
- package/dist/routing/index.js +478 -0
- package/dist/routing/index.js.map +1 -0
- package/dist/sdk/index.d.ts +322 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +1530 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sybil/index.d.ts +123 -0
- package/dist/sybil/index.d.ts.map +1 -0
- package/dist/sybil/index.js +491 -0
- package/dist/sybil/index.js.map +1 -0
- package/dist/transport/browser/index.d.ts +34 -0
- package/dist/transport/browser/index.d.ts.map +1 -0
- package/dist/transport/browser/index.js +176 -0
- package/dist/transport/browser/index.js.map +1 -0
- package/dist/transport/local/index.d.ts +57 -0
- package/dist/transport/local/index.d.ts.map +1 -0
- package/dist/transport/local/index.js +442 -0
- package/dist/transport/local/index.js.map +1 -0
- package/dist/transport/negotiator/index.d.ts +79 -0
- package/dist/transport/negotiator/index.d.ts.map +1 -0
- package/dist/transport/negotiator/index.js +289 -0
- package/dist/transport/negotiator/index.js.map +1 -0
- package/dist/transport/node/index.d.ts +56 -0
- package/dist/transport/node/index.d.ts.map +1 -0
- package/dist/transport/node/index.js +209 -0
- package/dist/transport/node/index.js.map +1 -0
- package/dist/transport/noop/index.d.ts +11 -0
- package/dist/transport/noop/index.d.ts.map +1 -0
- package/dist/transport/noop/index.js +20 -0
- package/dist/transport/noop/index.js.map +1 -0
- package/dist/transport/p2p/index.d.ts +109 -0
- package/dist/transport/p2p/index.d.ts.map +1 -0
- package/dist/transport/p2p/index.js +237 -0
- package/dist/transport/p2p/index.js.map +1 -0
- package/dist/transport/websocket/index.d.ts +89 -0
- package/dist/transport/websocket/index.d.ts.map +1 -0
- package/dist/transport/websocket/index.js +498 -0
- package/dist/transport/websocket/index.js.map +1 -0
- package/dist/transport/websocket/serialize.d.ts +5 -0
- package/dist/transport/websocket/serialize.d.ts.map +1 -0
- package/dist/transport/websocket/serialize.js +55 -0
- package/dist/transport/websocket/serialize.js.map +1 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/dist/x3dh/index.d.ts +120 -0
- package/dist/x3dh/index.d.ts.map +1 -0
- package/dist/x3dh/index.js +290 -0
- package/dist/x3dh/index.js.map +1 -0
- package/package.json +59 -0
- package/src/browser/index.ts +19 -0
- package/src/chaff/index.ts +340 -0
- package/src/cluster/index.ts +482 -0
- package/src/compliance/index.ts +407 -0
- package/src/crypto/index.ts +193 -0
- package/src/group/index.ts +719 -0
- package/src/index.ts +87 -0
- package/src/lz4js.d.ts +58 -0
- package/src/namespace/index.ts +336 -0
- package/src/node/index.ts +19 -0
- package/src/packet/index.ts +326 -0
- package/src/permissions/index.ts +405 -0
- package/src/persistence/idb-storage.ts +83 -0
- package/src/persistence/index.ts +3 -0
- package/src/persistence/node-storage.ts +96 -0
- package/src/persistence/serialization.ts +75 -0
- package/src/persistence/types.ts +33 -0
- package/src/ratchet/index.ts +363 -0
- package/src/reciprocity/index.ts +371 -0
- package/src/relay/index.ts +382 -0
- package/src/routing/index.ts +577 -0
- package/src/sdk/index.ts +1994 -0
- package/src/sybil/index.ts +661 -0
- package/src/transport/browser/index.ts +201 -0
- package/src/transport/local/index.ts +540 -0
- package/src/transport/negotiator/index.ts +397 -0
- package/src/transport/node/index.ts +234 -0
- package/src/transport/noop/index.ts +22 -0
- package/src/transport/p2p/index.ts +345 -0
- package/src/transport/websocket/index.ts +660 -0
- package/src/transport/websocket/serialize.ts +68 -0
- package/src/types.ts +275 -0
- package/src/x3dh/index.ts +388 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// MeshWhisper SDK — Local Network Transport (LAN)
|
|
3
|
+
// Bearer: local_net
|
|
4
|
+
//
|
|
5
|
+
// Uses UDP broadcast for peer discovery and TCP for reliable
|
|
6
|
+
// data transfer. Designed for device self-clustering on the
|
|
7
|
+
// same subnet (phone ↔ laptop in the same home).
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
import * as dgram from 'node:dgram';
|
|
11
|
+
import * as net from 'node:net';
|
|
12
|
+
import type { Transport, Packet, PacketFlags } from '../../types.js';
|
|
13
|
+
|
|
14
|
+
// --- Constants ---
|
|
15
|
+
|
|
16
|
+
const MAGIC = 0x4d575350; // "MWSP"
|
|
17
|
+
const DEFAULT_UDP_PORT = 19205;
|
|
18
|
+
const DEFAULT_TCP_PORT = 19206;
|
|
19
|
+
const ANNOUNCE_INTERVAL_MS = 5_000;
|
|
20
|
+
const PEER_TTL_MS = 15_000;
|
|
21
|
+
const DEVICE_ID_LENGTH = 16;
|
|
22
|
+
const ANNOUNCEMENT_SIZE = 4 + DEVICE_ID_LENGTH + 2; // magic + id + port
|
|
23
|
+
const LENGTH_PREFIX_SIZE = 4; // uint32 big-endian frame header
|
|
24
|
+
|
|
25
|
+
// --- Discovered peer entry ---
|
|
26
|
+
|
|
27
|
+
interface DiscoveredPeer {
|
|
28
|
+
id: string;
|
|
29
|
+
address: string;
|
|
30
|
+
port: number;
|
|
31
|
+
lastSeen: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- TCP connection wrapper ---
|
|
35
|
+
|
|
36
|
+
interface PeerConnection {
|
|
37
|
+
peerId: string;
|
|
38
|
+
socket: net.Socket;
|
|
39
|
+
recvBuffer: Buffer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Helpers ---
|
|
43
|
+
|
|
44
|
+
/** Encode a 16-byte device ID to a hex string. */
|
|
45
|
+
function deviceIdToHex(buf: Uint8Array): string {
|
|
46
|
+
return Buffer.from(buf).toString('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Decode a hex string back to a 16-byte Uint8Array. */
|
|
50
|
+
function hexToDeviceId(hex: string): Uint8Array {
|
|
51
|
+
return new Uint8Array(Buffer.from(hex, 'hex'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Serialize a Packet to a binary buffer. */
|
|
55
|
+
function serializePacket(packet: Packet): Buffer {
|
|
56
|
+
const headerSize =
|
|
57
|
+
1 + // version
|
|
58
|
+
1 + // flags
|
|
59
|
+
8 + // destHash
|
|
60
|
+
16 + // senderEphemeralId
|
|
61
|
+
1 + // ttl
|
|
62
|
+
4; // payloadLength (uint32)
|
|
63
|
+
const buf = Buffer.alloc(headerSize + packet.encryptedPayload.length);
|
|
64
|
+
let offset = 0;
|
|
65
|
+
|
|
66
|
+
buf.writeUInt8(packet.version, offset);
|
|
67
|
+
offset += 1;
|
|
68
|
+
buf.writeUInt8(packet.flags, offset);
|
|
69
|
+
offset += 1;
|
|
70
|
+
Buffer.from(packet.destHash).copy(buf, offset, 0, 8);
|
|
71
|
+
offset += 8;
|
|
72
|
+
Buffer.from(packet.senderEphemeralId).copy(buf, offset, 0, 16);
|
|
73
|
+
offset += 16;
|
|
74
|
+
buf.writeUInt8(packet.ttl, offset);
|
|
75
|
+
offset += 1;
|
|
76
|
+
buf.writeUInt32BE(packet.encryptedPayload.length, offset);
|
|
77
|
+
offset += 4;
|
|
78
|
+
Buffer.from(packet.encryptedPayload).copy(buf, offset);
|
|
79
|
+
|
|
80
|
+
return buf;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Deserialize a binary buffer back into a Packet. */
|
|
84
|
+
function deserializePacket(buf: Buffer): Packet {
|
|
85
|
+
let offset = 0;
|
|
86
|
+
|
|
87
|
+
const version = buf.readUInt8(offset);
|
|
88
|
+
offset += 1;
|
|
89
|
+
const flags = buf.readUInt8(offset) as PacketFlags;
|
|
90
|
+
offset += 1;
|
|
91
|
+
const destHash = new Uint8Array(buf.subarray(offset, offset + 8));
|
|
92
|
+
offset += 8;
|
|
93
|
+
const senderEphemeralId = new Uint8Array(buf.subarray(offset, offset + 16));
|
|
94
|
+
offset += 16;
|
|
95
|
+
const ttl = buf.readUInt8(offset);
|
|
96
|
+
offset += 1;
|
|
97
|
+
const payloadLength = buf.readUInt32BE(offset);
|
|
98
|
+
offset += 4;
|
|
99
|
+
const encryptedPayload = new Uint8Array(buf.subarray(offset, offset + payloadLength));
|
|
100
|
+
|
|
101
|
+
return { version, flags, destHash, senderEphemeralId, ttl, payloadLength, encryptedPayload };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================
|
|
105
|
+
// LocalTransport
|
|
106
|
+
// ============================================================
|
|
107
|
+
|
|
108
|
+
export class LocalTransport implements Transport {
|
|
109
|
+
readonly type = 'local_net' as const;
|
|
110
|
+
|
|
111
|
+
// --- Configuration ---
|
|
112
|
+
private readonly deviceId: Uint8Array;
|
|
113
|
+
private readonly deviceIdHex: string;
|
|
114
|
+
private readonly udpPort: number;
|
|
115
|
+
private readonly tcpPort: number;
|
|
116
|
+
|
|
117
|
+
// --- Networking ---
|
|
118
|
+
private udpSocket: dgram.Socket | null = null;
|
|
119
|
+
private tcpServer: net.Server | null = null;
|
|
120
|
+
private announceTimer: ReturnType<typeof setInterval> | null = null;
|
|
121
|
+
private pruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
122
|
+
|
|
123
|
+
// --- State ---
|
|
124
|
+
private readonly discoveredPeers = new Map<string, DiscoveredPeer>();
|
|
125
|
+
private readonly connections = new Map<string, PeerConnection>();
|
|
126
|
+
private readonly pendingConnections = new Set<string>(); // addresses currently being connected to
|
|
127
|
+
private receiveCallback: ((packet: Packet, source: string) => void) | null = null;
|
|
128
|
+
private running = false;
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
deviceId: Uint8Array,
|
|
132
|
+
options?: { udpPort?: number; tcpPort?: number },
|
|
133
|
+
) {
|
|
134
|
+
if (deviceId.length !== DEVICE_ID_LENGTH) {
|
|
135
|
+
throw new Error(`deviceId must be ${DEVICE_ID_LENGTH} bytes, got ${deviceId.length}`);
|
|
136
|
+
}
|
|
137
|
+
this.deviceId = deviceId;
|
|
138
|
+
this.deviceIdHex = deviceIdToHex(deviceId);
|
|
139
|
+
this.udpPort = options?.udpPort ?? DEFAULT_UDP_PORT;
|
|
140
|
+
this.tcpPort = options?.tcpPort ?? DEFAULT_TCP_PORT;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --------------------------------------------------------
|
|
144
|
+
// Transport interface — lifecycle
|
|
145
|
+
// --------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async start(): Promise<void> {
|
|
148
|
+
if (this.running) return;
|
|
149
|
+
this.running = true;
|
|
150
|
+
|
|
151
|
+
await Promise.all([
|
|
152
|
+
this.startDiscovery(),
|
|
153
|
+
this.startListener(this.tcpPort),
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async stop(): Promise<void> {
|
|
158
|
+
if (!this.running) return;
|
|
159
|
+
this.running = false;
|
|
160
|
+
|
|
161
|
+
// Clear timers
|
|
162
|
+
if (this.announceTimer) {
|
|
163
|
+
clearInterval(this.announceTimer);
|
|
164
|
+
this.announceTimer = null;
|
|
165
|
+
}
|
|
166
|
+
if (this.pruneTimer) {
|
|
167
|
+
clearInterval(this.pruneTimer);
|
|
168
|
+
this.pruneTimer = null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Close all TCP peer connections
|
|
172
|
+
for (const [, conn] of this.connections) {
|
|
173
|
+
conn.socket.destroy();
|
|
174
|
+
}
|
|
175
|
+
this.connections.clear();
|
|
176
|
+
this.pendingConnections.clear();
|
|
177
|
+
|
|
178
|
+
// Close TCP server
|
|
179
|
+
await new Promise<void>((resolve) => {
|
|
180
|
+
if (this.tcpServer) {
|
|
181
|
+
this.tcpServer.close(() => resolve());
|
|
182
|
+
} else {
|
|
183
|
+
resolve();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
this.tcpServer = null;
|
|
187
|
+
|
|
188
|
+
// Close UDP socket
|
|
189
|
+
await new Promise<void>((resolve) => {
|
|
190
|
+
if (this.udpSocket) {
|
|
191
|
+
this.udpSocket.close(() => resolve());
|
|
192
|
+
} else {
|
|
193
|
+
resolve();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
this.udpSocket = null;
|
|
197
|
+
|
|
198
|
+
this.discoveredPeers.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async isAvailable(): Promise<boolean> {
|
|
202
|
+
// Local network is available if we can bind a UDP socket.
|
|
203
|
+
// In practice this checks whether the OS networking stack is usable.
|
|
204
|
+
return new Promise<boolean>((resolve) => {
|
|
205
|
+
const probe = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
206
|
+
probe.on('error', () => {
|
|
207
|
+
probe.close();
|
|
208
|
+
resolve(false);
|
|
209
|
+
});
|
|
210
|
+
probe.bind(0, () => {
|
|
211
|
+
probe.close();
|
|
212
|
+
resolve(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --------------------------------------------------------
|
|
218
|
+
// Transport interface — messaging
|
|
219
|
+
// --------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
async send(packet: Packet, destination: string): Promise<void> {
|
|
222
|
+
const conn = this.connections.get(destination);
|
|
223
|
+
if (!conn) {
|
|
224
|
+
throw new Error(`No active connection to peer ${destination}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const payload = serializePacket(packet);
|
|
228
|
+
const frame = Buffer.alloc(LENGTH_PREFIX_SIZE + payload.length);
|
|
229
|
+
frame.writeUInt32BE(payload.length, 0);
|
|
230
|
+
payload.copy(frame, LENGTH_PREFIX_SIZE);
|
|
231
|
+
|
|
232
|
+
await new Promise<void>((resolve, reject) => {
|
|
233
|
+
conn.socket.write(frame, (err) => {
|
|
234
|
+
if (err) reject(err);
|
|
235
|
+
else resolve();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
onReceive(callback: (packet: Packet, source: string) => void): void {
|
|
241
|
+
this.receiveCallback = callback;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --------------------------------------------------------
|
|
245
|
+
// UDP Discovery
|
|
246
|
+
// --------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
async startDiscovery(): Promise<void> {
|
|
249
|
+
await new Promise<void>((resolve, reject) => {
|
|
250
|
+
this.udpSocket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
251
|
+
|
|
252
|
+
this.udpSocket.on('error', (err) => {
|
|
253
|
+
if (!this.running) return;
|
|
254
|
+
// Non-fatal in steady state; during bind it rejects the promise.
|
|
255
|
+
reject(err);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
this.udpSocket.on('message', (msg, rinfo) => {
|
|
259
|
+
this.handleAnnouncement(msg, rinfo.address);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
this.udpSocket.bind(this.udpPort, () => {
|
|
263
|
+
this.udpSocket!.setBroadcast(true);
|
|
264
|
+
|
|
265
|
+
// Send first announcement immediately, then on interval
|
|
266
|
+
this.broadcastAnnouncement();
|
|
267
|
+
this.announceTimer = setInterval(
|
|
268
|
+
() => this.broadcastAnnouncement(),
|
|
269
|
+
ANNOUNCE_INTERVAL_MS,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Periodically prune stale peers
|
|
273
|
+
this.pruneTimer = setInterval(
|
|
274
|
+
() => this.pruneStalePeers(),
|
|
275
|
+
ANNOUNCE_INTERVAL_MS,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
resolve();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Build and broadcast a MWSP announcement datagram. */
|
|
284
|
+
private broadcastAnnouncement(): void {
|
|
285
|
+
if (!this.udpSocket) return;
|
|
286
|
+
|
|
287
|
+
const buf = Buffer.alloc(ANNOUNCEMENT_SIZE);
|
|
288
|
+
let offset = 0;
|
|
289
|
+
|
|
290
|
+
buf.writeUInt32BE(MAGIC, offset);
|
|
291
|
+
offset += 4;
|
|
292
|
+
Buffer.from(this.deviceId).copy(buf, offset, 0, DEVICE_ID_LENGTH);
|
|
293
|
+
offset += DEVICE_ID_LENGTH;
|
|
294
|
+
buf.writeUInt16BE(this.tcpPort, offset);
|
|
295
|
+
|
|
296
|
+
this.udpSocket.send(buf, 0, buf.length, this.udpPort, '255.255.255.255', (err) => {
|
|
297
|
+
if (err && this.running) {
|
|
298
|
+
// Best-effort; swallow transient send errors.
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Process an incoming UDP announcement. */
|
|
304
|
+
private handleAnnouncement(msg: Buffer, senderAddress: string): void {
|
|
305
|
+
if (msg.length < ANNOUNCEMENT_SIZE) return;
|
|
306
|
+
|
|
307
|
+
const magic = msg.readUInt32BE(0);
|
|
308
|
+
if (magic !== MAGIC) return;
|
|
309
|
+
|
|
310
|
+
const peerIdBytes = msg.subarray(4, 4 + DEVICE_ID_LENGTH);
|
|
311
|
+
const peerId = deviceIdToHex(peerIdBytes);
|
|
312
|
+
|
|
313
|
+
// Ignore our own announcements
|
|
314
|
+
if (peerId === this.deviceIdHex) return;
|
|
315
|
+
|
|
316
|
+
const tcpPort = msg.readUInt16BE(4 + DEVICE_ID_LENGTH);
|
|
317
|
+
|
|
318
|
+
const existing = this.discoveredPeers.get(peerId);
|
|
319
|
+
if (existing) {
|
|
320
|
+
existing.lastSeen = Date.now();
|
|
321
|
+
existing.address = senderAddress;
|
|
322
|
+
existing.port = tcpPort;
|
|
323
|
+
} else {
|
|
324
|
+
this.discoveredPeers.set(peerId, {
|
|
325
|
+
id: peerId,
|
|
326
|
+
address: senderAddress,
|
|
327
|
+
port: tcpPort,
|
|
328
|
+
lastSeen: Date.now(),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Auto-connect if we don't already have a TCP connection
|
|
333
|
+
if (!this.connections.has(peerId) && !this.pendingConnections.has(peerId)) {
|
|
334
|
+
this.connectToPeer(senderAddress, tcpPort).catch(() => {
|
|
335
|
+
// Connection failed; will retry on next announcement.
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Remove peers whose last announcement is older than PEER_TTL_MS. */
|
|
341
|
+
private pruneStalePeers(): void {
|
|
342
|
+
const now = Date.now();
|
|
343
|
+
for (const [id, peer] of this.discoveredPeers) {
|
|
344
|
+
if (now - peer.lastSeen > PEER_TTL_MS) {
|
|
345
|
+
this.discoveredPeers.delete(id);
|
|
346
|
+
// Also tear down stale TCP connections
|
|
347
|
+
const conn = this.connections.get(id);
|
|
348
|
+
if (conn) {
|
|
349
|
+
conn.socket.destroy();
|
|
350
|
+
this.connections.delete(id);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --------------------------------------------------------
|
|
357
|
+
// TCP Data Channel
|
|
358
|
+
// --------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
async startListener(port?: number): Promise<void> {
|
|
361
|
+
const listenPort = port ?? this.tcpPort;
|
|
362
|
+
|
|
363
|
+
await new Promise<void>((resolve, reject) => {
|
|
364
|
+
this.tcpServer = net.createServer((socket) => {
|
|
365
|
+
this.handleIncomingConnection(socket);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
this.tcpServer.on('error', (err) => {
|
|
369
|
+
reject(err);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
this.tcpServer.listen(listenPort, () => {
|
|
373
|
+
resolve();
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async connectToPeer(address: string, port: number): Promise<void> {
|
|
379
|
+
// Derive a temporary key until the peer identifies itself via handshake.
|
|
380
|
+
const addrKey = `${address}:${port}`;
|
|
381
|
+
this.pendingConnections.add(addrKey);
|
|
382
|
+
|
|
383
|
+
return new Promise<void>((resolve, reject) => {
|
|
384
|
+
const socket = net.createConnection({ host: address, port }, () => {
|
|
385
|
+
// Send our device ID so the remote side knows who connected
|
|
386
|
+
const idFrame = Buffer.alloc(LENGTH_PREFIX_SIZE + DEVICE_ID_LENGTH);
|
|
387
|
+
idFrame.writeUInt32BE(DEVICE_ID_LENGTH, 0);
|
|
388
|
+
Buffer.from(this.deviceId).copy(idFrame, LENGTH_PREFIX_SIZE);
|
|
389
|
+
socket.write(idFrame);
|
|
390
|
+
|
|
391
|
+
// We don't yet know the peer ID. We'll register the connection
|
|
392
|
+
// once we receive the peer's ID frame back.
|
|
393
|
+
const conn: PeerConnection = {
|
|
394
|
+
peerId: '', // will be populated
|
|
395
|
+
socket,
|
|
396
|
+
recvBuffer: Buffer.alloc(0),
|
|
397
|
+
};
|
|
398
|
+
this.setupTcpFraming(conn, true);
|
|
399
|
+
this.pendingConnections.delete(addrKey);
|
|
400
|
+
resolve();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
socket.on('error', (err) => {
|
|
404
|
+
this.pendingConnections.delete(addrKey);
|
|
405
|
+
reject(err);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Handle an incoming TCP connection from a remote peer. */
|
|
411
|
+
private handleIncomingConnection(socket: net.Socket): void {
|
|
412
|
+
const conn: PeerConnection = {
|
|
413
|
+
peerId: '', // unknown until the peer sends its ID frame
|
|
414
|
+
socket,
|
|
415
|
+
recvBuffer: Buffer.alloc(0),
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// The first framed message from the connecting side is the device ID.
|
|
419
|
+
this.setupTcpFraming(conn, false);
|
|
420
|
+
|
|
421
|
+
// Send our own ID back so the remote side can register us.
|
|
422
|
+
const idFrame = Buffer.alloc(LENGTH_PREFIX_SIZE + DEVICE_ID_LENGTH);
|
|
423
|
+
idFrame.writeUInt32BE(DEVICE_ID_LENGTH, 0);
|
|
424
|
+
Buffer.from(this.deviceId).copy(idFrame, LENGTH_PREFIX_SIZE);
|
|
425
|
+
socket.write(idFrame);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Attach length-prefixed framing to a TCP connection.
|
|
430
|
+
*
|
|
431
|
+
* The first message on every connection is a 16-byte device ID used to
|
|
432
|
+
* register the peer. All subsequent messages are serialized Packets.
|
|
433
|
+
*
|
|
434
|
+
* @param conn The peer connection wrapper (mutated in place).
|
|
435
|
+
* @param isInitiator True if we initiated the connection.
|
|
436
|
+
*/
|
|
437
|
+
private setupTcpFraming(conn: PeerConnection, isInitiator: boolean): void {
|
|
438
|
+
let identified = false;
|
|
439
|
+
|
|
440
|
+
conn.socket.on('data', (chunk: Buffer) => {
|
|
441
|
+
conn.recvBuffer = Buffer.concat([conn.recvBuffer, chunk]);
|
|
442
|
+
|
|
443
|
+
// Process as many complete frames as available
|
|
444
|
+
while (conn.recvBuffer.length >= LENGTH_PREFIX_SIZE) {
|
|
445
|
+
const frameLen = conn.recvBuffer.readUInt32BE(0);
|
|
446
|
+
|
|
447
|
+
// Guard against absurdly large frames (16 MiB limit)
|
|
448
|
+
if (frameLen > 16 * 1024 * 1024) {
|
|
449
|
+
conn.socket.destroy(new Error('Frame too large'));
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (conn.recvBuffer.length < LENGTH_PREFIX_SIZE + frameLen) {
|
|
454
|
+
break; // wait for more data
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const frameData = conn.recvBuffer.subarray(
|
|
458
|
+
LENGTH_PREFIX_SIZE,
|
|
459
|
+
LENGTH_PREFIX_SIZE + frameLen,
|
|
460
|
+
);
|
|
461
|
+
conn.recvBuffer = Buffer.from(
|
|
462
|
+
conn.recvBuffer.subarray(LENGTH_PREFIX_SIZE + frameLen),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (!identified) {
|
|
466
|
+
// First frame: device ID
|
|
467
|
+
if (frameData.length !== DEVICE_ID_LENGTH) {
|
|
468
|
+
conn.socket.destroy(new Error('Invalid identification frame'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const peerId = deviceIdToHex(frameData);
|
|
472
|
+
|
|
473
|
+
// Don't connect to ourselves
|
|
474
|
+
if (peerId === this.deviceIdHex) {
|
|
475
|
+
conn.socket.destroy();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// If we already have a connection to this peer, keep only one.
|
|
480
|
+
// The tie-breaker: the side with the lexicographically smaller ID
|
|
481
|
+
// keeps its *initiated* connection.
|
|
482
|
+
const existingConn = this.connections.get(peerId);
|
|
483
|
+
if (existingConn) {
|
|
484
|
+
const weAreSmaller = this.deviceIdHex < peerId;
|
|
485
|
+
if (isInitiator === weAreSmaller) {
|
|
486
|
+
// We keep this connection; destroy the old one.
|
|
487
|
+
existingConn.socket.destroy();
|
|
488
|
+
} else {
|
|
489
|
+
// We keep the existing connection; destroy this one.
|
|
490
|
+
conn.socket.destroy();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
conn.peerId = peerId;
|
|
496
|
+
this.connections.set(peerId, conn);
|
|
497
|
+
identified = true;
|
|
498
|
+
} else {
|
|
499
|
+
// Subsequent frames: Packets
|
|
500
|
+
try {
|
|
501
|
+
const packet = deserializePacket(Buffer.from(frameData));
|
|
502
|
+
this.receiveCallback?.(packet, conn.peerId);
|
|
503
|
+
} catch {
|
|
504
|
+
// Malformed packet — drop silently.
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
conn.socket.on('close', () => {
|
|
511
|
+
if (conn.peerId && this.connections.get(conn.peerId) === conn) {
|
|
512
|
+
this.connections.delete(conn.peerId);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
conn.socket.on('error', () => {
|
|
517
|
+
// Error is followed by close; cleanup happens there.
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// --------------------------------------------------------
|
|
522
|
+
// Peer Queries
|
|
523
|
+
// --------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/** Return the list of peers discovered via UDP announcements. */
|
|
526
|
+
getDiscoveredPeers(): Array<{ id: string; address: string; port: number }> {
|
|
527
|
+
return Array.from(this.discoveredPeers.values()).map(({ id, address, port }) => ({
|
|
528
|
+
id,
|
|
529
|
+
address,
|
|
530
|
+
port,
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Return the IDs of peers with an active TCP connection. */
|
|
535
|
+
getConnectedPeers(): string[] {
|
|
536
|
+
return Array.from(this.connections.keys());
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export default LocalTransport;
|