@silicaclaw/cli 1.0.0-beta.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 (77) hide show
  1. package/ARCHITECTURE.md +137 -0
  2. package/CHANGELOG.md +411 -0
  3. package/DEMO_GUIDE.md +89 -0
  4. package/INSTALL.md +156 -0
  5. package/README.md +244 -0
  6. package/RELEASE_NOTES_v1.0.md +65 -0
  7. package/ROADMAP.md +48 -0
  8. package/SOCIAL_MD_SPEC.md +122 -0
  9. package/VERSION +1 -0
  10. package/apps/local-console/package.json +23 -0
  11. package/apps/local-console/public/assets/README.md +5 -0
  12. package/apps/local-console/public/assets/silicaclaw-logo.png +0 -0
  13. package/apps/local-console/public/index.html +1602 -0
  14. package/apps/local-console/src/server.ts +1656 -0
  15. package/apps/local-console/src/socialRoutes.ts +90 -0
  16. package/apps/local-console/tsconfig.json +7 -0
  17. package/apps/public-explorer/package.json +20 -0
  18. package/apps/public-explorer/public/assets/README.md +5 -0
  19. package/apps/public-explorer/public/assets/silicaclaw-logo.png +0 -0
  20. package/apps/public-explorer/public/index.html +483 -0
  21. package/apps/public-explorer/src/server.ts +32 -0
  22. package/apps/public-explorer/tsconfig.json +7 -0
  23. package/docs/QUICK_START.md +48 -0
  24. package/docs/assets/README.md +8 -0
  25. package/docs/assets/banner.svg +25 -0
  26. package/docs/assets/silicaclaw-logo.png +0 -0
  27. package/docs/assets/silicaclaw-og.png +0 -0
  28. package/docs/release/GITHUB_RELEASE_v1.0-beta.md +143 -0
  29. package/docs/screenshots/README.md +8 -0
  30. package/docs/screenshots/v0.3.1-explorer-search.svg +9 -0
  31. package/docs/screenshots/v0.3.1-machine-a-network.svg +9 -0
  32. package/docs/screenshots/v0.3.1-machine-b-peers.svg +9 -0
  33. package/docs/screenshots/v0.3.1-stale-transition.svg +9 -0
  34. package/openclaw.social.md.example +28 -0
  35. package/package.json +64 -0
  36. package/packages/core/package.json +13 -0
  37. package/packages/core/src/crypto.ts +55 -0
  38. package/packages/core/src/directory.ts +171 -0
  39. package/packages/core/src/identity.ts +14 -0
  40. package/packages/core/src/index.ts +11 -0
  41. package/packages/core/src/indexing.ts +42 -0
  42. package/packages/core/src/presence.ts +24 -0
  43. package/packages/core/src/profile.ts +39 -0
  44. package/packages/core/src/publicProfileSummary.ts +180 -0
  45. package/packages/core/src/socialConfig.ts +440 -0
  46. package/packages/core/src/socialResolver.ts +281 -0
  47. package/packages/core/src/socialTemplate.ts +97 -0
  48. package/packages/core/src/types.ts +43 -0
  49. package/packages/core/tsconfig.json +7 -0
  50. package/packages/network/package.json +10 -0
  51. package/packages/network/src/abstractions/messageEnvelope.ts +80 -0
  52. package/packages/network/src/abstractions/peerDiscovery.ts +49 -0
  53. package/packages/network/src/abstractions/topicCodec.ts +4 -0
  54. package/packages/network/src/abstractions/transport.ts +40 -0
  55. package/packages/network/src/codec/jsonMessageEnvelopeCodec.ts +22 -0
  56. package/packages/network/src/codec/jsonTopicCodec.ts +11 -0
  57. package/packages/network/src/discovery/heartbeatPeerDiscovery.ts +173 -0
  58. package/packages/network/src/index.ts +16 -0
  59. package/packages/network/src/localEventBus.ts +61 -0
  60. package/packages/network/src/mock.ts +27 -0
  61. package/packages/network/src/realPreview.ts +436 -0
  62. package/packages/network/src/transport/udpLanBroadcastTransport.ts +173 -0
  63. package/packages/network/src/types.ts +6 -0
  64. package/packages/network/src/webrtcPreview.ts +1052 -0
  65. package/packages/network/tsconfig.json +7 -0
  66. package/packages/storage/package.json +13 -0
  67. package/packages/storage/src/index.ts +3 -0
  68. package/packages/storage/src/jsonRepo.ts +25 -0
  69. package/packages/storage/src/repos.ts +46 -0
  70. package/packages/storage/src/socialRuntimeRepo.ts +51 -0
  71. package/packages/storage/tsconfig.json +7 -0
  72. package/scripts/functional-check.mjs +165 -0
  73. package/scripts/install-logo.sh +53 -0
  74. package/scripts/quickstart.sh +144 -0
  75. package/scripts/silicaclaw-cli.mjs +88 -0
  76. package/scripts/webrtc-signaling-server.mjs +249 -0
  77. package/social.md.example +30 -0
@@ -0,0 +1,1052 @@
1
+ import { randomUUID } from "crypto";
2
+ import { NetworkAdapter } from "./types";
3
+ import {
4
+ MessageEnvelopeCodec,
5
+ NetworkMessageEnvelope,
6
+ validateNetworkMessageEnvelope,
7
+ } from "./abstractions/messageEnvelope";
8
+ import { TopicCodec } from "./abstractions/topicCodec";
9
+ import { JsonMessageEnvelopeCodec } from "./codec/jsonMessageEnvelopeCodec";
10
+ import { JsonTopicCodec } from "./codec/jsonTopicCodec";
11
+
12
+ type WebRTCPreviewOptions = {
13
+ peerId?: string;
14
+ namespace?: string;
15
+ signalingUrl?: string;
16
+ signalingUrls?: string[];
17
+ room?: string;
18
+ seedPeers?: string[];
19
+ bootstrapHints?: string[];
20
+ bootstrapSources?: string[];
21
+ maxMessageBytes?: number;
22
+ pollIntervalMs?: number;
23
+ maxFutureDriftMs?: number;
24
+ maxPastDriftMs?: number;
25
+ discoveryEventsLimit?: number;
26
+ };
27
+
28
+ type PeerStatus = "connecting" | "online" | "stale";
29
+
30
+ type WebRTCConnectionState =
31
+ | "new"
32
+ | "connecting"
33
+ | "connected"
34
+ | "disconnected"
35
+ | "failed"
36
+ | "closed"
37
+ | "unknown";
38
+
39
+ type WebRTCDataChannelState = "connecting" | "open" | "closing" | "closed" | "unknown";
40
+
41
+ type PeerSession = {
42
+ peer_id: string;
43
+ status: PeerStatus;
44
+ first_seen_at: number;
45
+ last_seen_at: number;
46
+ messages_seen: number;
47
+ reconnect_attempts: number;
48
+ last_reconnect_attempt_at: number;
49
+ connection_state: WebRTCConnectionState;
50
+ datachannel_state: WebRTCDataChannelState;
51
+ connection: any | null;
52
+ channel: any | null;
53
+ remote_description_set: boolean;
54
+ pending_ice: any[];
55
+ seen_ice_keys: Set<string>;
56
+ last_offer_sdp: string;
57
+ last_answer_sdp: string;
58
+ };
59
+
60
+ type StateSummary<T extends string> = Record<T, number>;
61
+
62
+ type WebRTCDiagnostics = {
63
+ adapter: "webrtc-preview";
64
+ peer_id: string;
65
+ namespace: string;
66
+ room: string;
67
+ signaling_url: string;
68
+ signaling_endpoints: string[];
69
+ bootstrap_sources: string[];
70
+ seed_peers_count: number;
71
+ bootstrap_hints_count: number;
72
+ discovery_events_total: number;
73
+ last_discovery_event_at: number;
74
+ discovery_events: DiscoveryEvent[];
75
+ connection_states_summary: StateSummary<WebRTCConnectionState>;
76
+ datachannel_states_summary: StateSummary<WebRTCDataChannelState>;
77
+ signaling_messages_sent_total: number;
78
+ signaling_messages_received_total: number;
79
+ reconnect_attempts_total: number;
80
+ active_webrtc_peers: number;
81
+ components: {
82
+ transport: string;
83
+ discovery: string;
84
+ envelope_codec: string;
85
+ topic_codec: string;
86
+ };
87
+ limits: {
88
+ max_message_bytes: number;
89
+ max_future_drift_ms: number;
90
+ max_past_drift_ms: number;
91
+ };
92
+ config: {
93
+ started: boolean;
94
+ topic_handler_count: number;
95
+ poll_interval_ms: number;
96
+ };
97
+ peers: {
98
+ total: number;
99
+ online: number;
100
+ stale: number;
101
+ items: Array<{
102
+ peer_id: string;
103
+ status: PeerStatus;
104
+ first_seen_at: number;
105
+ last_seen_at: number;
106
+ messages_seen: number;
107
+ reconnect_attempts: number;
108
+ connection_state: WebRTCConnectionState;
109
+ datachannel_state: WebRTCDataChannelState;
110
+ }>;
111
+ };
112
+ stats: {
113
+ publish_attempted: number;
114
+ publish_sent: number;
115
+ received_total: number;
116
+ delivered_total: number;
117
+ dropped_malformed: number;
118
+ dropped_oversized: number;
119
+ dropped_namespace_mismatch: number;
120
+ dropped_timestamp_future_drift: number;
121
+ dropped_timestamp_past_drift: number;
122
+ dropped_decode_failed: number;
123
+ dropped_self: number;
124
+ dropped_topic_decode_error: number;
125
+ dropped_handler_error: number;
126
+ signaling_errors: number;
127
+ invalid_signaling_payload_total: number;
128
+ duplicate_sdp_total: number;
129
+ duplicate_ice_total: number;
130
+ start_errors: number;
131
+ stop_errors: number;
132
+ received_validated: number;
133
+ };
134
+ };
135
+
136
+ type DiscoveryEventType =
137
+ | "peer_joined"
138
+ | "peer_stale"
139
+ | "peer_removed"
140
+ | "signaling_connected"
141
+ | "signaling_disconnected"
142
+ | "reconnect_started"
143
+ | "reconnect_succeeded"
144
+ | "reconnect_failed"
145
+ | "malformed_signal_dropped"
146
+ | "duplicate_signal_dropped";
147
+
148
+ type DiscoveryEvent = {
149
+ id: string;
150
+ type: DiscoveryEventType;
151
+ at: number;
152
+ peer_id?: string;
153
+ endpoint?: string;
154
+ detail?: string;
155
+ };
156
+
157
+ function now(): number {
158
+ return Date.now();
159
+ }
160
+
161
+ function toBuffer(data: unknown): Buffer | null {
162
+ if (Buffer.isBuffer(data)) return data;
163
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
164
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
165
+ if (typeof data === "string") return Buffer.from(data, "utf8");
166
+ return null;
167
+ }
168
+
169
+ function connectionStateOrUnknown(connection: any): WebRTCConnectionState {
170
+ const value = String(connection?.connectionState ?? connection?.iceConnectionState ?? "unknown");
171
+ if (
172
+ value === "new" ||
173
+ value === "connecting" ||
174
+ value === "connected" ||
175
+ value === "disconnected" ||
176
+ value === "failed" ||
177
+ value === "closed"
178
+ ) {
179
+ return value;
180
+ }
181
+ return "unknown";
182
+ }
183
+
184
+ function dataChannelStateOrUnknown(channel: any): WebRTCDataChannelState {
185
+ const value = String(channel?.readyState ?? "unknown");
186
+ if (value === "connecting" || value === "open" || value === "closing" || value === "closed") {
187
+ return value;
188
+ }
189
+ return "unknown";
190
+ }
191
+
192
+ function iceKey(candidatePayload: any): string {
193
+ return JSON.stringify({
194
+ candidate: candidatePayload?.candidate ?? "",
195
+ sdpMid: candidatePayload?.sdpMid ?? "",
196
+ sdpMLineIndex: candidatePayload?.sdpMLineIndex ?? "",
197
+ });
198
+ }
199
+
200
+ function dedupeArray(values: string[]): string[] {
201
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
202
+ }
203
+
204
+ export class WebRTCPreviewAdapter implements NetworkAdapter {
205
+ private readonly peerId: string;
206
+ private readonly namespace: string;
207
+ private readonly signalingUrl: string;
208
+ private readonly signalingEndpoints: string[];
209
+ private readonly room: string;
210
+ private readonly seedPeers: string[];
211
+ private readonly bootstrapHints: string[];
212
+ private readonly bootstrapSources: string[];
213
+ private readonly maxMessageBytes: number;
214
+ private readonly pollIntervalMs: number;
215
+ private readonly maxFutureDriftMs: number;
216
+ private readonly maxPastDriftMs: number;
217
+ private readonly discoveryEventsLimit: number;
218
+
219
+ private readonly envelopeCodec: MessageEnvelopeCodec;
220
+ private readonly topicCodec: TopicCodec;
221
+ private readonly handlers = new Map<string, Set<(data: any) => void>>();
222
+ private readonly sessions = new Map<string, PeerSession>();
223
+
224
+ private started = false;
225
+ private poller: NodeJS.Timeout | null = null;
226
+ private wrtc: any = null;
227
+ private processedSignalIds = new Map<string, number>();
228
+ private signalingConnectivity = new Map<string, boolean>();
229
+ private signalingIndex = 0;
230
+ private activeSignalingEndpoint = "";
231
+ private discoveryEvents: DiscoveryEvent[] = [];
232
+ private discoveryEventsTotal = 0;
233
+ private lastDiscoveryEventAt = 0;
234
+
235
+ private signalingMessagesSentTotal = 0;
236
+ private signalingMessagesReceivedTotal = 0;
237
+ private reconnectAttemptsTotal = 0;
238
+
239
+ private stats: WebRTCDiagnostics["stats"] = {
240
+ publish_attempted: 0,
241
+ publish_sent: 0,
242
+ received_total: 0,
243
+ delivered_total: 0,
244
+ dropped_malformed: 0,
245
+ dropped_oversized: 0,
246
+ dropped_namespace_mismatch: 0,
247
+ dropped_timestamp_future_drift: 0,
248
+ dropped_timestamp_past_drift: 0,
249
+ dropped_decode_failed: 0,
250
+ dropped_self: 0,
251
+ dropped_topic_decode_error: 0,
252
+ dropped_handler_error: 0,
253
+ signaling_errors: 0,
254
+ invalid_signaling_payload_total: 0,
255
+ duplicate_sdp_total: 0,
256
+ duplicate_ice_total: 0,
257
+ start_errors: 0,
258
+ stop_errors: 0,
259
+ received_validated: 0,
260
+ };
261
+
262
+ constructor(options: WebRTCPreviewOptions = {}) {
263
+ this.peerId = options.peerId ?? `webrtc-${process.pid}-${Math.random().toString(36).slice(2, 9)}`;
264
+ this.namespace = (options.namespace ?? "silicaclaw.preview").trim() || "silicaclaw.preview";
265
+ const configuredSignalingUrls = dedupeArray([
266
+ ...(options.signalingUrls ?? []),
267
+ options.signalingUrl ?? "",
268
+ ]).map((url) => url.replace(/\/+$/, ""));
269
+ this.signalingEndpoints =
270
+ configuredSignalingUrls.length > 0 ? configuredSignalingUrls : ["http://localhost:4510"];
271
+ this.signalingUrl = this.signalingEndpoints[0];
272
+ this.room = (options.room ?? "silicaclaw-room").trim() || "silicaclaw-room";
273
+ this.seedPeers = dedupeArray(options.seedPeers ?? []);
274
+ this.bootstrapHints = dedupeArray(options.bootstrapHints ?? []);
275
+ this.bootstrapSources =
276
+ dedupeArray(options.bootstrapSources ?? []).length > 0
277
+ ? dedupeArray(options.bootstrapSources ?? [])
278
+ : [
279
+ configuredSignalingUrls.length > 0
280
+ ? "config:signaling_urls"
281
+ : options.signalingUrl
282
+ ? "config:signaling_url"
283
+ : "default:signaling_url",
284
+ options.room ? "config:room" : "default:room",
285
+ this.seedPeers.length > 0 ? "config:seed_peers" : "default:seed_peers",
286
+ this.bootstrapHints.length > 0 ? "config:bootstrap_hints" : "default:bootstrap_hints",
287
+ ];
288
+ this.maxMessageBytes = options.maxMessageBytes ?? 64 * 1024;
289
+ this.pollIntervalMs = options.pollIntervalMs ?? 1200;
290
+ this.maxFutureDriftMs = options.maxFutureDriftMs ?? 30_000;
291
+ this.maxPastDriftMs = options.maxPastDriftMs ?? 120_000;
292
+ this.discoveryEventsLimit = Math.max(10, options.discoveryEventsLimit ?? 200);
293
+ this.envelopeCodec = new JsonMessageEnvelopeCodec();
294
+ this.topicCodec = new JsonTopicCodec();
295
+ }
296
+
297
+ async start(): Promise<void> {
298
+ if (this.started) return;
299
+
300
+ this.wrtc = this.resolveWebRTCImplementation();
301
+ if (!this.wrtc) {
302
+ this.stats.start_errors += 1;
303
+ throw new Error(
304
+ "WebRTC runtime unavailable: RTCPeerConnection not found. In Node.js install `@roamhq/wrtc` (or `wrtc`) and restart."
305
+ );
306
+ }
307
+
308
+ this.started = true;
309
+ try {
310
+ await this.postJson("/join", { room: this.room, peer_id: this.peerId });
311
+ await this.syncPeersFromSignaling();
312
+ for (const seedPeer of this.seedPeers) {
313
+ if (!seedPeer || seedPeer === this.peerId) continue;
314
+ const session = this.ensureSession(seedPeer);
315
+ if (this.isInitiatorFor(seedPeer) && this.shouldAttemptConnect(session)) {
316
+ await this.attemptReconnect(session, "seed_peer_hint");
317
+ }
318
+ }
319
+ this.poller = setInterval(() => {
320
+ this.pollOnce().catch(() => {
321
+ this.stats.signaling_errors += 1;
322
+ });
323
+ }, this.pollIntervalMs);
324
+ } catch (error) {
325
+ this.stats.start_errors += 1;
326
+ this.started = false;
327
+ throw new Error(`WebRTC preview start failed: ${error instanceof Error ? error.message : String(error)}`);
328
+ }
329
+ }
330
+
331
+ async stop(): Promise<void> {
332
+ if (!this.started) return;
333
+ this.started = false;
334
+
335
+ if (this.poller) {
336
+ clearInterval(this.poller);
337
+ this.poller = null;
338
+ }
339
+
340
+ for (const session of this.sessions.values()) {
341
+ this.closePeerSession(session);
342
+ this.recordDiscoveryEvent("peer_removed", {
343
+ peer_id: session.peer_id,
344
+ detail: "adapter_stop",
345
+ });
346
+ }
347
+ this.sessions.clear();
348
+
349
+ try {
350
+ await this.postJson("/leave", { room: this.room, peer_id: this.peerId });
351
+ } catch {
352
+ this.stats.stop_errors += 1;
353
+ }
354
+ }
355
+
356
+ async publish(topic: string, data: any): Promise<void> {
357
+ if (!this.started) return;
358
+ if (typeof topic !== "string" || !topic.trim() || topic.includes(":")) return;
359
+
360
+ this.stats.publish_attempted += 1;
361
+
362
+ const envelope: NetworkMessageEnvelope = {
363
+ version: 1,
364
+ message_id: randomUUID(),
365
+ topic: `${this.namespace}:${topic}`,
366
+ source_peer_id: this.peerId,
367
+ timestamp: now(),
368
+ payload: this.topicCodec.encode(topic, data),
369
+ };
370
+
371
+ const raw = this.envelopeCodec.encode(envelope);
372
+ if (raw.length > this.maxMessageBytes) {
373
+ this.stats.dropped_oversized += 1;
374
+ return;
375
+ }
376
+
377
+ let sent = 0;
378
+ for (const session of this.sessions.values()) {
379
+ if (!session.channel || session.channel.readyState !== "open") continue;
380
+ try {
381
+ session.channel.send(new Uint8Array(raw));
382
+ sent += 1;
383
+ } catch {
384
+ this.stats.signaling_errors += 1;
385
+ }
386
+ }
387
+ if (sent > 0) {
388
+ this.stats.publish_sent += 1;
389
+ }
390
+ }
391
+
392
+ subscribe(topic: string, handler: (data: any) => void): void {
393
+ if (typeof topic !== "string" || !topic.trim() || topic.includes(":")) return;
394
+ const key = `${this.namespace}:${topic}`;
395
+ if (!this.handlers.has(key)) this.handlers.set(key, new Set());
396
+ this.handlers.get(key)?.add(handler);
397
+ }
398
+
399
+ getDiagnostics(): WebRTCDiagnostics {
400
+ const connectionStates: StateSummary<WebRTCConnectionState> = {
401
+ new: 0,
402
+ connecting: 0,
403
+ connected: 0,
404
+ disconnected: 0,
405
+ failed: 0,
406
+ closed: 0,
407
+ unknown: 0,
408
+ };
409
+ const dataStates: StateSummary<WebRTCDataChannelState> = {
410
+ connecting: 0,
411
+ open: 0,
412
+ closing: 0,
413
+ closed: 0,
414
+ unknown: 0,
415
+ };
416
+
417
+ const items = Array.from(this.sessions.values()).map((session) => {
418
+ connectionStates[session.connection_state] += 1;
419
+ dataStates[session.datachannel_state] += 1;
420
+ return {
421
+ peer_id: session.peer_id,
422
+ status: session.status,
423
+ first_seen_at: session.first_seen_at,
424
+ last_seen_at: session.last_seen_at,
425
+ messages_seen: session.messages_seen,
426
+ reconnect_attempts: session.reconnect_attempts,
427
+ connection_state: session.connection_state,
428
+ datachannel_state: session.datachannel_state,
429
+ };
430
+ });
431
+
432
+ const online = items.filter((item) => item.status === "online").length;
433
+ const activePeers = items.filter((item) => item.datachannel_state === "open").length;
434
+
435
+ return {
436
+ adapter: "webrtc-preview",
437
+ peer_id: this.peerId,
438
+ namespace: this.namespace,
439
+ room: this.room,
440
+ signaling_url: this.activeSignalingEndpoint || this.signalingUrl,
441
+ signaling_endpoints: [...this.signalingEndpoints],
442
+ bootstrap_sources: [...this.bootstrapSources],
443
+ seed_peers_count: this.seedPeers.length,
444
+ bootstrap_hints_count: this.bootstrapHints.length,
445
+ discovery_events_total: this.discoveryEventsTotal,
446
+ last_discovery_event_at: this.lastDiscoveryEventAt,
447
+ discovery_events: [...this.discoveryEvents],
448
+ connection_states_summary: connectionStates,
449
+ datachannel_states_summary: dataStates,
450
+ signaling_messages_sent_total: this.signalingMessagesSentTotal,
451
+ signaling_messages_received_total: this.signalingMessagesReceivedTotal,
452
+ reconnect_attempts_total: this.reconnectAttemptsTotal,
453
+ active_webrtc_peers: activePeers,
454
+ components: {
455
+ transport: "WebRTCDataChannelTransport",
456
+ discovery: "SignalingRoomPolling",
457
+ envelope_codec: this.envelopeCodec.constructor.name,
458
+ topic_codec: this.topicCodec.constructor.name,
459
+ },
460
+ limits: {
461
+ max_message_bytes: this.maxMessageBytes,
462
+ max_future_drift_ms: this.maxFutureDriftMs,
463
+ max_past_drift_ms: this.maxPastDriftMs,
464
+ },
465
+ config: {
466
+ started: this.started,
467
+ topic_handler_count: this.handlers.size,
468
+ poll_interval_ms: this.pollIntervalMs,
469
+ },
470
+ peers: {
471
+ total: items.length,
472
+ online,
473
+ stale: Math.max(0, items.length - online),
474
+ items,
475
+ },
476
+ stats: { ...this.stats },
477
+ };
478
+ }
479
+
480
+ private resolveWebRTCImplementation(): any | null {
481
+ const g = globalThis as any;
482
+ if (typeof g.RTCPeerConnection === "function") {
483
+ return {
484
+ RTCPeerConnection: g.RTCPeerConnection,
485
+ RTCSessionDescription: g.RTCSessionDescription,
486
+ RTCIceCandidate: g.RTCIceCandidate,
487
+ };
488
+ }
489
+ try {
490
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
491
+ const wrtc = require("@roamhq/wrtc");
492
+ return {
493
+ RTCPeerConnection: wrtc.RTCPeerConnection,
494
+ RTCSessionDescription: wrtc.RTCSessionDescription,
495
+ RTCIceCandidate: wrtc.RTCIceCandidate,
496
+ };
497
+ } catch {
498
+ try {
499
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
500
+ const wrtc = require("wrtc");
501
+ return {
502
+ RTCPeerConnection: wrtc.RTCPeerConnection,
503
+ RTCSessionDescription: wrtc.RTCSessionDescription,
504
+ RTCIceCandidate: wrtc.RTCIceCandidate,
505
+ };
506
+ } catch {
507
+ return null;
508
+ }
509
+ }
510
+ }
511
+
512
+ private async pollOnce(): Promise<void> {
513
+ if (!this.started) return;
514
+
515
+ await this.syncPeersFromSignaling();
516
+
517
+ const response = await this.getJson(
518
+ `/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`
519
+ );
520
+
521
+ const messages = Array.isArray(response?.messages) ? response.messages : [];
522
+ for (const message of messages) {
523
+ await this.handleSignalMessage(message);
524
+ }
525
+
526
+ this.cleanupProcessedSignalIds();
527
+
528
+ const staleThreshold = now() - this.maxPastDriftMs;
529
+ for (const session of this.sessions.values()) {
530
+ if (session.last_seen_at < staleThreshold && session.status !== "stale") {
531
+ session.status = "stale";
532
+ this.recordDiscoveryEvent("peer_stale", {
533
+ peer_id: session.peer_id,
534
+ detail: "presence_timeout",
535
+ });
536
+ }
537
+ }
538
+ }
539
+
540
+ private async syncPeersFromSignaling(): Promise<void> {
541
+ const response = await this.getJson(`/peers?room=${encodeURIComponent(this.room)}`);
542
+ const peers = Array.isArray(response?.peers)
543
+ ? response.peers.map((peer: unknown) => String(peer)).filter(Boolean)
544
+ : [];
545
+ const seen = new Set<string>();
546
+
547
+ for (const peerId of peers) {
548
+ if (peerId === this.peerId) continue;
549
+ seen.add(peerId);
550
+ const session = this.ensureSession(peerId);
551
+ session.last_seen_at = now();
552
+ if (this.isInitiatorFor(peerId) && this.shouldAttemptConnect(session)) {
553
+ await this.attemptReconnect(session, "sync_peers");
554
+ }
555
+ }
556
+
557
+ for (const [peerId, session] of this.sessions.entries()) {
558
+ if (!seen.has(peerId) && now() - session.last_seen_at > this.maxPastDriftMs) {
559
+ this.closePeerSession(session);
560
+ this.sessions.delete(peerId);
561
+ this.recordDiscoveryEvent("peer_removed", {
562
+ peer_id: peerId,
563
+ detail: "stale_session_cleanup",
564
+ });
565
+ }
566
+ }
567
+ }
568
+
569
+ private ensureSession(peerId: string): PeerSession {
570
+ const existing = this.sessions.get(peerId);
571
+ if (existing) return existing;
572
+ const session: PeerSession = {
573
+ peer_id: peerId,
574
+ status: "connecting",
575
+ first_seen_at: now(),
576
+ last_seen_at: now(),
577
+ messages_seen: 0,
578
+ reconnect_attempts: 0,
579
+ last_reconnect_attempt_at: 0,
580
+ connection_state: "new",
581
+ datachannel_state: "unknown",
582
+ connection: null,
583
+ channel: null,
584
+ remote_description_set: false,
585
+ pending_ice: [],
586
+ seen_ice_keys: new Set(),
587
+ last_offer_sdp: "",
588
+ last_answer_sdp: "",
589
+ };
590
+ this.sessions.set(peerId, session);
591
+ this.recordDiscoveryEvent("peer_joined", { peer_id: peerId });
592
+ return session;
593
+ }
594
+
595
+ private shouldAttemptConnect(session: PeerSession): boolean {
596
+ if (!this.started) return false;
597
+ const cs = session.connection_state;
598
+ if (session.channel && session.channel.readyState === "open") return false;
599
+ if (!session.connection) return true;
600
+ if (cs === "failed" || cs === "disconnected" || cs === "closed") return true;
601
+ return false;
602
+ }
603
+
604
+ private async attemptReconnect(session: PeerSession, _reason: string): Promise<void> {
605
+ const nowTs = now();
606
+ if (nowTs - session.last_reconnect_attempt_at < 2000) {
607
+ return;
608
+ }
609
+ session.last_reconnect_attempt_at = nowTs;
610
+ session.reconnect_attempts += 1;
611
+ this.reconnectAttemptsTotal += 1;
612
+ this.recordDiscoveryEvent("reconnect_started", {
613
+ peer_id: session.peer_id,
614
+ detail: _reason,
615
+ });
616
+
617
+ try {
618
+ this.closePeerSession(session);
619
+ session.connection = this.createPeerConnection(session);
620
+
621
+ if (this.isInitiatorFor(session.peer_id)) {
622
+ const channel = session.connection.createDataChannel("silicaclaw");
623
+ this.bindDataChannel(session, channel);
624
+ const offer = await session.connection.createOffer();
625
+ await session.connection.setLocalDescription(offer);
626
+ session.last_offer_sdp = String(offer?.sdp ?? "");
627
+ await this.sendSignal(session.peer_id, "offer", offer);
628
+ }
629
+ } catch (error) {
630
+ this.recordDiscoveryEvent("reconnect_failed", {
631
+ peer_id: session.peer_id,
632
+ detail: error instanceof Error ? error.message : "reconnect_failed",
633
+ });
634
+ throw error;
635
+ }
636
+ }
637
+
638
+ private createPeerConnection(session: PeerSession): any {
639
+ const pc = new this.wrtc.RTCPeerConnection();
640
+ session.connection_state = connectionStateOrUnknown(pc);
641
+
642
+ pc.onconnectionstatechange = () => {
643
+ session.connection_state = connectionStateOrUnknown(pc);
644
+ session.last_seen_at = now();
645
+ if (session.connection_state === "connected") {
646
+ session.status = "online";
647
+ if (session.reconnect_attempts > 0) {
648
+ this.recordDiscoveryEvent("reconnect_succeeded", {
649
+ peer_id: session.peer_id,
650
+ detail: "connection_state_connected",
651
+ });
652
+ }
653
+ }
654
+ if (
655
+ (session.connection_state === "failed" ||
656
+ session.connection_state === "disconnected" ||
657
+ session.connection_state === "closed") &&
658
+ this.isInitiatorFor(session.peer_id)
659
+ ) {
660
+ this.attemptReconnect(session, "connection_state_change").catch(() => {
661
+ this.stats.signaling_errors += 1;
662
+ this.recordDiscoveryEvent("reconnect_failed", {
663
+ peer_id: session.peer_id,
664
+ detail: "connection_state_change",
665
+ });
666
+ });
667
+ }
668
+ };
669
+
670
+ pc.onicecandidate = (event: any) => {
671
+ if (!event?.candidate) return;
672
+ this.sendSignal(session.peer_id, "candidate", event.candidate).catch(() => {
673
+ this.stats.signaling_errors += 1;
674
+ });
675
+ };
676
+
677
+ pc.ondatachannel = (event: any) => {
678
+ this.bindDataChannel(session, event.channel);
679
+ };
680
+
681
+ return pc;
682
+ }
683
+
684
+ private bindDataChannel(session: PeerSession, channel: any): void {
685
+ session.channel = channel;
686
+ session.datachannel_state = dataChannelStateOrUnknown(channel);
687
+ channel.binaryType = "arraybuffer";
688
+
689
+ channel.onopen = () => {
690
+ session.datachannel_state = "open";
691
+ session.status = "online";
692
+ session.last_seen_at = now();
693
+ if (session.reconnect_attempts > 0) {
694
+ this.recordDiscoveryEvent("reconnect_succeeded", {
695
+ peer_id: session.peer_id,
696
+ detail: "datachannel_open",
697
+ });
698
+ }
699
+ };
700
+
701
+ channel.onclose = () => {
702
+ session.datachannel_state = "closed";
703
+ session.status = "stale";
704
+ this.recordDiscoveryEvent("peer_stale", {
705
+ peer_id: session.peer_id,
706
+ detail: "datachannel_closed",
707
+ });
708
+ if (this.isInitiatorFor(session.peer_id)) {
709
+ this.attemptReconnect(session, "datachannel_closed").catch(() => {
710
+ this.stats.signaling_errors += 1;
711
+ });
712
+ }
713
+ };
714
+
715
+ channel.onerror = () => {
716
+ this.stats.signaling_errors += 1;
717
+ session.status = "stale";
718
+ };
719
+
720
+ channel.onmessage = (event: any) => {
721
+ const buffer = toBuffer(event?.data);
722
+ if (!buffer) {
723
+ this.stats.dropped_decode_failed += 1;
724
+ return;
725
+ }
726
+ this.onDataMessage(session, buffer);
727
+ };
728
+ }
729
+
730
+ private async handleSignalMessage(message: any): Promise<void> {
731
+ if (!message || typeof message !== "object") {
732
+ this.stats.invalid_signaling_payload_total += 1;
733
+ this.recordDiscoveryEvent("malformed_signal_dropped", { detail: "not_object" });
734
+ return;
735
+ }
736
+
737
+ const signalId = String(message.id ?? "");
738
+ if (signalId) {
739
+ const already = this.processedSignalIds.get(signalId);
740
+ if (already) {
741
+ this.recordDiscoveryEvent("duplicate_signal_dropped", { detail: "duplicate_signal_id" });
742
+ return;
743
+ }
744
+ this.processedSignalIds.set(signalId, now());
745
+ }
746
+
747
+ const fromPeerId = String(message.from_peer_id ?? "");
748
+ const type = String(message.type ?? "");
749
+ const payload = message.payload;
750
+ if (!fromPeerId || fromPeerId === this.peerId || !type) {
751
+ this.stats.invalid_signaling_payload_total += 1;
752
+ this.recordDiscoveryEvent("malformed_signal_dropped", { detail: "missing_required_fields" });
753
+ return;
754
+ }
755
+
756
+ this.signalingMessagesReceivedTotal += 1;
757
+
758
+ const session = this.ensureSession(fromPeerId);
759
+ session.last_seen_at = now();
760
+
761
+ if (!session.connection) {
762
+ session.connection = this.createPeerConnection(session);
763
+ }
764
+ const pc = session.connection;
765
+
766
+ try {
767
+ if (type === "offer") {
768
+ const sdp = String(payload?.sdp ?? "");
769
+ if (!sdp) {
770
+ this.stats.invalid_signaling_payload_total += 1;
771
+ this.recordDiscoveryEvent("malformed_signal_dropped", {
772
+ peer_id: fromPeerId,
773
+ detail: "offer_missing_sdp",
774
+ });
775
+ return;
776
+ }
777
+ if (session.last_offer_sdp === sdp) {
778
+ this.stats.duplicate_sdp_total += 1;
779
+ this.recordDiscoveryEvent("duplicate_signal_dropped", {
780
+ peer_id: fromPeerId,
781
+ detail: "duplicate_offer_sdp",
782
+ });
783
+ return;
784
+ }
785
+ session.last_offer_sdp = sdp;
786
+
787
+ await pc.setRemoteDescription(new this.wrtc.RTCSessionDescription(payload));
788
+ session.remote_description_set = true;
789
+ await this.flushBufferedIce(session);
790
+
791
+ const answer = await pc.createAnswer();
792
+ await pc.setLocalDescription(answer);
793
+ session.last_answer_sdp = String(answer?.sdp ?? "");
794
+ await this.sendSignal(fromPeerId, "answer", answer);
795
+ return;
796
+ }
797
+
798
+ if (type === "answer") {
799
+ const sdp = String(payload?.sdp ?? "");
800
+ if (!sdp) {
801
+ this.stats.invalid_signaling_payload_total += 1;
802
+ this.recordDiscoveryEvent("malformed_signal_dropped", {
803
+ peer_id: fromPeerId,
804
+ detail: "answer_missing_sdp",
805
+ });
806
+ return;
807
+ }
808
+ if (session.last_answer_sdp === sdp) {
809
+ this.stats.duplicate_sdp_total += 1;
810
+ this.recordDiscoveryEvent("duplicate_signal_dropped", {
811
+ peer_id: fromPeerId,
812
+ detail: "duplicate_answer_sdp",
813
+ });
814
+ return;
815
+ }
816
+ session.last_answer_sdp = sdp;
817
+
818
+ await pc.setRemoteDescription(new this.wrtc.RTCSessionDescription(payload));
819
+ session.remote_description_set = true;
820
+ await this.flushBufferedIce(session);
821
+ return;
822
+ }
823
+
824
+ if (type === "candidate") {
825
+ const key = iceKey(payload);
826
+ if (!key || key === "{}") {
827
+ this.stats.invalid_signaling_payload_total += 1;
828
+ this.recordDiscoveryEvent("malformed_signal_dropped", {
829
+ peer_id: fromPeerId,
830
+ detail: "candidate_missing_fields",
831
+ });
832
+ return;
833
+ }
834
+ if (session.seen_ice_keys.has(key)) {
835
+ this.stats.duplicate_ice_total += 1;
836
+ this.recordDiscoveryEvent("duplicate_signal_dropped", {
837
+ peer_id: fromPeerId,
838
+ detail: "duplicate_ice_candidate",
839
+ });
840
+ return;
841
+ }
842
+ session.seen_ice_keys.add(key);
843
+
844
+ if (!session.remote_description_set) {
845
+ session.pending_ice.push(payload);
846
+ return;
847
+ }
848
+ await pc.addIceCandidate(new this.wrtc.RTCIceCandidate(payload));
849
+ return;
850
+ }
851
+
852
+ this.stats.invalid_signaling_payload_total += 1;
853
+ this.recordDiscoveryEvent("malformed_signal_dropped", {
854
+ peer_id: fromPeerId,
855
+ detail: `unsupported_signal_type:${type}`,
856
+ });
857
+ } catch {
858
+ this.stats.signaling_errors += 1;
859
+ }
860
+ }
861
+
862
+ private async flushBufferedIce(session: PeerSession): Promise<void> {
863
+ if (!session.connection || !session.pending_ice.length) return;
864
+ const pending = [...session.pending_ice];
865
+ session.pending_ice = [];
866
+ for (const candidate of pending) {
867
+ try {
868
+ await session.connection.addIceCandidate(new this.wrtc.RTCIceCandidate(candidate));
869
+ } catch {
870
+ this.stats.signaling_errors += 1;
871
+ }
872
+ }
873
+ }
874
+
875
+ private onDataMessage(session: PeerSession, raw: Buffer): void {
876
+ this.stats.received_total += 1;
877
+ session.last_seen_at = now();
878
+ session.messages_seen += 1;
879
+
880
+ if (raw.length > this.maxMessageBytes) {
881
+ this.stats.dropped_oversized += 1;
882
+ return;
883
+ }
884
+
885
+ const decoded = this.envelopeCodec.decode(raw);
886
+ if (!decoded) {
887
+ this.stats.dropped_decode_failed += 1;
888
+ this.stats.dropped_malformed += 1;
889
+ return;
890
+ }
891
+
892
+ const validated = validateNetworkMessageEnvelope(decoded.envelope, {
893
+ max_future_drift_ms: this.maxFutureDriftMs,
894
+ max_past_drift_ms: this.maxPastDriftMs,
895
+ });
896
+
897
+ if (!validated.ok || !validated.envelope) {
898
+ if (validated.reason === "timestamp_future_drift") {
899
+ this.stats.dropped_timestamp_future_drift += 1;
900
+ } else if (validated.reason === "timestamp_past_drift") {
901
+ this.stats.dropped_timestamp_past_drift += 1;
902
+ } else {
903
+ this.stats.dropped_malformed += 1;
904
+ }
905
+ return;
906
+ }
907
+
908
+ this.stats.received_validated += 1;
909
+ const envelope = validated.envelope;
910
+
911
+ if (envelope.source_peer_id === this.peerId) {
912
+ this.stats.dropped_self += 1;
913
+ return;
914
+ }
915
+
916
+ if (!envelope.topic.startsWith(`${this.namespace}:`)) {
917
+ this.stats.dropped_namespace_mismatch += 1;
918
+ return;
919
+ }
920
+
921
+ const handlers = this.handlers.get(envelope.topic);
922
+ if (!handlers || handlers.size === 0) return;
923
+
924
+ const logicalTopic = envelope.topic.slice(`${this.namespace}:`.length);
925
+
926
+ try {
927
+ const payload = this.topicCodec.decode(logicalTopic, envelope.payload);
928
+ for (const handler of handlers) {
929
+ try {
930
+ handler(payload);
931
+ this.stats.delivered_total += 1;
932
+ } catch {
933
+ this.stats.dropped_handler_error += 1;
934
+ }
935
+ }
936
+ } catch {
937
+ this.stats.dropped_topic_decode_error += 1;
938
+ }
939
+ }
940
+
941
+ private cleanupProcessedSignalIds(): void {
942
+ const threshold = now() - this.maxPastDriftMs;
943
+ for (const [id, ts] of this.processedSignalIds.entries()) {
944
+ if (ts < threshold) {
945
+ this.processedSignalIds.delete(id);
946
+ }
947
+ }
948
+ }
949
+
950
+ private isInitiatorFor(peerId: string): boolean {
951
+ return this.peerId < peerId;
952
+ }
953
+
954
+ private closePeerSession(session: PeerSession): void {
955
+ try {
956
+ session.channel?.close?.();
957
+ } catch {
958
+ this.stats.stop_errors += 1;
959
+ }
960
+ try {
961
+ session.connection?.close?.();
962
+ } catch {
963
+ this.stats.stop_errors += 1;
964
+ }
965
+ session.channel = null;
966
+ session.connection = null;
967
+ session.datachannel_state = "closed";
968
+ session.connection_state = "closed";
969
+ session.remote_description_set = false;
970
+ session.pending_ice = [];
971
+ session.seen_ice_keys.clear();
972
+ }
973
+
974
+ private async sendSignal(toPeerId: string, type: "offer" | "answer" | "candidate", payload: unknown): Promise<void> {
975
+ this.signalingMessagesSentTotal += 1;
976
+ await this.postJson("/signal", {
977
+ id: randomUUID(),
978
+ room: this.room,
979
+ from_peer_id: this.peerId,
980
+ to_peer_id: toPeerId,
981
+ type,
982
+ payload,
983
+ });
984
+ }
985
+
986
+ private async postJson(path: string, body: Record<string, unknown>): Promise<any> {
987
+ return this.requestJson("POST", path, body);
988
+ }
989
+
990
+ private async getJson(path: string): Promise<any> {
991
+ return this.requestJson("GET", path);
992
+ }
993
+
994
+ private async requestJson(method: "GET" | "POST", path: string, body?: Record<string, unknown>): Promise<any> {
995
+ const errors: string[] = [];
996
+ for (let offset = 0; offset < this.signalingEndpoints.length; offset += 1) {
997
+ const idx = (this.signalingIndex + offset) % this.signalingEndpoints.length;
998
+ const endpoint = this.signalingEndpoints[idx];
999
+ try {
1000
+ const res = await fetch(`${endpoint}${path}`, {
1001
+ method,
1002
+ headers: { "content-type": "application/json" },
1003
+ body: method === "POST" ? JSON.stringify(body ?? {}) : undefined,
1004
+ });
1005
+ if (!res.ok) {
1006
+ throw new Error(`HTTP ${res.status}`);
1007
+ }
1008
+ this.signalingIndex = idx;
1009
+ this.activeSignalingEndpoint = endpoint;
1010
+ this.markSignalingConnected(endpoint);
1011
+ return res.json().catch(() => ({}));
1012
+ } catch (error) {
1013
+ const errMsg = error instanceof Error ? error.message : String(error);
1014
+ errors.push(`${endpoint}(${errMsg})`);
1015
+ this.markSignalingDisconnected(endpoint, errMsg);
1016
+ }
1017
+ }
1018
+ this.stats.signaling_errors += 1;
1019
+ throw new Error(`Signaling ${method} ${path} failed: ${errors.join("; ")}`);
1020
+ }
1021
+
1022
+ private markSignalingConnected(endpoint: string): void {
1023
+ if (this.signalingConnectivity.get(endpoint)) {
1024
+ return;
1025
+ }
1026
+ this.signalingConnectivity.set(endpoint, true);
1027
+ this.recordDiscoveryEvent("signaling_connected", { endpoint });
1028
+ }
1029
+
1030
+ private markSignalingDisconnected(endpoint: string, detail: string): void {
1031
+ if (this.signalingConnectivity.get(endpoint) === false) {
1032
+ return;
1033
+ }
1034
+ this.signalingConnectivity.set(endpoint, false);
1035
+ this.recordDiscoveryEvent("signaling_disconnected", { endpoint, detail });
1036
+ }
1037
+
1038
+ private recordDiscoveryEvent(type: DiscoveryEventType, extra: Omit<DiscoveryEvent, "id" | "type" | "at"> = {}): void {
1039
+ const event: DiscoveryEvent = {
1040
+ id: randomUUID(),
1041
+ type,
1042
+ at: now(),
1043
+ ...extra,
1044
+ };
1045
+ this.discoveryEvents.push(event);
1046
+ if (this.discoveryEvents.length > this.discoveryEventsLimit) {
1047
+ this.discoveryEvents.splice(0, this.discoveryEvents.length - this.discoveryEventsLimit);
1048
+ }
1049
+ this.discoveryEventsTotal += 1;
1050
+ this.lastDiscoveryEventAt = event.at;
1051
+ }
1052
+ }