yakmesh 2.8.2 → 3.0.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 (232) hide show
  1. package/CHANGELOG.md +637 -0
  2. package/CONTRIBUTING.md +42 -0
  3. package/Caddyfile +77 -0
  4. package/README.md +119 -29
  5. package/adapters/adapter-mlv-bible/README.md +124 -0
  6. package/adapters/adapter-mlv-bible/index.js +400 -0
  7. package/adapters/chat-mod-adapter.js +532 -0
  8. package/adapters/content-adapter.js +273 -0
  9. package/content/api.js +50 -41
  10. package/content/index.js +2 -2
  11. package/content/store.js +355 -173
  12. package/dashboard/index.html +19 -3
  13. package/database/replication.js +117 -37
  14. package/docs/CRYPTO-AGILITY.md +204 -0
  15. package/docs/MTLS-RESEARCH.md +367 -0
  16. package/docs/NAMCHE-SPEC.md +681 -0
  17. package/docs/PEERQUANTA-YAKMESH-INTEGRATION.md +407 -0
  18. package/docs/PRECISION-DISCLOSURE.md +96 -0
  19. package/docs/README.md +76 -0
  20. package/docs/ROADMAP-2.4.0.md +447 -0
  21. package/docs/ROADMAP-2.5.0.md +244 -0
  22. package/docs/SECURITY-AUDIT-REPORT.md +306 -0
  23. package/docs/SST-INTEGRATION.md +712 -0
  24. package/docs/STEADYWATCH-IMPLEMENTATION.md +303 -0
  25. package/docs/TERNARY-AUDIT-REPORT.md +247 -0
  26. package/docs/TME-FAQ.md +221 -0
  27. package/docs/WHITEPAPER.md +623 -0
  28. package/docs/adapters.html +1001 -0
  29. package/docs/advanced-systems.html +1045 -0
  30. package/docs/annex.html +1046 -0
  31. package/docs/api.html +970 -0
  32. package/docs/business/response-templates.md +160 -0
  33. package/docs/c2c.html +1225 -0
  34. package/docs/cli.html +1332 -0
  35. package/docs/configuration.html +1248 -0
  36. package/docs/darshan.html +1085 -0
  37. package/docs/dharma.html +966 -0
  38. package/docs/docs-bundle.html +1075 -0
  39. package/docs/docs.css +3120 -0
  40. package/docs/docs.js +556 -0
  41. package/docs/doko.html +969 -0
  42. package/docs/geo-proof.html +858 -0
  43. package/docs/getting-started.html +840 -0
  44. package/docs/gumba-tutorial.html +1144 -0
  45. package/docs/gumba.html +1098 -0
  46. package/docs/index.html +914 -0
  47. package/docs/jhilke.html +1312 -0
  48. package/docs/karma.html +1100 -0
  49. package/docs/katha.html +1037 -0
  50. package/docs/lama.html +978 -0
  51. package/docs/mandala.html +1067 -0
  52. package/docs/mani.html +964 -0
  53. package/docs/mantra.html +967 -0
  54. package/docs/mesh.html +1409 -0
  55. package/docs/nakpak.html +869 -0
  56. package/docs/namche.html +928 -0
  57. package/docs/nav-order.json +53 -0
  58. package/docs/prahari.html +1043 -0
  59. package/docs/prism-bash.min.js +1 -0
  60. package/docs/prism-javascript.min.js +1 -0
  61. package/docs/prism-json.min.js +1 -0
  62. package/docs/prism-tomorrow.min.css +1 -0
  63. package/docs/prism.min.js +1 -0
  64. package/docs/privacy.html +699 -0
  65. package/docs/quick-reference.html +1181 -0
  66. package/docs/sakshi.html +1402 -0
  67. package/docs/sandboxing.md +386 -0
  68. package/docs/seva.html +911 -0
  69. package/docs/sherpa.html +871 -0
  70. package/docs/studio.html +860 -0
  71. package/docs/stupa.html +995 -0
  72. package/docs/tailwind.min.css +2 -0
  73. package/docs/tattva.html +1332 -0
  74. package/docs/terms.html +686 -0
  75. package/docs/time-server-deployment.md +166 -0
  76. package/docs/time-sources.html +1392 -0
  77. package/docs/tivra.html +1127 -0
  78. package/docs/trademark-policy.html +686 -0
  79. package/docs/tribhuj.html +1183 -0
  80. package/docs/trust-security.html +1029 -0
  81. package/docs/tutorials/backup-recovery.html +654 -0
  82. package/docs/tutorials/dashboard.html +604 -0
  83. package/docs/tutorials/domain-setup.html +605 -0
  84. package/docs/tutorials/host-website.html +456 -0
  85. package/docs/tutorials/mesh-network.html +505 -0
  86. package/docs/tutorials/mobile-access.html +445 -0
  87. package/docs/tutorials/privacy.html +467 -0
  88. package/docs/tutorials/raspberry-pi.html +600 -0
  89. package/docs/tutorials/security-basics.html +539 -0
  90. package/docs/tutorials/share-files.html +431 -0
  91. package/docs/tutorials/troubleshooting.html +637 -0
  92. package/docs/tutorials/trust-karma.html +419 -0
  93. package/docs/tutorials/yak-protocol.html +456 -0
  94. package/docs/tutorials.html +1034 -0
  95. package/docs/vani.html +1270 -0
  96. package/docs/webserver.html +809 -0
  97. package/docs/yak-protocol.html +940 -0
  98. package/docs/yak-timeserver-design.md +475 -0
  99. package/docs/yakapp.html +1015 -0
  100. package/docs/ypc27.html +1069 -0
  101. package/docs/yurt.html +1344 -0
  102. package/embedded-docs/bundle.js +334 -74
  103. package/gossip/protocol.js +247 -27
  104. package/identity/key-resolver.js +262 -0
  105. package/identity/machine-seed.js +632 -0
  106. package/identity/node-key.js +669 -368
  107. package/identity/tribhuj-ratchet.js +506 -0
  108. package/knowledge-base.js +37 -8
  109. package/launcher/yakmesh.bat +62 -0
  110. package/launcher/yakmesh.sh +70 -0
  111. package/mesh/annex.js +462 -108
  112. package/mesh/beacon-broadcast.js +113 -1
  113. package/mesh/darshan.js +1718 -0
  114. package/mesh/gumba.js +1567 -0
  115. package/mesh/jhilke.js +651 -0
  116. package/mesh/katha.js +1012 -0
  117. package/mesh/nakpak-routing.js +8 -5
  118. package/mesh/network.js +724 -34
  119. package/mesh/pulse-sync.js +4 -1
  120. package/mesh/rate-limiter.js +127 -15
  121. package/mesh/seva.js +526 -0
  122. package/mesh/sherpa-discovery.js +89 -8
  123. package/mesh/sybil-defense.js +19 -5
  124. package/mesh/temporal-encoder.js +4 -3
  125. package/mesh/vani.js +1364 -0
  126. package/mesh/yurt.js +1340 -0
  127. package/models/entropy-sentinel.onnx +0 -0
  128. package/models/karma-trust.onnx +0 -0
  129. package/models/manifest.json +43 -0
  130. package/models/sakshi-anomaly.onnx +0 -0
  131. package/oracle/code-proof-protocol.js +7 -6
  132. package/oracle/codebase-lock.js +257 -28
  133. package/oracle/index.js +74 -15
  134. package/oracle/ma902-snmp.js +678 -0
  135. package/oracle/module-sealer.js +5 -3
  136. package/oracle/network-identity.js +16 -0
  137. package/oracle/packet-checksum.js +201 -0
  138. package/oracle/sst.js +579 -0
  139. package/oracle/ternary-144t.js +714 -0
  140. package/oracle/ternary-ml.js +481 -0
  141. package/oracle/time-api.js +239 -0
  142. package/oracle/time-source.js +137 -47
  143. package/oracle/validation-oracle-hardened.js +1111 -1071
  144. package/oracle/validation-oracle.js +4 -2
  145. package/oracle/ypc27.js +211 -0
  146. package/package.json +20 -3
  147. package/protocol/yak-handler.js +35 -9
  148. package/protocol/yak-protocol.js +28 -13
  149. package/reference/cpp/yakmesh_mceliece_shard.cpp +168 -0
  150. package/reference/cpp/yakmesh_ypc27.cpp +179 -0
  151. package/sbom.json +87 -0
  152. package/scripts/security-audit.mjs +264 -0
  153. package/scripts/update-docs-nav.js +194 -0
  154. package/scripts/update-docs-sidebar.cjs +164 -0
  155. package/security/crypto-config.js +4 -3
  156. package/security/dharma-moderation.js +517 -0
  157. package/security/doko-identity.js +193 -143
  158. package/security/domain-consensus.js +86 -85
  159. package/security/fs-hardening.js +620 -0
  160. package/security/hardware-attestation.js +5 -3
  161. package/security/hybrid-trust.js +227 -87
  162. package/security/karma-rate-limiter.js +692 -0
  163. package/security/khata-protocol.js +22 -21
  164. package/security/khata-trust-integration.js +277 -150
  165. package/security/memory-safety.js +635 -0
  166. package/security/mesh-auth.js +11 -10
  167. package/security/mesh-revocation.js +373 -5
  168. package/security/namche-gateway.js +298 -69
  169. package/security/sakshi.js +460 -3
  170. package/security/sangha.js +770 -0
  171. package/security/secure-config.js +473 -0
  172. package/security/silicon-parity.js +13 -10
  173. package/security/steadywatch.js +1142 -0
  174. package/security/strike-system.js +32 -3
  175. package/security/temporal-signing.js +488 -0
  176. package/security/trit-commitment.js +464 -0
  177. package/server/crypto/annex.js +247 -0
  178. package/server/darshan-api.js +343 -0
  179. package/server/index.js +3259 -362
  180. package/server/komm-api.js +668 -0
  181. package/utils/accel.js +2273 -0
  182. package/utils/ternary-id.js +79 -0
  183. package/utils/verify-worker.js +57 -0
  184. package/webserver/index.js +95 -5
  185. package/assets/yakmesh-logo.png +0 -0
  186. package/assets/yakmesh-logo.svg +0 -80
  187. package/assets/yakmesh-logo2.png +0 -0
  188. package/assets/yakmesh-logo2sm.png +0 -0
  189. package/assets/ymsm.png +0 -0
  190. package/website/assets/silhouettes/adapters.svg +0 -107
  191. package/website/assets/silhouettes/api-endpoints.svg +0 -115
  192. package/website/assets/silhouettes/atomic-clock.svg +0 -83
  193. package/website/assets/silhouettes/base-camp.svg +0 -81
  194. package/website/assets/silhouettes/bridge.svg +0 -69
  195. package/website/assets/silhouettes/docs-bundle.svg +0 -113
  196. package/website/assets/silhouettes/doko-basket.svg +0 -70
  197. package/website/assets/silhouettes/fortress.svg +0 -93
  198. package/website/assets/silhouettes/gateway.svg +0 -54
  199. package/website/assets/silhouettes/gears.svg +0 -93
  200. package/website/assets/silhouettes/globe-satellite.svg +0 -67
  201. package/website/assets/silhouettes/karma-wheel.svg +0 -137
  202. package/website/assets/silhouettes/lama-council.svg +0 -141
  203. package/website/assets/silhouettes/mandala-network.svg +0 -169
  204. package/website/assets/silhouettes/mani-stones.svg +0 -149
  205. package/website/assets/silhouettes/mantra-wheel.svg +0 -116
  206. package/website/assets/silhouettes/mesh-nodes.svg +0 -113
  207. package/website/assets/silhouettes/nakpak.svg +0 -56
  208. package/website/assets/silhouettes/peak-lightning.svg +0 -73
  209. package/website/assets/silhouettes/sherpa.svg +0 -69
  210. package/website/assets/silhouettes/stupa-tower.svg +0 -119
  211. package/website/assets/silhouettes/tattva-eye.svg +0 -78
  212. package/website/assets/silhouettes/terminal.svg +0 -74
  213. package/website/assets/silhouettes/webserver.svg +0 -145
  214. package/website/assets/silhouettes/yak.svg +0 -78
  215. package/website/assets/yakmesh-logo.png +0 -0
  216. package/website/assets/yakmesh-logo.webp +0 -0
  217. package/website/assets/yakmesh-logo128x140.webp +0 -0
  218. package/website/assets/yakmesh-logo2.png +0 -0
  219. package/website/assets/yakmesh-logo2.svg +0 -51
  220. package/website/assets/yakmesh-logo40x44.webp +0 -0
  221. package/website/assets/yakmesh.gif +0 -0
  222. package/website/assets/yakmesh.ico +0 -0
  223. package/website/assets/yakmesh.jpg +0 -0
  224. package/website/assets/yakmesh.pdf +0 -0
  225. package/website/assets/yakmesh.png +0 -0
  226. package/website/assets/yakmesh.svg +0 -70
  227. package/website/assets/yakmesh128.webp +0 -0
  228. package/website/assets/yakmesh32.png +0 -0
  229. package/website/assets/yakmesh32.svg +0 -65
  230. package/website/assets/yakmesh32o.ico +0 -2
  231. package/website/assets/yakmesh32o.svg +0 -65
  232. package/website/assets/yakmesh32o.svgz +0 -0
package/mesh/vani.js ADDED
@@ -0,0 +1,1364 @@
1
+ /**
2
+ * VANI - Voice And Networked Interaction
3
+ *
4
+ * WebRTC voice and video calling for Yakmesh:
5
+ * - Peer-to-peer media streams (no central server)
6
+ * - Mesh network signaling (SDP offer/answer, ICE candidates)
7
+ * - Call state management (ringing, connected, ended)
8
+ * - Multi-party calls via mesh relay
9
+ * - Integration with GUMBA for private room calls
10
+ *
11
+ * Etymology: वाणी (vani) = voice, speech in Sanskrit
12
+ *
13
+ * WebRTC provides the actual media transport; VANI handles:
14
+ * 1. Signaling through the mesh network
15
+ * 2. Call lifecycle (initiate, accept, reject, end)
16
+ * 3. Participant management for group calls
17
+ * 4. STUN/TURN configuration
18
+ *
19
+ * @module mesh/vani
20
+ * @license MIT
21
+ * @copyright 2026 YAKMESH™ Contributors
22
+ */
23
+
24
+ import { randomBytes } from 'crypto';
25
+ import { bytesToHex } from '@noble/hashes/utils.js';
26
+
27
+ // ═══════════════════════════════════════════════════════════════════════════════
28
+ // CONFIGURATION
29
+ // ═══════════════════════════════════════════════════════════════════════════════
30
+
31
+ export const VANI_CONFIG = Object.freeze({
32
+ // Call settings
33
+ callTimeout: 30000, // Ring timeout (30 seconds)
34
+ iceGatheringTimeout: 10000, // ICE gathering timeout
35
+ reconnectTimeout: 15000, // Reconnect attempt window
36
+ maxParticipants: 10, // Max participants in group call
37
+
38
+ // ICE servers for NAT traversal
39
+ // ⚠️ ETHOS: Empty by default — Yakmesh mesh relay is preferred
40
+ // For hybrid deployments, configure your own STUN/TURN servers:
41
+ // iceServers: [{ urls: 'stun:your.stun.server:3478' }]
42
+ iceServers: [],
43
+
44
+ // Mesh relay settings (preferred over external STUN/TURN)
45
+ meshRelayEnabled: true,
46
+ meshRelayTimeout: 5000, // Try mesh relay after 5s of ICE failure
47
+
48
+ // Message types
49
+ messageTypes: {
50
+ // Call setup
51
+ CALL_OFFER: 'vani:call:offer',
52
+ CALL_ANSWER: 'vani:call:answer',
53
+ CALL_REJECT: 'vani:call:reject',
54
+ CALL_END: 'vani:call:end',
55
+ CALL_BUSY: 'vani:call:busy',
56
+
57
+ // WebRTC signaling
58
+ SDP_OFFER: 'vani:sdp:offer',
59
+ SDP_ANSWER: 'vani:sdp:answer',
60
+ ICE_CANDIDATE: 'vani:ice:candidate',
61
+
62
+ // Call control
63
+ MUTE_AUDIO: 'vani:mute:audio',
64
+ MUTE_VIDEO: 'vani:mute:video',
65
+ SCREEN_SHARE_START: 'vani:screen:start',
66
+ SCREEN_SHARE_STOP: 'vani:screen:stop',
67
+
68
+ // Group calls
69
+ PARTICIPANT_JOIN: 'vani:participant:join',
70
+ PARTICIPANT_LEAVE: 'vani:participant:leave',
71
+ PARTICIPANT_LIST: 'vani:participant:list',
72
+ },
73
+
74
+ // Media constraints
75
+ defaultConstraints: {
76
+ audio: {
77
+ echoCancellation: true,
78
+ noiseSuppression: true,
79
+ autoGainControl: true,
80
+ },
81
+ video: {
82
+ width: { ideal: 1280, max: 1920 },
83
+ height: { ideal: 720, max: 1080 },
84
+ frameRate: { ideal: 30, max: 60 },
85
+ },
86
+ },
87
+ });
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════════
90
+ // CALL STATES
91
+ // ═══════════════════════════════════════════════════════════════════════════════
92
+
93
+ export const CALL_STATE = Object.freeze({
94
+ IDLE: 'idle',
95
+ INITIATING: 'initiating', // Creating offer
96
+ RINGING: 'ringing', // Waiting for answer
97
+ INCOMING: 'incoming', // Received call
98
+ CONNECTING: 'connecting', // Exchanging ICE
99
+ CONNECTED: 'connected', // Media flowing
100
+ RECONNECTING: 'reconnecting', // Temporary disconnect
101
+ ENDED: 'ended', // Call terminated
102
+ FAILED: 'failed', // Call failed
103
+ });
104
+
105
+ export const CALL_END_REASON = Object.freeze({
106
+ NORMAL: 'normal', // Normal hangup
107
+ REJECTED: 'rejected', // Callee rejected
108
+ BUSY: 'busy', // Callee busy
109
+ TIMEOUT: 'timeout', // No answer
110
+ FAILED: 'failed', // Connection failed
111
+ NETWORK_ERROR: 'network', // Network issue
112
+ PARTICIPANT_LEFT: 'left', // Participant left group
113
+ });
114
+
115
+ export const MEDIA_TYPE = Object.freeze({
116
+ AUDIO: 'audio',
117
+ VIDEO: 'video',
118
+ SCREEN: 'screen',
119
+ });
120
+
121
+ // ═══════════════════════════════════════════════════════════════════════════════
122
+ // VANI PARTICIPANT - Individual call participant
123
+ // ═══════════════════════════════════════════════════════════════════════════════
124
+
125
+ /**
126
+ * VaniParticipant - Represents a participant in a call
127
+ */
128
+ export class VaniParticipant {
129
+ constructor(options = {}) {
130
+ this.id = options.id || VaniParticipant.generateId();
131
+ this.peerId = options.peerId; // Mesh node ID
132
+ this.displayName = options.displayName || options.peerId;
133
+ this.joinedAt = options.joinedAt || Date.now();
134
+
135
+ // Media state
136
+ this.audioEnabled = options.audioEnabled !== false;
137
+ this.videoEnabled = options.videoEnabled !== false;
138
+ this.screenSharing = options.screenSharing || false;
139
+
140
+ // Connection state
141
+ this.connectionState = options.connectionState || 'new';
142
+ this.iceConnectionState = options.iceConnectionState || 'new';
143
+
144
+ // WebRTC peer connection (set externally)
145
+ this.peerConnection = null;
146
+ this.localStream = null;
147
+ this.remoteStream = null;
148
+ }
149
+
150
+ static generateId() {
151
+ return 'p-' + bytesToHex(randomBytes(8));
152
+ }
153
+
154
+ toJSON() {
155
+ return {
156
+ id: this.id,
157
+ peerId: this.peerId,
158
+ displayName: this.displayName,
159
+ joinedAt: this.joinedAt,
160
+ audioEnabled: this.audioEnabled,
161
+ videoEnabled: this.videoEnabled,
162
+ screenSharing: this.screenSharing,
163
+ connectionState: this.connectionState,
164
+ iceConnectionState: this.iceConnectionState,
165
+ };
166
+ }
167
+
168
+ static fromJSON(json) {
169
+ return new VaniParticipant(json);
170
+ }
171
+ }
172
+
173
+ // ═══════════════════════════════════════════════════════════════════════════════
174
+ // VANI SIGNAL - Signaling message
175
+ // ═══════════════════════════════════════════════════════════════════════════════
176
+
177
+ /**
178
+ * VaniSignal - A signaling message for call setup
179
+ */
180
+ export class VaniSignal {
181
+ constructor(options = {}) {
182
+ this.id = options.id || VaniSignal.generateId();
183
+ this.type = options.type;
184
+ this.callId = options.callId;
185
+ this.fromPeer = options.fromPeer;
186
+ this.toPeer = options.toPeer; // null for broadcast in group
187
+ this.timestamp = options.timestamp || Date.now();
188
+ this.payload = options.payload || {};
189
+ }
190
+
191
+ static generateId() {
192
+ return 's-' + bytesToHex(randomBytes(8));
193
+ }
194
+
195
+ /**
196
+ * Create a call offer signal
197
+ */
198
+ static offer(options) {
199
+ return new VaniSignal({
200
+ type: VANI_CONFIG.messageTypes.CALL_OFFER,
201
+ callId: options.callId,
202
+ fromPeer: options.fromPeer,
203
+ toPeer: options.toPeer,
204
+ payload: {
205
+ mediaType: options.mediaType || [MEDIA_TYPE.AUDIO],
206
+ displayName: options.displayName,
207
+ groupCall: options.groupCall || false,
208
+ bundleId: options.bundleId || null, // For GUMBA private calls
209
+ },
210
+ });
211
+ }
212
+
213
+ /**
214
+ * Create an SDP offer signal
215
+ */
216
+ static sdpOffer(options) {
217
+ return new VaniSignal({
218
+ type: VANI_CONFIG.messageTypes.SDP_OFFER,
219
+ callId: options.callId,
220
+ fromPeer: options.fromPeer,
221
+ toPeer: options.toPeer,
222
+ payload: {
223
+ sdp: options.sdp,
224
+ },
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Create an SDP answer signal
230
+ */
231
+ static sdpAnswer(options) {
232
+ return new VaniSignal({
233
+ type: VANI_CONFIG.messageTypes.SDP_ANSWER,
234
+ callId: options.callId,
235
+ fromPeer: options.fromPeer,
236
+ toPeer: options.toPeer,
237
+ payload: {
238
+ sdp: options.sdp,
239
+ },
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Create an ICE candidate signal
245
+ */
246
+ static iceCandidate(options) {
247
+ return new VaniSignal({
248
+ type: VANI_CONFIG.messageTypes.ICE_CANDIDATE,
249
+ callId: options.callId,
250
+ fromPeer: options.fromPeer,
251
+ toPeer: options.toPeer,
252
+ payload: {
253
+ candidate: options.candidate,
254
+ sdpMid: options.sdpMid,
255
+ sdpMLineIndex: options.sdpMLineIndex,
256
+ },
257
+ });
258
+ }
259
+
260
+ /**
261
+ * Create call answer signal
262
+ */
263
+ static answer(options) {
264
+ return new VaniSignal({
265
+ type: VANI_CONFIG.messageTypes.CALL_ANSWER,
266
+ callId: options.callId,
267
+ fromPeer: options.fromPeer,
268
+ toPeer: options.toPeer,
269
+ payload: {
270
+ displayName: options.displayName,
271
+ mediaType: options.mediaType,
272
+ },
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Create call reject signal
278
+ */
279
+ static reject(options) {
280
+ return new VaniSignal({
281
+ type: VANI_CONFIG.messageTypes.CALL_REJECT,
282
+ callId: options.callId,
283
+ fromPeer: options.fromPeer,
284
+ toPeer: options.toPeer,
285
+ payload: {
286
+ reason: options.reason || CALL_END_REASON.REJECTED,
287
+ },
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Create call end signal
293
+ */
294
+ static end(options) {
295
+ return new VaniSignal({
296
+ type: VANI_CONFIG.messageTypes.CALL_END,
297
+ callId: options.callId,
298
+ fromPeer: options.fromPeer,
299
+ toPeer: options.toPeer,
300
+ payload: {
301
+ reason: options.reason || CALL_END_REASON.NORMAL,
302
+ },
303
+ });
304
+ }
305
+
306
+ validate() {
307
+ const errors = [];
308
+ if (!this.type) errors.push('type is required');
309
+ if (!this.callId) errors.push('callId is required');
310
+ if (!this.fromPeer) errors.push('fromPeer is required');
311
+ if (!Object.values(VANI_CONFIG.messageTypes).includes(this.type)) {
312
+ errors.push('invalid message type');
313
+ }
314
+ return { valid: errors.length === 0, errors };
315
+ }
316
+
317
+ toJSON() {
318
+ return {
319
+ id: this.id,
320
+ type: this.type,
321
+ callId: this.callId,
322
+ fromPeer: this.fromPeer,
323
+ toPeer: this.toPeer,
324
+ timestamp: this.timestamp,
325
+ payload: this.payload,
326
+ };
327
+ }
328
+
329
+ static fromJSON(json) {
330
+ return new VaniSignal(json);
331
+ }
332
+ }
333
+
334
+ // ═══════════════════════════════════════════════════════════════════════════════
335
+ // VANI CALL - Individual call session
336
+ // ═══════════════════════════════════════════════════════════════════════════════
337
+
338
+ /**
339
+ * VaniCall - Manages a single call session
340
+ *
341
+ * Handles the full call lifecycle including WebRTC setup.
342
+ * Works in both browser and Node.js (Node requires wrtc package).
343
+ */
344
+ export class VaniCall {
345
+ constructor(options = {}) {
346
+ this.id = options.id || VaniCall.generateId();
347
+ this.localPeerId = options.localPeerId;
348
+ this.state = CALL_STATE.IDLE;
349
+ this.isInitiator = options.isInitiator || false;
350
+ this.isGroupCall = options.isGroupCall || false;
351
+ this.bundleId = options.bundleId || null; // GUMBA bundle for private calls
352
+
353
+ // Media settings
354
+ this.mediaType = options.mediaType || [MEDIA_TYPE.AUDIO];
355
+ this.constraints = options.constraints || VANI_CONFIG.defaultConstraints;
356
+ this.iceServers = options.iceServers || VANI_CONFIG.iceServers;
357
+
358
+ // Participants
359
+ this.participants = new Map(); // peerId -> VaniParticipant
360
+
361
+ // WebRTC connections
362
+ this.peerConnections = new Map(); // peerId -> RTCPeerConnection
363
+ this.localStream = null;
364
+ this.remoteStreams = new Map(); // peerId -> MediaStream
365
+
366
+ // Pending ICE candidates (before remote description set)
367
+ this.pendingCandidates = new Map(); // peerId -> []
368
+
369
+ // Timers
370
+ this._ringTimeout = null;
371
+ this._reconnectTimeout = null;
372
+
373
+ // Callbacks
374
+ this.onStateChange = options.onStateChange || (() => {});
375
+ this.onRemoteStream = options.onRemoteStream || (() => {});
376
+ this.onParticipantJoin = options.onParticipantJoin || (() => {});
377
+ this.onParticipantLeave = options.onParticipantLeave || (() => {});
378
+ this.onSignal = options.onSignal || (() => {}); // Send signal via mesh
379
+ this.onError = options.onError || (() => {});
380
+
381
+ // Timestamps
382
+ this.createdAt = Date.now();
383
+ this.connectedAt = null;
384
+ this.endedAt = null;
385
+ this.endReason = null;
386
+ }
387
+
388
+ static generateId() {
389
+ return 'call-' + bytesToHex(randomBytes(8));
390
+ }
391
+
392
+ /**
393
+ * Get RTCPeerConnection (browser or node-wrtc)
394
+ */
395
+ _getRTCPeerConnection() {
396
+ if (typeof RTCPeerConnection !== 'undefined') {
397
+ return RTCPeerConnection;
398
+ }
399
+ // For Node.js, user must provide wrtc
400
+ throw new Error('RTCPeerConnection not available. In Node.js, pass wrtc via options.');
401
+ }
402
+
403
+ /**
404
+ * Set call state and notify
405
+ */
406
+ _setState(newState, reason = null) {
407
+ const oldState = this.state;
408
+ this.state = newState;
409
+
410
+ if (newState === CALL_STATE.CONNECTED && !this.connectedAt) {
411
+ this.connectedAt = Date.now();
412
+ }
413
+ if (newState === CALL_STATE.ENDED || newState === CALL_STATE.FAILED) {
414
+ this.endedAt = Date.now();
415
+ this.endReason = reason;
416
+ this._clearTimers();
417
+ }
418
+
419
+ this.onStateChange(newState, oldState, reason);
420
+ }
421
+
422
+ /**
423
+ * Clear all timers
424
+ */
425
+ _clearTimers() {
426
+ if (this._ringTimeout) {
427
+ clearTimeout(this._ringTimeout);
428
+ this._ringTimeout = null;
429
+ }
430
+ if (this._reconnectTimeout) {
431
+ clearTimeout(this._reconnectTimeout);
432
+ this._reconnectTimeout = null;
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Create peer connection for a remote peer
438
+ */
439
+ _createPeerConnection(remotePeerId) {
440
+ const RTCPeerConnectionClass = this._getRTCPeerConnection();
441
+
442
+ const pc = new RTCPeerConnectionClass({
443
+ iceServers: this.iceServers,
444
+ });
445
+
446
+ // Handle ICE candidates
447
+ pc.onicecandidate = (event) => {
448
+ if (event.candidate) {
449
+ const signal = VaniSignal.iceCandidate({
450
+ callId: this.id,
451
+ fromPeer: this.localPeerId,
452
+ toPeer: remotePeerId,
453
+ candidate: event.candidate.candidate,
454
+ sdpMid: event.candidate.sdpMid,
455
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
456
+ });
457
+ this.onSignal(signal);
458
+ }
459
+ };
460
+
461
+ // Handle connection state changes
462
+ pc.onconnectionstatechange = () => {
463
+ const participant = this.participants.get(remotePeerId);
464
+ if (participant) {
465
+ participant.connectionState = pc.connectionState;
466
+ }
467
+
468
+ this._updateCallState();
469
+ };
470
+
471
+ // Handle ICE connection state
472
+ pc.oniceconnectionstatechange = () => {
473
+ const participant = this.participants.get(remotePeerId);
474
+ if (participant) {
475
+ participant.iceConnectionState = pc.iceConnectionState;
476
+ }
477
+
478
+ if (pc.iceConnectionState === 'failed') {
479
+ this._handleConnectionFailure(remotePeerId);
480
+ }
481
+ };
482
+
483
+ // Handle remote tracks
484
+ pc.ontrack = (event) => {
485
+ let stream = this.remoteStreams.get(remotePeerId);
486
+ if (!stream) {
487
+ stream = new MediaStream();
488
+ this.remoteStreams.set(remotePeerId, stream);
489
+ }
490
+ stream.addTrack(event.track);
491
+
492
+ const participant = this.participants.get(remotePeerId);
493
+ if (participant) {
494
+ participant.remoteStream = stream;
495
+ }
496
+
497
+ this.onRemoteStream(remotePeerId, stream, event.track);
498
+ };
499
+
500
+ this.peerConnections.set(remotePeerId, pc);
501
+ return pc;
502
+ }
503
+
504
+ /**
505
+ * Update overall call state based on connections
506
+ */
507
+ _updateCallState() {
508
+ if (this.state === CALL_STATE.ENDED || this.state === CALL_STATE.FAILED) {
509
+ return;
510
+ }
511
+
512
+ const connections = Array.from(this.peerConnections.values());
513
+
514
+ if (connections.length === 0) {
515
+ return;
516
+ }
517
+
518
+ // Check if all connected
519
+ const allConnected = connections.every(pc => pc.connectionState === 'connected');
520
+ if (allConnected && this.state !== CALL_STATE.CONNECTED) {
521
+ this._setState(CALL_STATE.CONNECTED);
522
+ return;
523
+ }
524
+
525
+ // Check if any connecting
526
+ const anyConnecting = connections.some(pc =>
527
+ ['new', 'connecting', 'checking'].includes(pc.connectionState) ||
528
+ ['new', 'checking'].includes(pc.iceConnectionState)
529
+ );
530
+ if (anyConnecting && this.state === CALL_STATE.RINGING) {
531
+ this._setState(CALL_STATE.CONNECTING);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Handle connection failure for a peer
537
+ */
538
+ _handleConnectionFailure(peerId) {
539
+ if (this.isGroupCall && this.peerConnections.size > 1) {
540
+ // In group call, just remove the failed peer
541
+ this.removeParticipant(peerId, CALL_END_REASON.FAILED);
542
+ } else {
543
+ // In 1:1 call, end the call
544
+ this._setState(CALL_STATE.FAILED, CALL_END_REASON.FAILED);
545
+ this.end(CALL_END_REASON.FAILED);
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Get local media stream
551
+ */
552
+ async getLocalStream() {
553
+ if (this.localStream) {
554
+ return this.localStream;
555
+ }
556
+
557
+ // Build constraints based on media type
558
+ const mediaConstraints = {
559
+ audio: this.mediaType.includes(MEDIA_TYPE.AUDIO) ? this.constraints.audio : false,
560
+ video: this.mediaType.includes(MEDIA_TYPE.VIDEO) ? this.constraints.video : false,
561
+ };
562
+
563
+ try {
564
+ // navigator.mediaDevices is browser API
565
+ if (typeof navigator !== 'undefined' && navigator.mediaDevices) {
566
+ this.localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints);
567
+ } else {
568
+ // For Node.js testing, create mock stream
569
+ this.localStream = this._createMockStream();
570
+ }
571
+ return this.localStream;
572
+ } catch (error) {
573
+ this.onError('MEDIA_ACCESS_DENIED', error);
574
+ throw error;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Create mock stream for testing
580
+ */
581
+ _createMockStream() {
582
+ // Return an object that mimics MediaStream for testing
583
+ return {
584
+ id: 'mock-stream-' + Date.now(),
585
+ active: true,
586
+ getTracks: () => [],
587
+ getAudioTracks: () => [],
588
+ getVideoTracks: () => [],
589
+ addTrack: () => {},
590
+ removeTrack: () => {},
591
+ };
592
+ }
593
+
594
+ /**
595
+ * Initiate a call to one or more peers
596
+ */
597
+ async initiate(targetPeerIds) {
598
+ if (this.state !== CALL_STATE.IDLE) {
599
+ throw new Error('Call already in progress');
600
+ }
601
+
602
+ this.isInitiator = true;
603
+ this._setState(CALL_STATE.INITIATING);
604
+
605
+ // Get local media
606
+ const stream = await this.getLocalStream();
607
+
608
+ // Create participants
609
+ const peerIds = Array.isArray(targetPeerIds) ? targetPeerIds : [targetPeerIds];
610
+
611
+ for (const peerId of peerIds) {
612
+ const participant = new VaniParticipant({
613
+ peerId,
614
+ });
615
+ this.participants.set(peerId, participant);
616
+
617
+ // Send call offer
618
+ const offer = VaniSignal.offer({
619
+ callId: this.id,
620
+ fromPeer: this.localPeerId,
621
+ toPeer: peerId,
622
+ mediaType: this.mediaType,
623
+ groupCall: peerIds.length > 1,
624
+ bundleId: this.bundleId,
625
+ });
626
+ this.onSignal(offer);
627
+ }
628
+
629
+ this._setState(CALL_STATE.RINGING);
630
+
631
+ // Set ring timeout
632
+ this._ringTimeout = setTimeout(() => {
633
+ if (this.state === CALL_STATE.RINGING) {
634
+ this._setState(CALL_STATE.ENDED, CALL_END_REASON.TIMEOUT);
635
+ this.end(CALL_END_REASON.TIMEOUT);
636
+ }
637
+ }, VANI_CONFIG.callTimeout);
638
+
639
+ return this;
640
+ }
641
+
642
+ /**
643
+ * Accept an incoming call
644
+ */
645
+ async accept(callerPeerId) {
646
+ if (this.state !== CALL_STATE.INCOMING) {
647
+ throw new Error('No incoming call to accept');
648
+ }
649
+
650
+ // Get local media
651
+ const stream = await this.getLocalStream();
652
+
653
+ // Create peer connection
654
+ const pc = this._createPeerConnection(callerPeerId);
655
+
656
+ // Add local tracks
657
+ if (stream && stream.getTracks) {
658
+ for (const track of stream.getTracks()) {
659
+ pc.addTrack(track, stream);
660
+ }
661
+ }
662
+
663
+ // Send answer signal
664
+ const answer = VaniSignal.answer({
665
+ callId: this.id,
666
+ fromPeer: this.localPeerId,
667
+ toPeer: callerPeerId,
668
+ mediaType: this.mediaType,
669
+ });
670
+ this.onSignal(answer);
671
+
672
+ this._setState(CALL_STATE.CONNECTING);
673
+
674
+ return this;
675
+ }
676
+
677
+ /**
678
+ * Reject an incoming call
679
+ */
680
+ reject(reason = CALL_END_REASON.REJECTED) {
681
+ if (this.state !== CALL_STATE.INCOMING) {
682
+ return;
683
+ }
684
+
685
+ for (const peerId of this.participants.keys()) {
686
+ const signal = VaniSignal.reject({
687
+ callId: this.id,
688
+ fromPeer: this.localPeerId,
689
+ toPeer: peerId,
690
+ reason,
691
+ });
692
+ this.onSignal(signal);
693
+ }
694
+
695
+ this._setState(CALL_STATE.ENDED, reason);
696
+ this.cleanup();
697
+ }
698
+
699
+ /**
700
+ * End the call
701
+ */
702
+ end(reason = CALL_END_REASON.NORMAL) {
703
+ if (this.state === CALL_STATE.ENDED) {
704
+ return;
705
+ }
706
+
707
+ // Notify all participants
708
+ for (const peerId of this.participants.keys()) {
709
+ const signal = VaniSignal.end({
710
+ callId: this.id,
711
+ fromPeer: this.localPeerId,
712
+ toPeer: peerId,
713
+ reason,
714
+ });
715
+ this.onSignal(signal);
716
+ }
717
+
718
+ this._setState(CALL_STATE.ENDED, reason);
719
+ this.cleanup();
720
+ }
721
+
722
+ /**
723
+ * Handle incoming signaling message
724
+ */
725
+ async handleSignal(signal) {
726
+ if (signal.callId !== this.id) {
727
+ return;
728
+ }
729
+
730
+ const fromPeer = signal.fromPeer;
731
+
732
+ switch (signal.type) {
733
+ case VANI_CONFIG.messageTypes.CALL_OFFER:
734
+ await this._handleCallOffer(signal);
735
+ break;
736
+
737
+ case VANI_CONFIG.messageTypes.CALL_ANSWER:
738
+ await this._handleCallAnswer(signal);
739
+ break;
740
+
741
+ case VANI_CONFIG.messageTypes.CALL_REJECT:
742
+ this._handleCallReject(signal);
743
+ break;
744
+
745
+ case VANI_CONFIG.messageTypes.CALL_END:
746
+ this._handleCallEnd(signal);
747
+ break;
748
+
749
+ case VANI_CONFIG.messageTypes.SDP_OFFER:
750
+ await this._handleSdpOffer(signal);
751
+ break;
752
+
753
+ case VANI_CONFIG.messageTypes.SDP_ANSWER:
754
+ await this._handleSdpAnswer(signal);
755
+ break;
756
+
757
+ case VANI_CONFIG.messageTypes.ICE_CANDIDATE:
758
+ await this._handleIceCandidate(signal);
759
+ break;
760
+
761
+ case VANI_CONFIG.messageTypes.MUTE_AUDIO:
762
+ case VANI_CONFIG.messageTypes.MUTE_VIDEO:
763
+ this._handleMuteEvent(signal);
764
+ break;
765
+ }
766
+ }
767
+
768
+ async _handleCallOffer(signal) {
769
+ if (this.state !== CALL_STATE.IDLE) {
770
+ // Already in a call, send busy
771
+ const busy = new VaniSignal({
772
+ type: VANI_CONFIG.messageTypes.CALL_BUSY,
773
+ callId: signal.callId,
774
+ fromPeer: this.localPeerId,
775
+ toPeer: signal.fromPeer,
776
+ });
777
+ this.onSignal(busy);
778
+ return;
779
+ }
780
+
781
+ this.isInitiator = false;
782
+ this.mediaType = signal.payload.mediaType || [MEDIA_TYPE.AUDIO];
783
+ this.isGroupCall = signal.payload.groupCall || false;
784
+ this.bundleId = signal.payload.bundleId;
785
+
786
+ // Add caller as participant
787
+ const participant = new VaniParticipant({
788
+ peerId: signal.fromPeer,
789
+ displayName: signal.payload.displayName,
790
+ });
791
+ this.participants.set(signal.fromPeer, participant);
792
+
793
+ this._setState(CALL_STATE.INCOMING);
794
+ }
795
+
796
+ async _handleCallAnswer(signal) {
797
+ if (this.state !== CALL_STATE.RINGING) {
798
+ return;
799
+ }
800
+
801
+ const remotePeerId = signal.fromPeer;
802
+ const participant = this.participants.get(remotePeerId);
803
+ if (participant) {
804
+ participant.displayName = signal.payload.displayName || participant.displayName;
805
+ }
806
+
807
+ // Create peer connection and start negotiation
808
+ const pc = this._createPeerConnection(remotePeerId);
809
+
810
+ // Add local tracks
811
+ const stream = this.localStream;
812
+ if (stream && stream.getTracks) {
813
+ for (const track of stream.getTracks()) {
814
+ pc.addTrack(track, stream);
815
+ }
816
+ }
817
+
818
+ this._setState(CALL_STATE.CONNECTING);
819
+
820
+ // Create and send SDP offer
821
+ try {
822
+ const offer = await pc.createOffer();
823
+ await pc.setLocalDescription(offer);
824
+
825
+ const sdpSignal = VaniSignal.sdpOffer({
826
+ callId: this.id,
827
+ fromPeer: this.localPeerId,
828
+ toPeer: remotePeerId,
829
+ sdp: offer.sdp,
830
+ });
831
+ this.onSignal(sdpSignal);
832
+ } catch (error) {
833
+ this.onError('SDP_CREATE_FAILED', error);
834
+ }
835
+ }
836
+
837
+ _handleCallReject(signal) {
838
+ this.removeParticipant(signal.fromPeer, signal.payload.reason);
839
+
840
+ if (this.participants.size === 0) {
841
+ this._setState(CALL_STATE.ENDED, signal.payload.reason);
842
+ this.cleanup();
843
+ }
844
+ }
845
+
846
+ _handleCallEnd(signal) {
847
+ if (this.isGroupCall) {
848
+ this.removeParticipant(signal.fromPeer, signal.payload.reason);
849
+ if (this.participants.size === 0) {
850
+ this._setState(CALL_STATE.ENDED, signal.payload.reason);
851
+ this.cleanup();
852
+ }
853
+ } else {
854
+ this._setState(CALL_STATE.ENDED, signal.payload.reason);
855
+ this.cleanup();
856
+ }
857
+ }
858
+
859
+ async _handleSdpOffer(signal) {
860
+ const remotePeerId = signal.fromPeer;
861
+
862
+ let pc = this.peerConnections.get(remotePeerId);
863
+ if (!pc) {
864
+ pc = this._createPeerConnection(remotePeerId);
865
+
866
+ // Add local tracks
867
+ const stream = this.localStream || await this.getLocalStream();
868
+ if (stream && stream.getTracks) {
869
+ for (const track of stream.getTracks()) {
870
+ pc.addTrack(track, stream);
871
+ }
872
+ }
873
+ }
874
+
875
+ try {
876
+ await pc.setRemoteDescription({
877
+ type: 'offer',
878
+ sdp: signal.payload.sdp,
879
+ });
880
+
881
+ // Process pending ICE candidates
882
+ await this._processPendingCandidates(remotePeerId);
883
+
884
+ // Create and send answer
885
+ const answer = await pc.createAnswer();
886
+ await pc.setLocalDescription(answer);
887
+
888
+ const sdpSignal = VaniSignal.sdpAnswer({
889
+ callId: this.id,
890
+ fromPeer: this.localPeerId,
891
+ toPeer: remotePeerId,
892
+ sdp: answer.sdp,
893
+ });
894
+ this.onSignal(sdpSignal);
895
+ } catch (error) {
896
+ this.onError('SDP_NEGOTIATION_FAILED', error);
897
+ }
898
+ }
899
+
900
+ async _handleSdpAnswer(signal) {
901
+ const remotePeerId = signal.fromPeer;
902
+ const pc = this.peerConnections.get(remotePeerId);
903
+
904
+ if (!pc) {
905
+ return;
906
+ }
907
+
908
+ try {
909
+ await pc.setRemoteDescription({
910
+ type: 'answer',
911
+ sdp: signal.payload.sdp,
912
+ });
913
+
914
+ // Process pending ICE candidates
915
+ await this._processPendingCandidates(remotePeerId);
916
+ } catch (error) {
917
+ this.onError('SDP_ANSWER_FAILED', error);
918
+ }
919
+ }
920
+
921
+ async _handleIceCandidate(signal) {
922
+ const remotePeerId = signal.fromPeer;
923
+ const pc = this.peerConnections.get(remotePeerId);
924
+
925
+ const candidate = {
926
+ candidate: signal.payload.candidate,
927
+ sdpMid: signal.payload.sdpMid,
928
+ sdpMLineIndex: signal.payload.sdpMLineIndex,
929
+ };
930
+
931
+ if (!pc || !pc.remoteDescription) {
932
+ // Queue candidate until remote description is set
933
+ if (!this.pendingCandidates.has(remotePeerId)) {
934
+ this.pendingCandidates.set(remotePeerId, []);
935
+ }
936
+ this.pendingCandidates.get(remotePeerId).push(candidate);
937
+ return;
938
+ }
939
+
940
+ try {
941
+ await pc.addIceCandidate(candidate);
942
+ } catch (error) {
943
+ // Ignore candidate errors
944
+ }
945
+ }
946
+
947
+ async _processPendingCandidates(remotePeerId) {
948
+ const candidates = this.pendingCandidates.get(remotePeerId);
949
+ if (!candidates) return;
950
+
951
+ const pc = this.peerConnections.get(remotePeerId);
952
+ if (!pc) return;
953
+
954
+ for (const candidate of candidates) {
955
+ try {
956
+ await pc.addIceCandidate(candidate);
957
+ } catch (error) {
958
+ // Ignore
959
+ }
960
+ }
961
+
962
+ this.pendingCandidates.delete(remotePeerId);
963
+ }
964
+
965
+ _handleMuteEvent(signal) {
966
+ const participant = this.participants.get(signal.fromPeer);
967
+ if (!participant) return;
968
+
969
+ if (signal.type === VANI_CONFIG.messageTypes.MUTE_AUDIO) {
970
+ participant.audioEnabled = !signal.payload.muted;
971
+ } else if (signal.type === VANI_CONFIG.messageTypes.MUTE_VIDEO) {
972
+ participant.videoEnabled = !signal.payload.muted;
973
+ }
974
+ }
975
+
976
+ /**
977
+ * Mute/unmute local audio
978
+ */
979
+ setAudioEnabled(enabled) {
980
+ if (this.localStream && this.localStream.getAudioTracks) {
981
+ for (const track of this.localStream.getAudioTracks()) {
982
+ track.enabled = enabled;
983
+ }
984
+ }
985
+
986
+ // Notify peers
987
+ const signal = new VaniSignal({
988
+ type: VANI_CONFIG.messageTypes.MUTE_AUDIO,
989
+ callId: this.id,
990
+ fromPeer: this.localPeerId,
991
+ toPeer: null,
992
+ payload: { muted: !enabled },
993
+ });
994
+ this.onSignal(signal);
995
+ }
996
+
997
+ /**
998
+ * Mute/unmute local video
999
+ */
1000
+ setVideoEnabled(enabled) {
1001
+ if (this.localStream && this.localStream.getVideoTracks) {
1002
+ for (const track of this.localStream.getVideoTracks()) {
1003
+ track.enabled = enabled;
1004
+ }
1005
+ }
1006
+
1007
+ const signal = new VaniSignal({
1008
+ type: VANI_CONFIG.messageTypes.MUTE_VIDEO,
1009
+ callId: this.id,
1010
+ fromPeer: this.localPeerId,
1011
+ toPeer: null,
1012
+ payload: { muted: !enabled },
1013
+ });
1014
+ this.onSignal(signal);
1015
+ }
1016
+
1017
+ /**
1018
+ * Add a participant to group call
1019
+ */
1020
+ addParticipant(peerId, displayName) {
1021
+ if (!this.isGroupCall) {
1022
+ throw new Error('Cannot add participant to 1:1 call');
1023
+ }
1024
+
1025
+ const participant = new VaniParticipant({
1026
+ peerId,
1027
+ displayName,
1028
+ });
1029
+ this.participants.set(peerId, participant);
1030
+ this.onParticipantJoin(participant);
1031
+
1032
+ return participant;
1033
+ }
1034
+
1035
+ /**
1036
+ * Remove a participant
1037
+ */
1038
+ removeParticipant(peerId, reason = CALL_END_REASON.PARTICIPANT_LEFT) {
1039
+ const participant = this.participants.get(peerId);
1040
+ if (!participant) return;
1041
+
1042
+ // Close peer connection
1043
+ const pc = this.peerConnections.get(peerId);
1044
+ if (pc) {
1045
+ pc.close();
1046
+ this.peerConnections.delete(peerId);
1047
+ }
1048
+
1049
+ // Remove remote stream
1050
+ this.remoteStreams.delete(peerId);
1051
+ this.pendingCandidates.delete(peerId);
1052
+ this.participants.delete(peerId);
1053
+
1054
+ this.onParticipantLeave(participant, reason);
1055
+ }
1056
+
1057
+ /**
1058
+ * Clean up all resources
1059
+ */
1060
+ cleanup() {
1061
+ this._clearTimers();
1062
+
1063
+ // Close all peer connections
1064
+ for (const pc of this.peerConnections.values()) {
1065
+ pc.close();
1066
+ }
1067
+ this.peerConnections.clear();
1068
+
1069
+ // Stop local stream
1070
+ if (this.localStream && this.localStream.getTracks) {
1071
+ for (const track of this.localStream.getTracks()) {
1072
+ track.stop();
1073
+ }
1074
+ }
1075
+ this.localStream = null;
1076
+
1077
+ // Clear remote streams
1078
+ this.remoteStreams.clear();
1079
+ this.pendingCandidates.clear();
1080
+ }
1081
+
1082
+ /**
1083
+ * Get call duration in ms
1084
+ */
1085
+ getDuration() {
1086
+ if (!this.connectedAt) return 0;
1087
+ const end = this.endedAt || Date.now();
1088
+ return end - this.connectedAt;
1089
+ }
1090
+
1091
+ /**
1092
+ * Get call info
1093
+ */
1094
+ toJSON() {
1095
+ return {
1096
+ id: this.id,
1097
+ localPeerId: this.localPeerId,
1098
+ state: this.state,
1099
+ isInitiator: this.isInitiator,
1100
+ isGroupCall: this.isGroupCall,
1101
+ bundleId: this.bundleId,
1102
+ mediaType: this.mediaType,
1103
+ participants: Array.from(this.participants.values()).map(p => p.toJSON()),
1104
+ createdAt: this.createdAt,
1105
+ connectedAt: this.connectedAt,
1106
+ endedAt: this.endedAt,
1107
+ endReason: this.endReason,
1108
+ duration: this.getDuration(),
1109
+ };
1110
+ }
1111
+ }
1112
+
1113
+ // ═══════════════════════════════════════════════════════════════════════════════
1114
+ // VANI HUB - Multi-call manager
1115
+ // ═══════════════════════════════════════════════════════════════════════════════
1116
+
1117
+ /**
1118
+ * VaniHub - Manages multiple concurrent calls
1119
+ */
1120
+ export class VaniHub {
1121
+ constructor(options = {}) {
1122
+ this.localPeerId = options.localPeerId;
1123
+ this.iceServers = options.iceServers || VANI_CONFIG.iceServers;
1124
+ this.calls = new Map(); // callId -> VaniCall
1125
+ this.activeCallId = null;
1126
+
1127
+ this.eventHandlers = new Map();
1128
+ this.onSignal = options.onSignal || (() => {});
1129
+ }
1130
+
1131
+ /**
1132
+ * Start a new call
1133
+ */
1134
+ async startCall(options) {
1135
+ const call = new VaniCall({
1136
+ localPeerId: this.localPeerId,
1137
+ iceServers: this.iceServers,
1138
+ mediaType: options.mediaType || [MEDIA_TYPE.AUDIO],
1139
+ isGroupCall: options.isGroupCall || false,
1140
+ bundleId: options.bundleId,
1141
+ onSignal: (signal) => {
1142
+ this.onSignal(signal);
1143
+ this._emit('signal', signal);
1144
+ },
1145
+ onStateChange: (state, old, reason) => {
1146
+ this._emit('stateChange', { callId: call.id, state, oldState: old, reason });
1147
+ if (state === CALL_STATE.ENDED || state === CALL_STATE.FAILED) {
1148
+ this.calls.delete(call.id);
1149
+ if (this.activeCallId === call.id) {
1150
+ this.activeCallId = null;
1151
+ }
1152
+ }
1153
+ },
1154
+ onRemoteStream: (peerId, stream, track) => {
1155
+ this._emit('remoteStream', { callId: call.id, peerId, stream, track });
1156
+ },
1157
+ onParticipantJoin: (participant) => {
1158
+ this._emit('participantJoin', { callId: call.id, participant });
1159
+ },
1160
+ onParticipantLeave: (participant, reason) => {
1161
+ this._emit('participantLeave', { callId: call.id, participant, reason });
1162
+ },
1163
+ onError: (code, error) => {
1164
+ this._emit('error', { callId: call.id, code, error });
1165
+ },
1166
+ });
1167
+
1168
+ this.calls.set(call.id, call);
1169
+ this.activeCallId = call.id;
1170
+
1171
+ const targets = options.targetPeerIds;
1172
+ await call.initiate(targets);
1173
+
1174
+ return call;
1175
+ }
1176
+
1177
+ /**
1178
+ * Handle incoming signal
1179
+ */
1180
+ async handleSignal(signal) {
1181
+ const callId = signal.callId;
1182
+
1183
+ // Check if this is for an existing call
1184
+ let call = this.calls.get(callId);
1185
+
1186
+ // If it's a new call offer, create the call
1187
+ if (!call && signal.type === VANI_CONFIG.messageTypes.CALL_OFFER) {
1188
+ call = new VaniCall({
1189
+ id: callId,
1190
+ localPeerId: this.localPeerId,
1191
+ iceServers: this.iceServers,
1192
+ onSignal: (sig) => {
1193
+ this.onSignal(sig);
1194
+ this._emit('signal', sig);
1195
+ },
1196
+ onStateChange: (state, old, reason) => {
1197
+ this._emit('stateChange', { callId: call.id, state, oldState: old, reason });
1198
+ if (state === CALL_STATE.ENDED || state === CALL_STATE.FAILED) {
1199
+ this.calls.delete(call.id);
1200
+ }
1201
+ },
1202
+ onRemoteStream: (peerId, stream, track) => {
1203
+ this._emit('remoteStream', { callId: call.id, peerId, stream, track });
1204
+ },
1205
+ onParticipantJoin: (participant) => {
1206
+ this._emit('participantJoin', { callId: call.id, participant });
1207
+ },
1208
+ onParticipantLeave: (participant, reason) => {
1209
+ this._emit('participantLeave', { callId: call.id, participant, reason });
1210
+ },
1211
+ onError: (code, error) => {
1212
+ this._emit('error', { callId: call.id, code, error });
1213
+ },
1214
+ });
1215
+
1216
+ this.calls.set(callId, call);
1217
+ this._emit('incomingCall', { call, signal });
1218
+ }
1219
+
1220
+ if (call) {
1221
+ await call.handleSignal(signal);
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Get call by ID
1227
+ */
1228
+ getCall(callId) {
1229
+ return this.calls.get(callId) || null;
1230
+ }
1231
+
1232
+ /**
1233
+ * Get active call
1234
+ */
1235
+ getActiveCall() {
1236
+ return this.activeCallId ? this.calls.get(this.activeCallId) : null;
1237
+ }
1238
+
1239
+ /**
1240
+ * Accept incoming call
1241
+ */
1242
+ async acceptCall(callId) {
1243
+ const call = this.calls.get(callId);
1244
+ if (!call) {
1245
+ throw new Error('Call not found');
1246
+ }
1247
+
1248
+ // End any active call first
1249
+ if (this.activeCallId && this.activeCallId !== callId) {
1250
+ const activeCall = this.calls.get(this.activeCallId);
1251
+ if (activeCall) {
1252
+ activeCall.end(CALL_END_REASON.NORMAL);
1253
+ }
1254
+ }
1255
+
1256
+ this.activeCallId = callId;
1257
+
1258
+ // Get the caller's peer ID from participants
1259
+ const callerPeerId = Array.from(call.participants.keys())[0];
1260
+ await call.accept(callerPeerId);
1261
+
1262
+ return call;
1263
+ }
1264
+
1265
+ /**
1266
+ * Reject incoming call
1267
+ */
1268
+ rejectCall(callId, reason = CALL_END_REASON.REJECTED) {
1269
+ const call = this.calls.get(callId);
1270
+ if (call) {
1271
+ call.reject(reason);
1272
+ this.calls.delete(callId);
1273
+ }
1274
+ }
1275
+
1276
+ /**
1277
+ * End a call
1278
+ */
1279
+ endCall(callId, reason = CALL_END_REASON.NORMAL) {
1280
+ const call = this.calls.get(callId);
1281
+ if (call) {
1282
+ call.end(reason);
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * Register event handler
1288
+ */
1289
+ on(eventType, handler) {
1290
+ if (!this.eventHandlers.has(eventType)) {
1291
+ this.eventHandlers.set(eventType, []);
1292
+ }
1293
+ this.eventHandlers.get(eventType).push(handler);
1294
+ }
1295
+
1296
+ /**
1297
+ * Remove event handler
1298
+ */
1299
+ off(eventType, handler) {
1300
+ const handlers = this.eventHandlers.get(eventType);
1301
+ if (handlers) {
1302
+ const idx = handlers.indexOf(handler);
1303
+ if (idx >= 0) handlers.splice(idx, 1);
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * Emit event
1309
+ */
1310
+ _emit(eventType, data) {
1311
+ const handlers = this.eventHandlers.get(eventType) || [];
1312
+ for (const handler of handlers) {
1313
+ try {
1314
+ handler(data);
1315
+ } catch (err) {
1316
+ console.error('Vani handler error:', err);
1317
+ }
1318
+ }
1319
+ }
1320
+
1321
+ /**
1322
+ * Get hub stats
1323
+ */
1324
+ getStats() {
1325
+ return {
1326
+ localPeerId: this.localPeerId,
1327
+ activeCallId: this.activeCallId,
1328
+ callCount: this.calls.size,
1329
+ calls: Array.from(this.calls.values()).map(c => ({
1330
+ id: c.id,
1331
+ state: c.state,
1332
+ participants: c.participants.size,
1333
+ duration: c.getDuration(),
1334
+ })),
1335
+ };
1336
+ }
1337
+
1338
+ /**
1339
+ * Clean up all calls
1340
+ */
1341
+ cleanup() {
1342
+ for (const call of this.calls.values()) {
1343
+ call.end(CALL_END_REASON.NORMAL);
1344
+ call.cleanup();
1345
+ }
1346
+ this.calls.clear();
1347
+ this.activeCallId = null;
1348
+ }
1349
+ }
1350
+
1351
+ // ═══════════════════════════════════════════════════════════════════════════════
1352
+ // EXPORTS
1353
+ // ═══════════════════════════════════════════════════════════════════════════════
1354
+
1355
+ export default {
1356
+ VANI_CONFIG,
1357
+ CALL_STATE,
1358
+ CALL_END_REASON,
1359
+ MEDIA_TYPE,
1360
+ VaniParticipant,
1361
+ VaniSignal,
1362
+ VaniCall,
1363
+ VaniHub,
1364
+ };