@anonotf/connect 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,12 @@
1
1
  # @anonotf/connect
2
2
 
3
- Client SDK for AnonOtF — handles calls, live streaming to many viewers, live chat, and voice notes/recordings.
3
+ Client SDK for AnonOtF — handles calls (1-on-1 and group, up to 9 people),
4
+ live streaming to many viewers, live chat, and voice notes/recordings, without struggling with raw webrtc.
5
+
6
+ > **v0.2.0 — breaking change:** group calls are now a mesh (everyone
7
+ > connects to everyone). `calls.on('remoteStream', ...)` now receives
8
+ > `{ userId, stream }` instead of a bare `stream` — update any v0.1.x
9
+ > integration accordingly. See the Calls section below.
4
10
 
5
11
  ## Install
6
12
 
@@ -13,7 +19,7 @@ npm install @anonotf/connect
13
19
  This SDK runs **in the browser**. Two things must never end up there:
14
20
 
15
21
  - Your AnonOtF **x-api-key** (it can create/delete apps, read all data)
16
- - Your **API secret**
22
+ - Your **Your API secret**
17
23
 
18
24
  So your own backend needs two small pieces:
19
25
 
@@ -45,15 +51,22 @@ client.connect();
45
51
  await client.register({ name: 'Alex' });
46
52
  ```
47
53
 
48
- ## Calls (1-on-1, grows to group)
54
+ ## Calls (1-on-1, grows to real group calls — full mesh)
49
55
 
50
56
  Every accepted call gets a room behind the scenes — that's what lets you
51
- add people later without restarting the call.
57
+ add people later without restarting the call. For 2+ people this is a
58
+ genuine mesh: everyone gets a separate peer connection to everyone else,
59
+ so everyone sees/hears everyone, the same way WhatsApp/Telegram group
60
+ calls behave.
52
61
 
53
62
  ```js
54
63
  // Caller
55
64
  client.calls.on('callAccepted', ({ roomId }) => console.log('connected', roomId));
56
- client.calls.on('remoteStream', (stream) => { videoEl.srcObject = stream; });
65
+ client.calls.on('remoteStream', ({ userId, stream }) => {
66
+ // one event per remote participant — render a tile per userId
67
+ renderVideoTile(userId, stream);
68
+ });
69
+ client.calls.on('peerLeft', ({ userId }) => removeVideoTile(userId));
57
70
  await client.calls.call('user_456', { callType: 'video' });
58
71
 
59
72
  // Callee
@@ -62,11 +75,17 @@ client.calls.on('incomingCall', async ({ from, callType, roomId }) => {
62
75
  });
63
76
 
64
77
  // Mid-call — ring a third person into the SAME call (cap: 9 total)
78
+ // Everyone already in the call automatically meshes with the newcomer —
79
+ // nothing else to wire up.
65
80
  client.calls.addToCall('user_789', { callType: 'video' });
66
81
 
67
- client.calls.end('user_456');
82
+ // Ends the call for yourself — closes every peer connection in your mesh
83
+ client.calls.end();
68
84
  ```
69
85
 
86
+ `client.calls.participantIds` gives you the current list of remote user
87
+ IDs in the mesh at any time, if you need to render an initial roster.
88
+
70
89
  ## Live streaming (broadcast to many viewers)
71
90
 
72
91
  Whether you can publish (vs. just watch) is decided by
@@ -88,7 +107,7 @@ await client.streaming.leave();
88
107
 
89
108
  ## Live chat + raise hand
90
109
 
91
- Ephemeral — Live, nothing is saved server-side.
110
+ Ephemeral — Everything is Live, nothing is saved server-side.
92
111
 
93
112
  ```js
94
113
  client.chat.on('message', ({ fromUserId, text }) => renderMessage(fromUserId, text));
@@ -122,4 +141,4 @@ recorder.onstop = async () => {
122
141
 
123
142
  ```js
124
143
  client.disconnect();
125
- ```
144
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anonotf/connect",
3
- "version": "0.1.0",
4
- "description": "Client SDK for AnonOtF — calls, group calls, live streaming, chat, and voice notes.",
3
+ "version": "0.4.0",
4
+ "description": "Client SDK for AnonOtF — calls, group calls, live streaming, chat, and voice notes, without touching raw WebRTC or Socket.IO directly.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
7
7
  "files": ["src"],
package/src/calls.js CHANGED
@@ -1,16 +1,18 @@
1
1
  // src/calls.js
2
2
  //
3
- // Wraps the raw WebRTC RTCPeerConnection + the server's call-user /
4
- // accept-call / signal / add-to-call socket events. A developer using
5
- // this module never creates an RTCPeerConnection or handles an ICE
6
- // candidate themselves — they call .call(), .accept(), .addToCall(),
7
- // and listen for simple events like 'remoteStream'.
3
+ // Wraps raw WebRTC + the server's call-user / accept-call / signal /
4
+ // add-to-call / join-room socket events. A developer using this
5
+ // module never creates an RTCPeerConnection or handles an ICE
6
+ // candidate themselves.
8
7
  //
9
- // IMPORTANT: every call (even a plain 1-on-1) gets a backing room on
10
- // the server the moment it's accepted that's what makes
11
- // .addToCall() possible later without restarting anything. This
12
- // module doesn't need to know that; it just reacts to whatever
13
- // roomId comes back from the server.
8
+ // MESH MODEL: for 2+ people, this is a full mesh one separate
9
+ // RTCPeerConnection per remote participant, tracked by userId, not a
10
+ // single shared connection. That's what lets everyone see/hear
11
+ // everyone in a group call: N participants means N-1 peer
12
+ // connections per device.
13
+ //
14
+ // Every accepted call gets a backing room on the server — that's
15
+ // what makes .addToCall() possible later without restarting anything.
14
16
 
15
17
  const RTC_CONFIG_DEFAULTS = {
16
18
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], // overridden by fetchIceServers() below when available
@@ -28,26 +30,34 @@ export class Calls {
28
30
  constructor(socketManager, { fetchIceServers } = {}) {
29
31
  this._socket = socketManager;
30
32
  this._fetchIceServers = fetchIceServers;
31
- this._listeners = new Map(); // event -> Set<handler>, for this module's own public events
32
- this._pc = null; // current RTCPeerConnection, one active call at a time
33
+ this._listeners = new Map(); // event -> Set<handler>
34
+ this._peers = new Map(); // userId -> RTCPeerConnection, one per remote participant
33
35
  this._localStream = null;
34
- this.roomId = null; // set once a call is accepted — null when idle
35
- this._pendingOffer = null; // offer received before the local peer connection exists yet
36
+ this.roomId = null; // set once a call is accepted/joined — null when idle
37
+ this._inCall = false; // true from accept/connect onward, used to decide whether to mesh-connect to newcomers
38
+ this._screenTrack = null; // active screen-capture track, if currently sharing
39
+ this._hadCameraBeforeShare = false; // so stopScreenShare() knows whether to restore the camera or just go video-off
36
40
 
37
41
  this._socket.on('incoming-call', (data) => this._emit('incomingCall', data));
38
42
  this._socket.on('call-accepted', (data) => this._onCallAccepted(data));
39
43
  this._socket.on('call-declined', (data) => this._emit('callDeclined', data));
40
44
  this._socket.on('call-cancelled', (data) => this._emit('callCancelled', data));
41
- this._socket.on('call-ended', (data) => this._teardown('callEnded', data));
45
+ this._socket.on('call-ended', (data) => this._onCallEnded(data));
42
46
  this._socket.on('call-error', (data) => this._emit('callError', data));
43
47
  this._socket.on('signal', (payload) => this._onSignal(payload));
48
+
49
+ // Mesh growth/shrink — fired by the room itself (rooms.js also
50
+ // listens to these for its own purposes; both can coexist since
51
+ // socket.on supports multiple handlers per event).
52
+ this._socket.on('user-joined', (data) => this._onUserJoined(data));
53
+ this._socket.on('user-left', (data) => this._onUserLeft(data));
44
54
  }
45
55
 
46
56
  // ---- public event subscription ----
47
57
  on(event, handler) {
48
58
  if (!this._listeners.has(event)) this._listeners.set(event, new Set());
49
59
  this._listeners.get(event).add(handler);
50
- return () => this._listeners.get(event)?.delete(handler); // returns an unsubscribe fn
60
+ return () => this._listeners.get(event)?.delete(handler);
51
61
  }
52
62
 
53
63
  _emit(event, data) {
@@ -67,7 +77,7 @@ export class Calls {
67
77
 
68
78
  cancelCall(calleeId) {
69
79
  this._socket.emit('call-cancelled', { calleeId });
70
- this._cleanupLocal();
80
+ this._cleanupAll();
71
81
  }
72
82
 
73
83
  // ---- incoming call ----
@@ -87,9 +97,11 @@ export class Calls {
87
97
  this._socket.emit('add-to-call', { roomId: this.roomId, newUserId, callType, callerName });
88
98
  }
89
99
 
90
- end(otherPartyId) {
91
- this._socket.emit('end-call', { otherParty: otherPartyId });
92
- this._teardown('callEnded', { from: 'self' });
100
+ /** Ends the call for yourself — closes every peer connection in the mesh, notifies the room. */
101
+ end() {
102
+ this._socket.emit('end-call', { otherParty: this._anyPeerId() });
103
+ this._cleanupAll();
104
+ this._emit('callEnded', { from: 'self' });
93
105
  }
94
106
 
95
107
  /** Notify everyone in the call that you've started/stopped a local recording. */
@@ -104,7 +116,102 @@ export class Calls {
104
116
  return this._localStream;
105
117
  }
106
118
 
119
+ /** Current remote participant user IDs in the mesh. */
120
+ get participantIds() {
121
+ return [...this._peers.keys()];
122
+ }
123
+
124
+ /**
125
+ * Swaps the outgoing video track on EVERY peer connection in the
126
+ * mesh at once — for camera flip (front/back), or any other
127
+ * "swap what I'm sending" need. Also updates localStream so your
128
+ * own preview reflects the new track. Does nothing to audio.
129
+ */
130
+ async replaceVideoTrack(newTrack) {
131
+ for (const pc of this._peers.values()) {
132
+ const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'video');
133
+ if (sender) await sender.replaceTrack(newTrack);
134
+ }
135
+ if (this._localStream) {
136
+ const oldTrack = this._localStream.getVideoTracks()[0];
137
+ if (oldTrack) {
138
+ this._localStream.removeTrack(oldTrack);
139
+ oldTrack.stop();
140
+ }
141
+ this._localStream.addTrack(newTrack);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Starts sharing your screen instead of your camera — swaps the
147
+ * outgoing video track on every peer connection in the mesh, same
148
+ * mechanism as camera flip. Remembers whether you had a camera
149
+ * track running so stopScreenShare() can restore it afterward.
150
+ * Audio is untouched either way.
151
+ *
152
+ * Fires 'screenShareStarted'/'screenShareEnded' — the latter also
153
+ * fires automatically if the user stops sharing from the browser's
154
+ * own "Stop sharing" control rather than your UI.
155
+ */
156
+ async startScreenShare() {
157
+ if (this._screenTrack) return; // already sharing
158
+ const displayStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false });
159
+ const screenTrack = displayStream.getVideoTracks()[0];
160
+
161
+ this._hadCameraBeforeShare = !!this._localStream?.getVideoTracks().length;
162
+ this._screenTrack = screenTrack;
163
+
164
+ await this.replaceVideoTrack(screenTrack);
165
+ this._emit('screenShareStarted');
166
+
167
+ // Browser's native "Stop sharing" UI ends the track directly —
168
+ // this catches that case so state stays accurate even if the
169
+ // person never calls stopScreenShare() themselves.
170
+ screenTrack.onended = () => {
171
+ if (this._screenTrack === screenTrack) this.stopScreenShare();
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Stops screen sharing and restores your camera (if you had one
177
+ * running before sharing started) across the whole mesh.
178
+ */
179
+ async stopScreenShare() {
180
+ if (!this._screenTrack) return;
181
+ this._screenTrack.stop();
182
+ this._screenTrack = null;
183
+
184
+ if (this._hadCameraBeforeShare) {
185
+ const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true });
186
+ await this.replaceVideoTrack(cameraStream.getVideoTracks()[0]);
187
+ } else if (this._localStream) {
188
+ // Wasn't sending video before sharing (audio-only call) — go
189
+ // back to that, rather than silently turning video on.
190
+ const track = this._localStream.getVideoTracks()[0];
191
+ if (track) { this._localStream.removeTrack(track); track.stop(); }
192
+ }
193
+ this._emit('screenShareEnded');
194
+ }
195
+
196
+ get isScreenSharing() {
197
+ return !!this._screenTrack;
198
+ }
199
+
200
+ /** Mute/unmute your own mic across the whole mesh — just disables the local track, no renegotiation needed. */
201
+ setMicEnabled(enabled) {
202
+ this._localStream?.getAudioTracks().forEach((t) => { t.enabled = enabled; });
203
+ }
204
+
205
+ /** Pause/resume your own camera across the whole mesh — same idea as setMicEnabled. */
206
+ setCameraEnabled(enabled) {
207
+ this._localStream?.getVideoTracks().forEach((t) => { t.enabled = enabled; });
208
+ }
209
+
107
210
  // ---- internals ----
211
+ _anyPeerId() {
212
+ return this._peers.keys().next().value;
213
+ }
214
+
108
215
  async _ensureLocalStream(callType) {
109
216
  if (this._localStream) return this._localStream;
110
217
  const constraints = callType === 'video' ? { audio: true, video: true } : { audio: true, video: false };
@@ -113,58 +220,112 @@ export class Calls {
113
220
  return this._localStream;
114
221
  }
115
222
 
116
- async _ensurePeerConnection() {
117
- if (this._pc) return this._pc;
223
+ // Creates (or returns the existing) peer connection for one specific
224
+ // remote user. Every track/ICE/connection-state handler is scoped to
225
+ // that userId, so multi-party events (remoteStream, peerLeft) always
226
+ // tell you WHICH participant they're about.
227
+ async _ensurePeerFor(remoteUserId) {
228
+ let pc = this._peers.get(remoteUserId);
229
+ if (pc) return pc;
118
230
 
119
231
  const config = this._fetchIceServers ? await this._fetchIceServers() : RTC_CONFIG_DEFAULTS;
120
- this._pc = new RTCPeerConnection(config);
232
+ pc = new RTCPeerConnection(config);
233
+ this._peers.set(remoteUserId, pc);
121
234
 
122
- this._localStream?.getTracks().forEach((track) => this._pc.addTrack(track, this._localStream));
235
+ this._localStream?.getTracks().forEach((track) => pc.addTrack(track, this._localStream));
123
236
 
124
- this._pc.onicecandidate = (event) => {
237
+ pc.onicecandidate = (event) => {
125
238
  if (event.candidate) {
126
239
  this._socket.emit('signal', {
127
240
  type: 'ice-candidate',
128
241
  candidate: event.candidate,
129
242
  roomId: this.roomId,
243
+ to: remoteUserId,
130
244
  });
131
245
  }
132
246
  };
133
247
 
134
- this._pc.ontrack = (event) => {
135
- this._emit('remoteStream', event.streams[0]);
248
+ pc.ontrack = (event) => {
249
+ this._emit('remoteStream', { userId: remoteUserId, stream: event.streams[0] });
136
250
  };
137
251
 
138
- this._pc.onconnectionstatechange = () => {
139
- this._emit('connectionStateChange', this._pc.connectionState);
140
- if (this._pc.connectionState === 'failed' || this._pc.connectionState === 'closed') {
141
- this._teardown('callEnded', { reason: this._pc.connectionState });
252
+ pc.onconnectionstatechange = () => {
253
+ this._emit('peerConnectionStateChange', { userId: remoteUserId, state: pc.connectionState });
254
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
255
+ this._removePeer(remoteUserId);
142
256
  }
143
257
  };
144
258
 
145
- return this._pc;
259
+ return pc;
260
+ }
261
+
262
+ _removePeer(remoteUserId) {
263
+ const pc = this._peers.get(remoteUserId);
264
+ if (!pc) return;
265
+ pc.close();
266
+ this._peers.delete(remoteUserId);
267
+ this._emit('peerLeft', { userId: remoteUserId });
268
+ // Mesh of one (just you) left in the room — the call is effectively over.
269
+ if (this._peers.size === 0 && this._inCall) {
270
+ this._cleanupAll();
271
+ this._emit('callEnded', { reason: 'all-peers-left' });
272
+ }
146
273
  }
147
274
 
275
+ // The ORIGINAL caller, once the callee accepts: send them the first offer.
148
276
  async _onCallAccepted({ from, roomId, callType }) {
149
277
  this.roomId = roomId;
150
- const pc = await this._ensurePeerConnection();
278
+ this._inCall = true;
279
+ await this._sendOfferTo(from);
280
+ this._emit('callAccepted', { from, roomId, callType });
281
+ }
282
+
283
+ async _sendOfferTo(remoteUserId) {
284
+ const pc = await this._ensurePeerFor(remoteUserId);
151
285
  const offer = await pc.createOffer();
152
286
  await pc.setLocalDescription(offer);
153
- this._socket.emit('signal', { type: 'offer', sdp: offer, roomId, to: roomId ? undefined : from });
154
- this._emit('callAccepted', { from, roomId, callType });
287
+ this._socket.emit('signal', { type: 'offer', sdp: offer, roomId: this.roomId, to: remoteUserId });
288
+ }
289
+
290
+ // Mesh growth: someone new joined the room we're actively calling
291
+ // in. Whoever was already in the call proactively offers to the
292
+ // newcomer — the newcomer doesn't need to do anything special,
293
+ // they'll just receive offers from everyone already present.
294
+ async _onUserJoined({ userId }) {
295
+ if (!this._inCall || !this.roomId || this._peers.has(userId)) return;
296
+ this._inCall = true;
297
+ await this._sendOfferTo(userId);
298
+ }
299
+
300
+ _onUserLeft({ userId }) {
301
+ this._removePeer(userId);
302
+ }
303
+
304
+ _onCallEnded(data) {
305
+ // A specific peer telling us they've ended — close just that
306
+ // connection; the mesh (and call) only fully tears down once
307
+ // everyone's gone (handled in _removePeer).
308
+ if (data?.from && this._peers.has(data.from)) {
309
+ this._removePeer(data.from);
310
+ } else {
311
+ this._cleanupAll();
312
+ this._emit('callEnded', data);
313
+ }
155
314
  }
156
315
 
157
316
  async _onSignal(payload) {
158
317
  const { type, sdp, candidate, from, roomId } = payload;
159
318
  if (roomId) this.roomId = roomId;
319
+ if (!from) return; // shouldn't happen — server always stamps `from`
160
320
 
161
- const pc = await this._ensurePeerConnection();
321
+ const pc = await this._ensurePeerFor(from);
322
+ this._inCall = true;
162
323
 
163
324
  if (type === 'offer') {
164
325
  await pc.setRemoteDescription(sdp);
165
326
  const answer = await pc.createAnswer();
166
327
  await pc.setLocalDescription(answer);
167
- this._socket.emit('signal', { type: 'answer', sdp: answer, roomId, to: roomId ? undefined : from });
328
+ this._socket.emit('signal', { type: 'answer', sdp: answer, roomId: this.roomId, to: from });
168
329
  } else if (type === 'answer') {
169
330
  await pc.setRemoteDescription(sdp);
170
331
  } else if (type === 'ice-candidate' && candidate) {
@@ -178,16 +339,14 @@ export class Calls {
178
339
  }
179
340
  }
180
341
 
181
- _cleanupLocal() {
342
+ _cleanupAll() {
182
343
  this._localStream?.getTracks().forEach((t) => t.stop());
183
344
  this._localStream = null;
184
- this._pc?.close();
185
- this._pc = null;
345
+ this._screenTrack?.stop();
346
+ this._screenTrack = null;
347
+ for (const pc of this._peers.values()) pc.close();
348
+ this._peers.clear();
186
349
  this.roomId = null;
187
- }
188
-
189
- _teardown(event, data) {
190
- this._cleanupLocal();
191
- this._emit(event, data);
350
+ this._inCall = false;
192
351
  }
193
352
  }
package/src/index.js CHANGED
@@ -73,6 +73,25 @@ export class AnonOtFConnect {
73
73
  heartbeat() {
74
74
  this._socketManager.emit('heartbeat');
75
75
  }
76
+
77
+ /**
78
+ * Presence events, scoped to the whole connection (not any one
79
+ * call/room) — who's online, who went offline, profile changes.
80
+ * Events: 'onlineUsersUpdate' ({userId,name,photo,emoji,about,online}[]),
81
+ * 'profileUpdated' ({userId,name,photo,emoji,about}),
82
+ * 'userOffline' ({userId,lastSeen}).
83
+ */
84
+ on(event, handler) {
85
+ const serverEventMap = {
86
+ onlineUsersUpdate: 'online-users-update',
87
+ profileUpdated: 'profile-updated',
88
+ userOffline: 'user-offline',
89
+ };
90
+ const serverEvent = serverEventMap[event];
91
+ if (!serverEvent) throw new Error(`[AnonOtFConnect] Unknown event "${event}"`);
92
+ this._socketManager.on(serverEvent, handler);
93
+ return () => this._socketManager.off(serverEvent, handler);
94
+ }
76
95
  }
77
96
 
78
97
  export { Calls, Rooms, Chat, Streaming, MediaClips };
package/src/rooms.js CHANGED
@@ -47,11 +47,11 @@ export class Rooms {
47
47
  }
48
48
 
49
49
  /** Create a new room. Goes through your backend proxy, not directly to AnonOtF, to keep the API key server-side. */
50
- async create({ roomName, maxParticipants } = {}) {
50
+ async create({ roomName, maxParticipants, userId } = {}) {
51
51
  const res = await fetch(`${this._apiBase}/rooms`, {
52
52
  method: 'POST',
53
53
  headers: { 'Content-Type': 'application/json' },
54
- body: JSON.stringify({ roomName, maxParticipants }),
54
+ body: JSON.stringify({ roomName, maxParticipants, userId }),
55
55
  });
56
56
  if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to create room: ${res.status}`);
57
57
  return res.json();