@silicaclaw/cli 1.0.0-beta.2 → 1.0.0-beta.21

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