@anonotf/connect 0.1.0 → 0.3.1

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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonotf/connect",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "description": "Client SDK for AnonOtF — calls, group calls, live streaming, chat, and voice notes.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
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,32 @@ 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
36
38
 
37
39
  this._socket.on('incoming-call', (data) => this._emit('incomingCall', data));
38
40
  this._socket.on('call-accepted', (data) => this._onCallAccepted(data));
39
41
  this._socket.on('call-declined', (data) => this._emit('callDeclined', data));
40
42
  this._socket.on('call-cancelled', (data) => this._emit('callCancelled', data));
41
- this._socket.on('call-ended', (data) => this._teardown('callEnded', data));
43
+ this._socket.on('call-ended', (data) => this._onCallEnded(data));
42
44
  this._socket.on('call-error', (data) => this._emit('callError', data));
43
45
  this._socket.on('signal', (payload) => this._onSignal(payload));
46
+
47
+ // Mesh growth/shrink — fired by the room itself (rooms.js also
48
+ // listens to these for its own purposes; both can coexist since
49
+ // socket.on supports multiple handlers per event).
50
+ this._socket.on('user-joined', (data) => this._onUserJoined(data));
51
+ this._socket.on('user-left', (data) => this._onUserLeft(data));
44
52
  }
45
53
 
46
54
  // ---- public event subscription ----
47
55
  on(event, handler) {
48
56
  if (!this._listeners.has(event)) this._listeners.set(event, new Set());
49
57
  this._listeners.get(event).add(handler);
50
- return () => this._listeners.get(event)?.delete(handler); // returns an unsubscribe fn
58
+ return () => this._listeners.get(event)?.delete(handler);
51
59
  }
52
60
 
53
61
  _emit(event, data) {
@@ -67,7 +75,7 @@ export class Calls {
67
75
 
68
76
  cancelCall(calleeId) {
69
77
  this._socket.emit('call-cancelled', { calleeId });
70
- this._cleanupLocal();
78
+ this._cleanupAll();
71
79
  }
72
80
 
73
81
  // ---- incoming call ----
@@ -87,9 +95,11 @@ export class Calls {
87
95
  this._socket.emit('add-to-call', { roomId: this.roomId, newUserId, callType, callerName });
88
96
  }
89
97
 
90
- end(otherPartyId) {
91
- this._socket.emit('end-call', { otherParty: otherPartyId });
92
- this._teardown('callEnded', { from: 'self' });
98
+ /** Ends the call for yourself — closes every peer connection in the mesh, notifies the room. */
99
+ end() {
100
+ this._socket.emit('end-call', { otherParty: this._anyPeerId() });
101
+ this._cleanupAll();
102
+ this._emit('callEnded', { from: 'self' });
93
103
  }
94
104
 
95
105
  /** Notify everyone in the call that you've started/stopped a local recording. */
@@ -104,7 +114,47 @@ export class Calls {
104
114
  return this._localStream;
105
115
  }
106
116
 
117
+ /** Current remote participant user IDs in the mesh. */
118
+ get participantIds() {
119
+ return [...this._peers.keys()];
120
+ }
121
+
122
+ /**
123
+ * Swaps the outgoing video track on EVERY peer connection in the
124
+ * mesh at once — for camera flip (front/back), or any other
125
+ * "swap what I'm sending" need. Also updates localStream so your
126
+ * own preview reflects the new track. Does nothing to audio.
127
+ */
128
+ async replaceVideoTrack(newTrack) {
129
+ for (const pc of this._peers.values()) {
130
+ const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'video');
131
+ if (sender) await sender.replaceTrack(newTrack);
132
+ }
133
+ if (this._localStream) {
134
+ const oldTrack = this._localStream.getVideoTracks()[0];
135
+ if (oldTrack) {
136
+ this._localStream.removeTrack(oldTrack);
137
+ oldTrack.stop();
138
+ }
139
+ this._localStream.addTrack(newTrack);
140
+ }
141
+ }
142
+
143
+ /** Mute/unmute your own mic across the whole mesh — just disables the local track, no renegotiation needed. */
144
+ setMicEnabled(enabled) {
145
+ this._localStream?.getAudioTracks().forEach((t) => { t.enabled = enabled; });
146
+ }
147
+
148
+ /** Pause/resume your own camera across the whole mesh — same idea as setMicEnabled. */
149
+ setCameraEnabled(enabled) {
150
+ this._localStream?.getVideoTracks().forEach((t) => { t.enabled = enabled; });
151
+ }
152
+
107
153
  // ---- internals ----
154
+ _anyPeerId() {
155
+ return this._peers.keys().next().value;
156
+ }
157
+
108
158
  async _ensureLocalStream(callType) {
109
159
  if (this._localStream) return this._localStream;
110
160
  const constraints = callType === 'video' ? { audio: true, video: true } : { audio: true, video: false };
@@ -113,58 +163,112 @@ export class Calls {
113
163
  return this._localStream;
114
164
  }
115
165
 
116
- async _ensurePeerConnection() {
117
- if (this._pc) return this._pc;
166
+ // Creates (or returns the existing) peer connection for one specific
167
+ // remote user. Every track/ICE/connection-state handler is scoped to
168
+ // that userId, so multi-party events (remoteStream, peerLeft) always
169
+ // tell you WHICH participant they're about.
170
+ async _ensurePeerFor(remoteUserId) {
171
+ let pc = this._peers.get(remoteUserId);
172
+ if (pc) return pc;
118
173
 
119
174
  const config = this._fetchIceServers ? await this._fetchIceServers() : RTC_CONFIG_DEFAULTS;
120
- this._pc = new RTCPeerConnection(config);
175
+ pc = new RTCPeerConnection(config);
176
+ this._peers.set(remoteUserId, pc);
121
177
 
122
- this._localStream?.getTracks().forEach((track) => this._pc.addTrack(track, this._localStream));
178
+ this._localStream?.getTracks().forEach((track) => pc.addTrack(track, this._localStream));
123
179
 
124
- this._pc.onicecandidate = (event) => {
180
+ pc.onicecandidate = (event) => {
125
181
  if (event.candidate) {
126
182
  this._socket.emit('signal', {
127
183
  type: 'ice-candidate',
128
184
  candidate: event.candidate,
129
185
  roomId: this.roomId,
186
+ to: remoteUserId,
130
187
  });
131
188
  }
132
189
  };
133
190
 
134
- this._pc.ontrack = (event) => {
135
- this._emit('remoteStream', event.streams[0]);
191
+ pc.ontrack = (event) => {
192
+ this._emit('remoteStream', { userId: remoteUserId, stream: event.streams[0] });
136
193
  };
137
194
 
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 });
195
+ pc.onconnectionstatechange = () => {
196
+ this._emit('peerConnectionStateChange', { userId: remoteUserId, state: pc.connectionState });
197
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
198
+ this._removePeer(remoteUserId);
142
199
  }
143
200
  };
144
201
 
145
- return this._pc;
202
+ return pc;
146
203
  }
147
204
 
205
+ _removePeer(remoteUserId) {
206
+ const pc = this._peers.get(remoteUserId);
207
+ if (!pc) return;
208
+ pc.close();
209
+ this._peers.delete(remoteUserId);
210
+ this._emit('peerLeft', { userId: remoteUserId });
211
+ // Mesh of one (just you) left in the room — the call is effectively over.
212
+ if (this._peers.size === 0 && this._inCall) {
213
+ this._cleanupAll();
214
+ this._emit('callEnded', { reason: 'all-peers-left' });
215
+ }
216
+ }
217
+
218
+ // The ORIGINAL caller, once the callee accepts: send them the first offer.
148
219
  async _onCallAccepted({ from, roomId, callType }) {
149
220
  this.roomId = roomId;
150
- const pc = await this._ensurePeerConnection();
221
+ this._inCall = true;
222
+ await this._sendOfferTo(from);
223
+ this._emit('callAccepted', { from, roomId, callType });
224
+ }
225
+
226
+ async _sendOfferTo(remoteUserId) {
227
+ const pc = await this._ensurePeerFor(remoteUserId);
151
228
  const offer = await pc.createOffer();
152
229
  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 });
230
+ this._socket.emit('signal', { type: 'offer', sdp: offer, roomId: this.roomId, to: remoteUserId });
231
+ }
232
+
233
+ // Mesh growth: someone new joined the room we're actively calling
234
+ // in. Whoever was already in the call proactively offers to the
235
+ // newcomer — the newcomer doesn't need to do anything special,
236
+ // they'll just receive offers from everyone already present.
237
+ async _onUserJoined({ userId }) {
238
+ if (!this._inCall || !this.roomId || this._peers.has(userId)) return;
239
+ this._inCall = true;
240
+ await this._sendOfferTo(userId);
241
+ }
242
+
243
+ _onUserLeft({ userId }) {
244
+ this._removePeer(userId);
245
+ }
246
+
247
+ _onCallEnded(data) {
248
+ // A specific peer telling us they've ended — close just that
249
+ // connection; the mesh (and call) only fully tears down once
250
+ // everyone's gone (handled in _removePeer).
251
+ if (data?.from && this._peers.has(data.from)) {
252
+ this._removePeer(data.from);
253
+ } else {
254
+ this._cleanupAll();
255
+ this._emit('callEnded', data);
256
+ }
155
257
  }
156
258
 
157
259
  async _onSignal(payload) {
158
260
  const { type, sdp, candidate, from, roomId } = payload;
159
261
  if (roomId) this.roomId = roomId;
262
+ if (!from) return; // shouldn't happen — server always stamps `from`
160
263
 
161
- const pc = await this._ensurePeerConnection();
264
+ const pc = await this._ensurePeerFor(from);
265
+ this._inCall = true;
162
266
 
163
267
  if (type === 'offer') {
164
268
  await pc.setRemoteDescription(sdp);
165
269
  const answer = await pc.createAnswer();
166
270
  await pc.setLocalDescription(answer);
167
- this._socket.emit('signal', { type: 'answer', sdp: answer, roomId, to: roomId ? undefined : from });
271
+ this._socket.emit('signal', { type: 'answer', sdp: answer, roomId: this.roomId, to: from });
168
272
  } else if (type === 'answer') {
169
273
  await pc.setRemoteDescription(sdp);
170
274
  } else if (type === 'ice-candidate' && candidate) {
@@ -178,16 +282,12 @@ export class Calls {
178
282
  }
179
283
  }
180
284
 
181
- _cleanupLocal() {
285
+ _cleanupAll() {
182
286
  this._localStream?.getTracks().forEach((t) => t.stop());
183
287
  this._localStream = null;
184
- this._pc?.close();
185
- this._pc = null;
288
+ for (const pc of this._peers.values()) pc.close();
289
+ this._peers.clear();
186
290
  this.roomId = null;
187
- }
188
-
189
- _teardown(event, data) {
190
- this._cleanupLocal();
191
- this._emit(event, data);
291
+ this._inCall = false;
192
292
  }
193
293
  }
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();