@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 +26 -7
- package/package.json +1 -1
- package/src/calls.js +145 -45
- package/src/index.js +19 -0
- package/src/rooms.js +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# @anonotf/connect
|
|
2
2
|
|
|
3
|
-
Client SDK for AnonOtF — handles calls
|
|
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', (
|
|
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
|
-
|
|
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 —
|
|
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
package/src/calls.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// src/calls.js
|
|
2
2
|
//
|
|
3
|
-
// Wraps
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// candidate themselves
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// .
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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();
|
|
32
|
-
this.
|
|
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;
|
|
35
|
-
this.
|
|
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.
|
|
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);
|
|
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.
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
175
|
+
pc = new RTCPeerConnection(config);
|
|
176
|
+
this._peers.set(remoteUserId, pc);
|
|
121
177
|
|
|
122
|
-
this._localStream?.getTracks().forEach((track) =>
|
|
178
|
+
this._localStream?.getTracks().forEach((track) => pc.addTrack(track, this._localStream));
|
|
123
179
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
this._emit('
|
|
140
|
-
if (
|
|
141
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
|
154
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
285
|
+
_cleanupAll() {
|
|
182
286
|
this._localStream?.getTracks().forEach((t) => t.stop());
|
|
183
287
|
this._localStream = null;
|
|
184
|
-
this.
|
|
185
|
-
this.
|
|
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();
|