@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.
Files changed (163) hide show
  1. package/README.md +138 -0
  2. package/dist/browser/index.d.ts +4 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +19 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/chaff/index.d.ts +91 -0
  7. package/dist/chaff/index.d.ts.map +1 -0
  8. package/dist/chaff/index.js +268 -0
  9. package/dist/chaff/index.js.map +1 -0
  10. package/dist/cluster/index.d.ts +159 -0
  11. package/dist/cluster/index.d.ts.map +1 -0
  12. package/dist/cluster/index.js +393 -0
  13. package/dist/cluster/index.js.map +1 -0
  14. package/dist/compliance/index.d.ts +129 -0
  15. package/dist/compliance/index.d.ts.map +1 -0
  16. package/dist/compliance/index.js +315 -0
  17. package/dist/compliance/index.js.map +1 -0
  18. package/dist/crypto/index.d.ts +65 -0
  19. package/dist/crypto/index.d.ts.map +1 -0
  20. package/dist/crypto/index.js +146 -0
  21. package/dist/crypto/index.js.map +1 -0
  22. package/dist/group/index.d.ts +155 -0
  23. package/dist/group/index.d.ts.map +1 -0
  24. package/dist/group/index.js +560 -0
  25. package/dist/group/index.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/namespace/index.d.ts +155 -0
  31. package/dist/namespace/index.d.ts.map +1 -0
  32. package/dist/namespace/index.js +278 -0
  33. package/dist/namespace/index.js.map +1 -0
  34. package/dist/node/index.d.ts +4 -0
  35. package/dist/node/index.d.ts.map +1 -0
  36. package/dist/node/index.js +19 -0
  37. package/dist/node/index.js.map +1 -0
  38. package/dist/packet/index.d.ts +63 -0
  39. package/dist/packet/index.d.ts.map +1 -0
  40. package/dist/packet/index.js +244 -0
  41. package/dist/packet/index.js.map +1 -0
  42. package/dist/permissions/index.d.ts +107 -0
  43. package/dist/permissions/index.d.ts.map +1 -0
  44. package/dist/permissions/index.js +282 -0
  45. package/dist/permissions/index.js.map +1 -0
  46. package/dist/persistence/idb-storage.d.ts +27 -0
  47. package/dist/persistence/idb-storage.d.ts.map +1 -0
  48. package/dist/persistence/idb-storage.js +75 -0
  49. package/dist/persistence/idb-storage.js.map +1 -0
  50. package/dist/persistence/index.d.ts +4 -0
  51. package/dist/persistence/index.d.ts.map +1 -0
  52. package/dist/persistence/index.js +3 -0
  53. package/dist/persistence/index.js.map +1 -0
  54. package/dist/persistence/node-storage.d.ts +33 -0
  55. package/dist/persistence/node-storage.d.ts.map +1 -0
  56. package/dist/persistence/node-storage.js +90 -0
  57. package/dist/persistence/node-storage.js.map +1 -0
  58. package/dist/persistence/serialization.d.ts +4 -0
  59. package/dist/persistence/serialization.d.ts.map +1 -0
  60. package/dist/persistence/serialization.js +49 -0
  61. package/dist/persistence/serialization.js.map +1 -0
  62. package/dist/persistence/types.d.ts +29 -0
  63. package/dist/persistence/types.d.ts.map +1 -0
  64. package/dist/persistence/types.js +5 -0
  65. package/dist/persistence/types.js.map +1 -0
  66. package/dist/ratchet/index.d.ts +80 -0
  67. package/dist/ratchet/index.d.ts.map +1 -0
  68. package/dist/ratchet/index.js +259 -0
  69. package/dist/ratchet/index.js.map +1 -0
  70. package/dist/reciprocity/index.d.ts +109 -0
  71. package/dist/reciprocity/index.d.ts.map +1 -0
  72. package/dist/reciprocity/index.js +311 -0
  73. package/dist/reciprocity/index.js.map +1 -0
  74. package/dist/relay/index.d.ts +87 -0
  75. package/dist/relay/index.d.ts.map +1 -0
  76. package/dist/relay/index.js +286 -0
  77. package/dist/relay/index.js.map +1 -0
  78. package/dist/routing/index.d.ts +136 -0
  79. package/dist/routing/index.d.ts.map +1 -0
  80. package/dist/routing/index.js +478 -0
  81. package/dist/routing/index.js.map +1 -0
  82. package/dist/sdk/index.d.ts +322 -0
  83. package/dist/sdk/index.d.ts.map +1 -0
  84. package/dist/sdk/index.js +1530 -0
  85. package/dist/sdk/index.js.map +1 -0
  86. package/dist/sybil/index.d.ts +123 -0
  87. package/dist/sybil/index.d.ts.map +1 -0
  88. package/dist/sybil/index.js +491 -0
  89. package/dist/sybil/index.js.map +1 -0
  90. package/dist/transport/browser/index.d.ts +34 -0
  91. package/dist/transport/browser/index.d.ts.map +1 -0
  92. package/dist/transport/browser/index.js +176 -0
  93. package/dist/transport/browser/index.js.map +1 -0
  94. package/dist/transport/local/index.d.ts +57 -0
  95. package/dist/transport/local/index.d.ts.map +1 -0
  96. package/dist/transport/local/index.js +442 -0
  97. package/dist/transport/local/index.js.map +1 -0
  98. package/dist/transport/negotiator/index.d.ts +79 -0
  99. package/dist/transport/negotiator/index.d.ts.map +1 -0
  100. package/dist/transport/negotiator/index.js +289 -0
  101. package/dist/transport/negotiator/index.js.map +1 -0
  102. package/dist/transport/node/index.d.ts +56 -0
  103. package/dist/transport/node/index.d.ts.map +1 -0
  104. package/dist/transport/node/index.js +209 -0
  105. package/dist/transport/node/index.js.map +1 -0
  106. package/dist/transport/noop/index.d.ts +11 -0
  107. package/dist/transport/noop/index.d.ts.map +1 -0
  108. package/dist/transport/noop/index.js +20 -0
  109. package/dist/transport/noop/index.js.map +1 -0
  110. package/dist/transport/p2p/index.d.ts +109 -0
  111. package/dist/transport/p2p/index.d.ts.map +1 -0
  112. package/dist/transport/p2p/index.js +237 -0
  113. package/dist/transport/p2p/index.js.map +1 -0
  114. package/dist/transport/websocket/index.d.ts +89 -0
  115. package/dist/transport/websocket/index.d.ts.map +1 -0
  116. package/dist/transport/websocket/index.js +498 -0
  117. package/dist/transport/websocket/index.js.map +1 -0
  118. package/dist/transport/websocket/serialize.d.ts +5 -0
  119. package/dist/transport/websocket/serialize.d.ts.map +1 -0
  120. package/dist/transport/websocket/serialize.js +55 -0
  121. package/dist/transport/websocket/serialize.js.map +1 -0
  122. package/dist/types.d.ts +215 -0
  123. package/dist/types.d.ts.map +1 -0
  124. package/dist/types.js +15 -0
  125. package/dist/types.js.map +1 -0
  126. package/dist/x3dh/index.d.ts +120 -0
  127. package/dist/x3dh/index.d.ts.map +1 -0
  128. package/dist/x3dh/index.js +290 -0
  129. package/dist/x3dh/index.js.map +1 -0
  130. package/package.json +59 -0
  131. package/src/browser/index.ts +19 -0
  132. package/src/chaff/index.ts +340 -0
  133. package/src/cluster/index.ts +482 -0
  134. package/src/compliance/index.ts +407 -0
  135. package/src/crypto/index.ts +193 -0
  136. package/src/group/index.ts +719 -0
  137. package/src/index.ts +87 -0
  138. package/src/lz4js.d.ts +58 -0
  139. package/src/namespace/index.ts +336 -0
  140. package/src/node/index.ts +19 -0
  141. package/src/packet/index.ts +326 -0
  142. package/src/permissions/index.ts +405 -0
  143. package/src/persistence/idb-storage.ts +83 -0
  144. package/src/persistence/index.ts +3 -0
  145. package/src/persistence/node-storage.ts +96 -0
  146. package/src/persistence/serialization.ts +75 -0
  147. package/src/persistence/types.ts +33 -0
  148. package/src/ratchet/index.ts +363 -0
  149. package/src/reciprocity/index.ts +371 -0
  150. package/src/relay/index.ts +382 -0
  151. package/src/routing/index.ts +577 -0
  152. package/src/sdk/index.ts +1994 -0
  153. package/src/sybil/index.ts +661 -0
  154. package/src/transport/browser/index.ts +201 -0
  155. package/src/transport/local/index.ts +540 -0
  156. package/src/transport/negotiator/index.ts +397 -0
  157. package/src/transport/node/index.ts +234 -0
  158. package/src/transport/noop/index.ts +22 -0
  159. package/src/transport/p2p/index.ts +345 -0
  160. package/src/transport/websocket/index.ts +660 -0
  161. package/src/transport/websocket/serialize.ts +68 -0
  162. package/src/types.ts +275 -0
  163. package/src/x3dh/index.ts +388 -0
@@ -0,0 +1,660 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — WebSocket Transport
3
+ // Primary internet transport over HTTPS port 443.
4
+ // Connectable devices run a WS server; non-connectable devices
5
+ // maintain outbound WS connections to connectable peers.
6
+ // ============================================================
7
+
8
+ import { WebSocket, WebSocketServer, type RawData } from 'ws';
9
+ import type { IncomingMessage } from 'node:http';
10
+ import type {
11
+ Transport,
12
+ Packet,
13
+ } from '../../types.js';
14
+ import {
15
+ serializePacket,
16
+ deserializePacket,
17
+ HEADER_SIZE,
18
+ } from './serialize.js';
19
+
20
+ // ---- Constants ----
21
+
22
+ const DEFAULT_PORT = 443;
23
+ const DEV_FALLBACK_PORT = 8443;
24
+ const HEARTBEAT_INTERVAL_MS = 30_000;
25
+ const RECONNECT_BASE_MS = 1_000;
26
+ const RECONNECT_MAX_MS = 30_000;
27
+ const PONG_TIMEOUT_MS = 10_000;
28
+
29
+ // ---- Transport Events ----
30
+
31
+ export type WebSocketTransportEventType =
32
+ | 'peer_connected'
33
+ | 'peer_disconnected'
34
+ | 'error';
35
+
36
+ export interface PeerConnectedEvent {
37
+ peerId: string;
38
+ address: string;
39
+ direction: 'inbound' | 'outbound';
40
+ }
41
+
42
+ export interface PeerDisconnectedEvent {
43
+ peerId: string;
44
+ code: number;
45
+ reason: string;
46
+ }
47
+
48
+ export interface TransportErrorEvent {
49
+ peerId?: string;
50
+ error: Error;
51
+ context: string;
52
+ }
53
+
54
+ export type WebSocketTransportEvent =
55
+ | { type: 'peer_connected'; data: PeerConnectedEvent }
56
+ | { type: 'peer_disconnected'; data: PeerDisconnectedEvent }
57
+ | { type: 'error'; data: TransportErrorEvent };
58
+
59
+ type EventHandler = (event: WebSocketTransportEvent) => void;
60
+
61
+ // ---- Peer Connection State ----
62
+
63
+ interface PeerConnection {
64
+ peerId: string;
65
+ socket: WebSocket;
66
+ direction: 'inbound' | 'outbound';
67
+ address: string;
68
+ connectedAt: number;
69
+ lastActivity: number;
70
+ heartbeatTimer?: ReturnType<typeof setInterval>;
71
+ pongTimer?: ReturnType<typeof setTimeout>;
72
+ alive: boolean;
73
+ }
74
+
75
+ // ---- Reconnect State (outbound only) ----
76
+
77
+ interface ReconnectState {
78
+ address: string;
79
+ attempt: number;
80
+ timer?: ReturnType<typeof setTimeout>;
81
+ cancelled: boolean;
82
+ }
83
+
84
+ // ---- WebSocketTransport ----
85
+
86
+ export class WebSocketTransport implements Transport {
87
+ readonly type = 'internet' as const;
88
+
89
+ private server: WebSocketServer | null = null;
90
+ private serverPort: number | null = null;
91
+ private peers: Map<string, PeerConnection> = new Map();
92
+ private reconnectStates: Map<string, ReconnectState> = new Map();
93
+ private receiveCallbacks: Array<(packet: Packet, source: string) => void> = [];
94
+ private eventHandlers: Set<EventHandler> = new Set();
95
+ private running = false;
96
+
97
+ // ---- Transport interface ----
98
+
99
+ async start(): Promise<void> {
100
+ this.running = true;
101
+ }
102
+
103
+ async stop(): Promise<void> {
104
+ this.running = false;
105
+
106
+ // Cancel all reconnect timers
107
+ for (const state of this.reconnectStates.values()) {
108
+ state.cancelled = true;
109
+ if (state.timer) clearTimeout(state.timer);
110
+ }
111
+ this.reconnectStates.clear();
112
+
113
+ // Close all peer connections
114
+ const peerIds = [...this.peers.keys()];
115
+ for (const peerId of peerIds) {
116
+ this.disconnectPeer(peerId);
117
+ }
118
+
119
+ // Shut down server
120
+ if (this.server) {
121
+ await new Promise<void>((resolve, reject) => {
122
+ this.server!.close((err) => {
123
+ if (err) reject(err);
124
+ else resolve();
125
+ });
126
+ });
127
+ this.server = null;
128
+ this.serverPort = null;
129
+ }
130
+ }
131
+
132
+ async isAvailable(): Promise<boolean> {
133
+ return this.running;
134
+ }
135
+
136
+ async send(packet: Packet, destination: string): Promise<void> {
137
+ const peer = this.peers.get(destination);
138
+ if (!peer) {
139
+ throw new Error(`No connection to peer: ${destination}`);
140
+ }
141
+ if (peer.socket.readyState !== WebSocket.OPEN) {
142
+ throw new Error(`Connection to peer ${destination} is not open (state=${peer.socket.readyState})`);
143
+ }
144
+
145
+ const binary = serializePacket(packet);
146
+
147
+ return new Promise<void>((resolve, reject) => {
148
+ peer.socket.send(binary, { binary: true }, (err) => {
149
+ if (err) {
150
+ reject(new Error(`Failed to send to ${destination}: ${err.message}`));
151
+ } else {
152
+ peer.lastActivity = Date.now();
153
+ resolve();
154
+ }
155
+ });
156
+ });
157
+ }
158
+
159
+ onReceive(callback: (packet: Packet, source: string) => void): void {
160
+ this.receiveCallbacks.push(callback);
161
+ }
162
+
163
+ // ---- Event system ----
164
+
165
+ on(handler: EventHandler): void {
166
+ this.eventHandlers.add(handler);
167
+ }
168
+
169
+ off(handler: EventHandler): void {
170
+ this.eventHandlers.delete(handler);
171
+ }
172
+
173
+ private emit(event: WebSocketTransportEvent): void {
174
+ for (const handler of this.eventHandlers) {
175
+ try {
176
+ handler(event);
177
+ } catch {
178
+ // Swallow listener errors to protect transport stability
179
+ }
180
+ }
181
+ }
182
+
183
+ // ---- Server mode (connectable) ----
184
+
185
+ async startServer(port?: number): Promise<void> {
186
+ if (this.server) {
187
+ throw new Error('WebSocket server already running');
188
+ }
189
+
190
+ const targetPort = port ?? DEFAULT_PORT;
191
+
192
+ try {
193
+ await this.listenOnPort(targetPort);
194
+ } catch (err) {
195
+ // If default port 443 fails (permission / in-use), fall back to 8443
196
+ if (!port && targetPort === DEFAULT_PORT) {
197
+ await this.listenOnPort(DEV_FALLBACK_PORT);
198
+ } else {
199
+ throw err;
200
+ }
201
+ }
202
+
203
+ this.running = true;
204
+ }
205
+
206
+ private listenOnPort(port: number): Promise<void> {
207
+ return new Promise<void>((resolve, reject) => {
208
+ const wss = new WebSocketServer({ port }, () => {
209
+ this.server = wss;
210
+ this.serverPort = port;
211
+ resolve();
212
+ });
213
+
214
+ wss.on('error', (err) => {
215
+ // If we haven't assigned this.server yet, this is a startup error
216
+ if (!this.server) {
217
+ reject(err);
218
+ return;
219
+ }
220
+ this.emit({
221
+ type: 'error',
222
+ data: { error: err instanceof Error ? err : new Error(String(err)), context: 'server' },
223
+ });
224
+ });
225
+
226
+ wss.on('connection', (socket: WebSocket, req: IncomingMessage) => {
227
+ this.handleInboundConnection(socket, req);
228
+ });
229
+ });
230
+ }
231
+
232
+ private handleInboundConnection(socket: WebSocket, req: IncomingMessage): void {
233
+ const remoteAddr =
234
+ (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ??
235
+ req.socket.remoteAddress ??
236
+ 'unknown';
237
+ const remotePort = req.socket.remotePort ?? 0;
238
+ const address = `${remoteAddr}:${remotePort}`;
239
+
240
+ // Derive a peer ID from the first message (protocol-level peer identification)
241
+ // For now, use address as temporary ID until a proper handshake message arrives.
242
+ // The caller can re-map peer IDs after handshake.
243
+ const peerId = address;
244
+
245
+ const conn: PeerConnection = {
246
+ peerId,
247
+ socket,
248
+ direction: 'inbound',
249
+ address,
250
+ connectedAt: Date.now(),
251
+ lastActivity: Date.now(),
252
+ alive: true,
253
+ };
254
+
255
+ this.peers.set(peerId, conn);
256
+ this.startHeartbeat(conn);
257
+
258
+ socket.binaryType = 'arraybuffer';
259
+
260
+ socket.on('message', (raw: RawData) => {
261
+ this.handleMessage(raw, peerId);
262
+ });
263
+
264
+ socket.on('close', (code: number, reason: Buffer) => {
265
+ this.cleanupPeer(peerId, code, reason.toString('utf-8'));
266
+ });
267
+
268
+ socket.on('error', (err: Error) => {
269
+ this.emit({
270
+ type: 'error',
271
+ data: { peerId, error: err, context: 'inbound_connection' },
272
+ });
273
+ });
274
+
275
+ socket.on('pong', () => {
276
+ conn.alive = true;
277
+ conn.lastActivity = Date.now();
278
+ if (conn.pongTimer) {
279
+ clearTimeout(conn.pongTimer);
280
+ conn.pongTimer = undefined;
281
+ }
282
+ });
283
+
284
+ this.emit({
285
+ type: 'peer_connected',
286
+ data: { peerId, address, direction: 'inbound' },
287
+ });
288
+ }
289
+
290
+ // ---- Client mode (non-connectable) ----
291
+
292
+ async connectToPeer(address: string): Promise<void> {
293
+ // Normalize address to a ws:// URL if not already
294
+ const url = this.normalizeAddress(address);
295
+ const peerId = address;
296
+
297
+ if (this.peers.has(peerId)) {
298
+ throw new Error(`Already connected to peer: ${peerId}`);
299
+ }
300
+
301
+ // Cancel any existing reconnect for this peer
302
+ this.cancelReconnect(peerId);
303
+
304
+ await this.establishConnection(peerId, url);
305
+ }
306
+
307
+ private normalizeAddress(address: string): string {
308
+ if (address.startsWith('ws://') || address.startsWith('wss://')) {
309
+ return address;
310
+ }
311
+ // Default to wss:// (port 443)
312
+ return `wss://${address}`;
313
+ }
314
+
315
+ private establishConnection(peerId: string, url: string): Promise<void> {
316
+ return new Promise<void>((resolve, reject) => {
317
+ const socket = new WebSocket(url);
318
+ socket.binaryType = 'arraybuffer';
319
+
320
+ let resolved = false;
321
+
322
+ socket.on('open', () => {
323
+ resolved = true;
324
+
325
+ const conn: PeerConnection = {
326
+ peerId,
327
+ socket,
328
+ direction: 'outbound',
329
+ address: url,
330
+ connectedAt: Date.now(),
331
+ lastActivity: Date.now(),
332
+ alive: true,
333
+ };
334
+
335
+ this.peers.set(peerId, conn);
336
+ this.startHeartbeat(conn);
337
+
338
+ // Reset reconnect state on successful connect
339
+ this.cancelReconnect(peerId);
340
+
341
+ socket.on('message', (raw: RawData) => {
342
+ this.handleMessage(raw, peerId);
343
+ });
344
+
345
+ socket.on('close', (code: number, reason: Buffer) => {
346
+ this.cleanupPeer(peerId, code, reason.toString('utf-8'));
347
+ this.scheduleReconnect(peerId, url);
348
+ });
349
+
350
+ socket.on('error', (err: Error) => {
351
+ this.emit({
352
+ type: 'error',
353
+ data: { peerId, error: err, context: 'outbound_connection' },
354
+ });
355
+ });
356
+
357
+ socket.on('pong', () => {
358
+ const c = this.peers.get(peerId);
359
+ if (c) {
360
+ c.alive = true;
361
+ c.lastActivity = Date.now();
362
+ if (c.pongTimer) {
363
+ clearTimeout(c.pongTimer);
364
+ c.pongTimer = undefined;
365
+ }
366
+ }
367
+ });
368
+
369
+ this.emit({
370
+ type: 'peer_connected',
371
+ data: { peerId, address: url, direction: 'outbound' },
372
+ });
373
+
374
+ resolve();
375
+ });
376
+
377
+ socket.on('error', (err: Error) => {
378
+ if (!resolved) {
379
+ resolved = true;
380
+ reject(new Error(`Failed to connect to ${url}: ${err.message}`));
381
+ }
382
+ });
383
+ });
384
+ }
385
+
386
+ // ---- Reconnect with exponential backoff ----
387
+
388
+ private scheduleReconnect(peerId: string, url: string): void {
389
+ if (!this.running) return;
390
+
391
+ let state = this.reconnectStates.get(peerId);
392
+ if (!state) {
393
+ state = { address: url, attempt: 0, cancelled: false };
394
+ this.reconnectStates.set(peerId, state);
395
+ }
396
+
397
+ if (state.cancelled) return;
398
+
399
+ const delay = Math.min(
400
+ RECONNECT_BASE_MS * Math.pow(2, state.attempt),
401
+ RECONNECT_MAX_MS,
402
+ );
403
+ state.attempt++;
404
+
405
+ state.timer = setTimeout(async () => {
406
+ if (state!.cancelled || !this.running) return;
407
+
408
+ try {
409
+ await this.establishConnection(peerId, url);
410
+ } catch {
411
+ // establishConnection failed — schedule another attempt
412
+ this.scheduleReconnect(peerId, url);
413
+ }
414
+ }, delay);
415
+ }
416
+
417
+ private cancelReconnect(peerId: string): void {
418
+ const state = this.reconnectStates.get(peerId);
419
+ if (state) {
420
+ state.cancelled = true;
421
+ if (state.timer) clearTimeout(state.timer);
422
+ this.reconnectStates.delete(peerId);
423
+ }
424
+ }
425
+
426
+ // ---- Heartbeat / keepalive ----
427
+
428
+ private startHeartbeat(conn: PeerConnection): void {
429
+ conn.heartbeatTimer = setInterval(() => {
430
+ if (!conn.alive) {
431
+ // Previous ping was not answered — terminate
432
+ conn.socket.terminate();
433
+ return;
434
+ }
435
+ conn.alive = false;
436
+ conn.socket.ping();
437
+
438
+ // Set a pong timeout — if no pong within PONG_TIMEOUT_MS, terminate
439
+ conn.pongTimer = setTimeout(() => {
440
+ if (!conn.alive) {
441
+ conn.socket.terminate();
442
+ }
443
+ }, PONG_TIMEOUT_MS);
444
+ }, HEARTBEAT_INTERVAL_MS);
445
+ }
446
+
447
+ private stopHeartbeat(conn: PeerConnection): void {
448
+ if (conn.heartbeatTimer) {
449
+ clearInterval(conn.heartbeatTimer);
450
+ conn.heartbeatTimer = undefined;
451
+ }
452
+ if (conn.pongTimer) {
453
+ clearTimeout(conn.pongTimer);
454
+ conn.pongTimer = undefined;
455
+ }
456
+ }
457
+
458
+ // ---- Message handling ----
459
+
460
+ private handleMessage(raw: RawData, source: string): void {
461
+ const peer = this.peers.get(source);
462
+ if (peer) {
463
+ peer.lastActivity = Date.now();
464
+ }
465
+
466
+ let data: Uint8Array;
467
+ if (raw instanceof ArrayBuffer) {
468
+ data = new Uint8Array(raw);
469
+ } else if (raw instanceof Buffer) {
470
+ data = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
471
+ } else if (Array.isArray(raw)) {
472
+ data = new Uint8Array(Buffer.concat(raw));
473
+ } else {
474
+ this.emit({
475
+ type: 'error',
476
+ data: {
477
+ peerId: source,
478
+ error: new Error('Received non-binary message'),
479
+ context: 'message_handling',
480
+ },
481
+ });
482
+ return;
483
+ }
484
+
485
+ let packet: Packet;
486
+ try {
487
+ packet = deserializePacket(data);
488
+ } catch (err) {
489
+ this.emit({
490
+ type: 'error',
491
+ data: {
492
+ peerId: source,
493
+ error: err instanceof Error ? err : new Error(String(err)),
494
+ context: 'deserialization',
495
+ },
496
+ });
497
+ return;
498
+ }
499
+
500
+ for (const callback of this.receiveCallbacks) {
501
+ try {
502
+ callback(packet, source);
503
+ } catch {
504
+ // Swallow callback errors
505
+ }
506
+ }
507
+ }
508
+
509
+ // ---- Peer management ----
510
+
511
+ getPeers(): string[] {
512
+ return [...this.peers.keys()];
513
+ }
514
+
515
+ isConnected(peerId: string): boolean {
516
+ const peer = this.peers.get(peerId);
517
+ return peer !== undefined && peer.socket.readyState === WebSocket.OPEN;
518
+ }
519
+
520
+ disconnect(peerId: string): void {
521
+ this.disconnectPeer(peerId);
522
+ }
523
+
524
+ private disconnectPeer(peerId: string): void {
525
+ this.cancelReconnect(peerId);
526
+
527
+ const peer = this.peers.get(peerId);
528
+ if (!peer) return;
529
+
530
+ this.stopHeartbeat(peer);
531
+
532
+ if (
533
+ peer.socket.readyState === WebSocket.OPEN ||
534
+ peer.socket.readyState === WebSocket.CONNECTING
535
+ ) {
536
+ peer.socket.close(1000, 'disconnect');
537
+ }
538
+
539
+ this.peers.delete(peerId);
540
+
541
+ this.emit({
542
+ type: 'peer_disconnected',
543
+ data: { peerId, code: 1000, reason: 'local_disconnect' },
544
+ });
545
+ }
546
+
547
+ private cleanupPeer(peerId: string, code: number, reason: string): void {
548
+ const peer = this.peers.get(peerId);
549
+ if (!peer) return;
550
+
551
+ this.stopHeartbeat(peer);
552
+ this.peers.delete(peerId);
553
+
554
+ this.emit({
555
+ type: 'peer_disconnected',
556
+ data: { peerId, code, reason },
557
+ });
558
+ }
559
+
560
+ // ---- Broadcast ----
561
+
562
+ async broadcast(packet: Packet): Promise<void> {
563
+ const binary = serializePacket(packet);
564
+ const errors: Error[] = [];
565
+
566
+ const promises = [...this.peers.entries()].map(
567
+ ([peerId, peer]) =>
568
+ new Promise<void>((resolve) => {
569
+ if (peer.socket.readyState !== WebSocket.OPEN) {
570
+ resolve();
571
+ return;
572
+ }
573
+ peer.socket.send(binary, { binary: true }, (err) => {
574
+ if (err) {
575
+ errors.push(
576
+ new Error(`Broadcast to ${peerId} failed: ${err.message}`),
577
+ );
578
+ } else {
579
+ peer.lastActivity = Date.now();
580
+ }
581
+ resolve();
582
+ });
583
+ }),
584
+ );
585
+
586
+ await Promise.all(promises);
587
+
588
+ if (errors.length > 0 && errors.length === this.peers.size) {
589
+ throw new AggregateError(errors, 'Broadcast failed to all peers');
590
+ }
591
+ }
592
+
593
+ // ---- Connectability detection ----
594
+
595
+ async detectConnectability(port?: number): Promise<boolean> {
596
+ const testPort = port ?? DEFAULT_PORT;
597
+
598
+ return new Promise<boolean>((resolve) => {
599
+ let testServer: WebSocketServer | null = null;
600
+
601
+ const cleanup = () => {
602
+ if (testServer) {
603
+ testServer.close();
604
+ testServer = null;
605
+ }
606
+ };
607
+
608
+ try {
609
+ testServer = new WebSocketServer({ port: testPort }, () => {
610
+ // We were able to bind — port is available locally.
611
+ // A full connectability check would also verify external
612
+ // reachability (e.g., via a coordination server or self-connect),
613
+ // but binding success is the necessary first condition.
614
+ cleanup();
615
+ resolve(true);
616
+ });
617
+
618
+ testServer.on('error', () => {
619
+ cleanup();
620
+ resolve(false);
621
+ });
622
+ } catch {
623
+ cleanup();
624
+ resolve(false);
625
+ }
626
+ });
627
+ }
628
+
629
+ // ---- Accessors ----
630
+
631
+ getServerPort(): number | null {
632
+ return this.serverPort;
633
+ }
634
+
635
+ isServerRunning(): boolean {
636
+ return this.server !== null;
637
+ }
638
+
639
+ getPeerInfo(peerId: string): Readonly<Omit<PeerConnection, 'socket' | 'heartbeatTimer' | 'pongTimer'>> | null {
640
+ const peer = this.peers.get(peerId);
641
+ if (!peer) return null;
642
+ return {
643
+ peerId: peer.peerId,
644
+ direction: peer.direction,
645
+ address: peer.address,
646
+ connectedAt: peer.connectedAt,
647
+ lastActivity: peer.lastActivity,
648
+ alive: peer.alive,
649
+ };
650
+ }
651
+ }
652
+
653
+ // ---- Exports ----
654
+
655
+ export { serializePacket, deserializePacket, HEADER_SIZE };
656
+
657
+ export type {
658
+ PeerConnection,
659
+ ReconnectState,
660
+ };
@@ -0,0 +1,68 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Packet Wire Serialization
3
+ // Pure functions with no platform-specific dependencies.
4
+ // Shared by WebSocketTransport (Node.js) and BrowserTransport.
5
+ //
6
+ // Binary layout (all big-endian):
7
+ // [0] version u8
8
+ // [1] flags u8
9
+ // [2..9] destHash 8 bytes
10
+ // [10..25] senderEphId 16 bytes
11
+ // [26] ttl u8
12
+ // [27..30] payloadLen u32
13
+ // [31..] encrypted payload
14
+ //
15
+ // Total header = 31 bytes.
16
+ // ============================================================
17
+
18
+ import type { Packet, PacketFlags } from '../../types.js';
19
+
20
+ export const HEADER_SIZE = 31;
21
+
22
+ export function serializePacket(packet: Packet): Uint8Array {
23
+ const buf = new Uint8Array(HEADER_SIZE + packet.encryptedPayload.length);
24
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
25
+
26
+ buf[0] = packet.version;
27
+ buf[1] = packet.flags;
28
+ buf.set(packet.destHash.subarray(0, 8), 2);
29
+ buf.set(packet.senderEphemeralId.subarray(0, 16), 10);
30
+ buf[26] = packet.ttl;
31
+ view.setUint32(27, packet.payloadLength, false);
32
+ buf.set(packet.encryptedPayload, HEADER_SIZE);
33
+
34
+ return buf;
35
+ }
36
+
37
+ export function deserializePacket(data: Uint8Array): Packet {
38
+ if (data.length < HEADER_SIZE) {
39
+ throw new Error(`Packet too small: ${data.length} bytes (min ${HEADER_SIZE})`);
40
+ }
41
+
42
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
43
+
44
+ const version = data[0];
45
+ const flags = data[1] as PacketFlags;
46
+ const destHash = data.slice(2, 10);
47
+ const senderEphemeralId = data.slice(10, 26);
48
+ const ttl = data[26];
49
+ const payloadLength = view.getUint32(27, false);
50
+
51
+ const encryptedPayload = data.slice(HEADER_SIZE, HEADER_SIZE + payloadLength);
52
+
53
+ if (encryptedPayload.length !== payloadLength) {
54
+ throw new Error(
55
+ `Payload length mismatch: header says ${payloadLength}, got ${encryptedPayload.length}`,
56
+ );
57
+ }
58
+
59
+ return {
60
+ version,
61
+ flags,
62
+ destHash,
63
+ senderEphemeralId,
64
+ ttl,
65
+ payloadLength,
66
+ encryptedPayload,
67
+ };
68
+ }