@anonotf/connect 0.1.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 +125 -0
- package/package.json +18 -0
- package/src/calls.js +193 -0
- package/src/chat.js +54 -0
- package/src/index.js +78 -0
- package/src/mediaClips.js +60 -0
- package/src/rooms.js +65 -0
- package/src/socket.js +80 -0
- package/src/streaming.js +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @anonotf/connect
|
|
2
|
+
|
|
3
|
+
Client SDK for AnonOtF — handles calls, live streaming to many viewers, live chat, and voice notes/recordings.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @anonotf/connect
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Architecture — read this before wiring it up
|
|
12
|
+
|
|
13
|
+
This SDK runs **in the browser**. Two things must never end up there:
|
|
14
|
+
|
|
15
|
+
- Your AnonOtF **x-api-key** (it can create/delete apps, read all data)
|
|
16
|
+
- Your **API secret**
|
|
17
|
+
|
|
18
|
+
So your own backend needs two small pieces:
|
|
19
|
+
|
|
20
|
+
1. An endpoint that mints a short-lived **socket token** —
|
|
21
|
+
call AnonOtF's `POST /api/apps/:appId/socket-token` with your x-api-key,
|
|
22
|
+
return the token to your frontend.
|
|
23
|
+
2. A thin **REST proxy** for everything under `/api/*` (rooms, media clips,
|
|
24
|
+
streaming tokens) — your frontend calls *your* backend, your backend
|
|
25
|
+
attaches the x-api-key and forwards to AnonOtF.
|
|
26
|
+
|
|
27
|
+
This SDK never sends your x-api-key from the browser. The `serverUrl` you
|
|
28
|
+
pass in is only used for the Socket.IO connection (authenticated by the
|
|
29
|
+
token, not the key); `apiBase` should point at your own backend's proxy.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { AnonOtFConnect } from '@anonotf/connect';
|
|
35
|
+
|
|
36
|
+
// `token` came from YOUR backend, which called AnonOtF's
|
|
37
|
+
// POST /api/apps/:appId/socket-token using your x-api-key.
|
|
38
|
+
const client = new AnonOtFConnect({
|
|
39
|
+
serverUrl: 'https://your-anonotf-server.com',
|
|
40
|
+
token: theTokenYourBackendGaveYou,
|
|
41
|
+
apiBase: 'https://your-backend.com/api', // your own proxy, see above
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
client.connect();
|
|
45
|
+
await client.register({ name: 'Alex' });
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Calls (1-on-1, grows to group)
|
|
49
|
+
|
|
50
|
+
Every accepted call gets a room behind the scenes — that's what lets you
|
|
51
|
+
add people later without restarting the call.
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
// Caller
|
|
55
|
+
client.calls.on('callAccepted', ({ roomId }) => console.log('connected', roomId));
|
|
56
|
+
client.calls.on('remoteStream', (stream) => { videoEl.srcObject = stream; });
|
|
57
|
+
await client.calls.call('user_456', { callType: 'video' });
|
|
58
|
+
|
|
59
|
+
// Callee
|
|
60
|
+
client.calls.on('incomingCall', async ({ from, callType, roomId }) => {
|
|
61
|
+
await client.calls.accept(from, { callType, roomId });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Mid-call — ring a third person into the SAME call (cap: 9 total)
|
|
65
|
+
client.calls.addToCall('user_789', { callType: 'video' });
|
|
66
|
+
|
|
67
|
+
client.calls.end('user_456');
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Live streaming (broadcast to many viewers)
|
|
71
|
+
|
|
72
|
+
Whether you can publish (vs. just watch) is decided by
|
|
73
|
+
the server based on your room role — broadcaster, moderator, and approved
|
|
74
|
+
contributors can publish; plain viewers are subscribe-only.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
const { role } = await client.streaming.join(roomId, myUserId);
|
|
78
|
+
|
|
79
|
+
client.streaming.on('trackSubscribed', ({ track, participant }) => {
|
|
80
|
+
const el = document.createElement(track.kind === 'video' ? 'video' : 'audio');
|
|
81
|
+
track.attach(el);
|
|
82
|
+
container.appendChild(el);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ...later
|
|
86
|
+
await client.streaming.leave();
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Live chat + raise hand
|
|
90
|
+
|
|
91
|
+
Ephemeral — Live, nothing is saved server-side.
|
|
92
|
+
|
|
93
|
+
```js
|
|
94
|
+
client.chat.on('message', ({ fromUserId, text }) => renderMessage(fromUserId, text));
|
|
95
|
+
client.chat.send(roomId, 'hello!');
|
|
96
|
+
|
|
97
|
+
client.chat.raiseHand(roomId);
|
|
98
|
+
|
|
99
|
+
// Broadcaster side
|
|
100
|
+
client.chat.on('handRaised', ({ userId }) => showRaisedHand(userId));
|
|
101
|
+
client.chat.approveContributor(roomId, userId);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Voice notes & call recordings
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const recorder = new MediaRecorder(stream);
|
|
108
|
+
const chunks = [];
|
|
109
|
+
recorder.ondataavailable = (e) => chunks.push(e.data);
|
|
110
|
+
recorder.onstop = async () => {
|
|
111
|
+
const blob = new Blob(chunks, { type: 'audio/webm' });
|
|
112
|
+
await client.mediaClips.upload(blob, {
|
|
113
|
+
type: 'voice_note',
|
|
114
|
+
mediaType: 'audio',
|
|
115
|
+
fromUserId: myUserId,
|
|
116
|
+
roomId, // or toUserId for a DM
|
|
117
|
+
});
|
|
118
|
+
};
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Cleanup
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
client.disconnect();
|
|
125
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anonotf/connect",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client SDK for AnonOtF — calls, group calls, live streaming, chat, and voice notes.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"keywords": ["video-call", "voice-call", "live-streaming", "chat", "sdk"],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"socket.io-client": "^4.7.0",
|
|
12
|
+
"livekit-client": "^2.0.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=16"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/calls.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// src/calls.js
|
|
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'.
|
|
8
|
+
//
|
|
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.
|
|
14
|
+
|
|
15
|
+
const RTC_CONFIG_DEFAULTS = {
|
|
16
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], // overridden by fetchIceServers() below when available
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class Calls {
|
|
20
|
+
/**
|
|
21
|
+
* @param {import('./socket.js').SocketManager} socketManager
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {() => Promise<RTCConfiguration>} [opts.fetchIceServers]
|
|
24
|
+
* Optional — call your backend's GET /api/ice and return the
|
|
25
|
+
* result here for TURN support behind restrictive networks.
|
|
26
|
+
* Falls back to a public STUN-only config if omitted.
|
|
27
|
+
*/
|
|
28
|
+
constructor(socketManager, { fetchIceServers } = {}) {
|
|
29
|
+
this._socket = socketManager;
|
|
30
|
+
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._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
|
+
|
|
37
|
+
this._socket.on('incoming-call', (data) => this._emit('incomingCall', data));
|
|
38
|
+
this._socket.on('call-accepted', (data) => this._onCallAccepted(data));
|
|
39
|
+
this._socket.on('call-declined', (data) => this._emit('callDeclined', data));
|
|
40
|
+
this._socket.on('call-cancelled', (data) => this._emit('callCancelled', data));
|
|
41
|
+
this._socket.on('call-ended', (data) => this._teardown('callEnded', data));
|
|
42
|
+
this._socket.on('call-error', (data) => this._emit('callError', data));
|
|
43
|
+
this._socket.on('signal', (payload) => this._onSignal(payload));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---- public event subscription ----
|
|
47
|
+
on(event, handler) {
|
|
48
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
49
|
+
this._listeners.get(event).add(handler);
|
|
50
|
+
return () => this._listeners.get(event)?.delete(handler); // returns an unsubscribe fn
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_emit(event, data) {
|
|
54
|
+
this._listeners.get(event)?.forEach((h) => h(data));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- outgoing call ----
|
|
58
|
+
/**
|
|
59
|
+
* Start a call. Prompts for mic (and camera, if callType is 'video')
|
|
60
|
+
* via getUserMedia internally — the browser will show its own
|
|
61
|
+
* permission prompt.
|
|
62
|
+
*/
|
|
63
|
+
async call(calleeId, { callType = 'audio', callerName } = {}) {
|
|
64
|
+
await this._ensureLocalStream(callType);
|
|
65
|
+
this._socket.emit('call-user', { calleeId, callType, callerName });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cancelCall(calleeId) {
|
|
69
|
+
this._socket.emit('call-cancelled', { calleeId });
|
|
70
|
+
this._cleanupLocal();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- incoming call ----
|
|
74
|
+
async accept(callerId, { callType = 'audio', calleeName, roomId } = {}) {
|
|
75
|
+
await this._ensureLocalStream(callType);
|
|
76
|
+
this._socket.emit('accept-call', { callerId, callType, calleeName, roomId });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
decline(callerId, { calleeName } = {}) {
|
|
80
|
+
this._socket.emit('decline-call', { callerId, calleeName });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- mid-call ----
|
|
84
|
+
/** Ring a third (or fourth, fifth...) person into the call you're already in. */
|
|
85
|
+
addToCall(newUserId, { callType = 'audio', callerName } = {}) {
|
|
86
|
+
if (!this.roomId) throw new Error('[AnonOtFConnect] Not currently in a call');
|
|
87
|
+
this._socket.emit('add-to-call', { roomId: this.roomId, newUserId, callType, callerName });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
end(otherPartyId) {
|
|
91
|
+
this._socket.emit('end-call', { otherParty: otherPartyId });
|
|
92
|
+
this._teardown('callEnded', { from: 'self' });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Notify everyone in the call that you've started/stopped a local recording. */
|
|
96
|
+
notifyRecordingStarted({ mediaType = 'audio', otherParty } = {}) {
|
|
97
|
+
this._socket.emit('recording-started', this.roomId ? { roomId: this.roomId, mediaType } : { otherParty, mediaType });
|
|
98
|
+
}
|
|
99
|
+
notifyRecordingStopped({ otherParty } = {}) {
|
|
100
|
+
this._socket.emit('recording-stopped', this.roomId ? { roomId: this.roomId } : { otherParty });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get localStream() {
|
|
104
|
+
return this._localStream;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- internals ----
|
|
108
|
+
async _ensureLocalStream(callType) {
|
|
109
|
+
if (this._localStream) return this._localStream;
|
|
110
|
+
const constraints = callType === 'video' ? { audio: true, video: true } : { audio: true, video: false };
|
|
111
|
+
this._localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
112
|
+
this._emit('localStream', this._localStream);
|
|
113
|
+
return this._localStream;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async _ensurePeerConnection() {
|
|
117
|
+
if (this._pc) return this._pc;
|
|
118
|
+
|
|
119
|
+
const config = this._fetchIceServers ? await this._fetchIceServers() : RTC_CONFIG_DEFAULTS;
|
|
120
|
+
this._pc = new RTCPeerConnection(config);
|
|
121
|
+
|
|
122
|
+
this._localStream?.getTracks().forEach((track) => this._pc.addTrack(track, this._localStream));
|
|
123
|
+
|
|
124
|
+
this._pc.onicecandidate = (event) => {
|
|
125
|
+
if (event.candidate) {
|
|
126
|
+
this._socket.emit('signal', {
|
|
127
|
+
type: 'ice-candidate',
|
|
128
|
+
candidate: event.candidate,
|
|
129
|
+
roomId: this.roomId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
this._pc.ontrack = (event) => {
|
|
135
|
+
this._emit('remoteStream', event.streams[0]);
|
|
136
|
+
};
|
|
137
|
+
|
|
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 });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return this._pc;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async _onCallAccepted({ from, roomId, callType }) {
|
|
149
|
+
this.roomId = roomId;
|
|
150
|
+
const pc = await this._ensurePeerConnection();
|
|
151
|
+
const offer = await pc.createOffer();
|
|
152
|
+
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 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async _onSignal(payload) {
|
|
158
|
+
const { type, sdp, candidate, from, roomId } = payload;
|
|
159
|
+
if (roomId) this.roomId = roomId;
|
|
160
|
+
|
|
161
|
+
const pc = await this._ensurePeerConnection();
|
|
162
|
+
|
|
163
|
+
if (type === 'offer') {
|
|
164
|
+
await pc.setRemoteDescription(sdp);
|
|
165
|
+
const answer = await pc.createAnswer();
|
|
166
|
+
await pc.setLocalDescription(answer);
|
|
167
|
+
this._socket.emit('signal', { type: 'answer', sdp: answer, roomId, to: roomId ? undefined : from });
|
|
168
|
+
} else if (type === 'answer') {
|
|
169
|
+
await pc.setRemoteDescription(sdp);
|
|
170
|
+
} else if (type === 'ice-candidate' && candidate) {
|
|
171
|
+
try {
|
|
172
|
+
await pc.addIceCandidate(candidate);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// Benign in most cases — candidates can arrive before setRemoteDescription
|
|
175
|
+
// resolves; the browser just drops the stale ones.
|
|
176
|
+
this._emit('callError', { message: 'Failed to add ICE candidate', error: err });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_cleanupLocal() {
|
|
182
|
+
this._localStream?.getTracks().forEach((t) => t.stop());
|
|
183
|
+
this._localStream = null;
|
|
184
|
+
this._pc?.close();
|
|
185
|
+
this._pc = null;
|
|
186
|
+
this.roomId = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_teardown(event, data) {
|
|
190
|
+
this._cleanupLocal();
|
|
191
|
+
this._emit(event, data);
|
|
192
|
+
}
|
|
193
|
+
}
|
package/src/chat.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/chat.js
|
|
2
|
+
//
|
|
3
|
+
// Wraps the ephemeral live-stream chat events plus the raise-hand /
|
|
4
|
+
// contributor-approval flow. Nothing here is persisted server-side —
|
|
5
|
+
// matches how TikTok/Instagram Live chat behaves (no scrollback for
|
|
6
|
+
// someone who joins mid-stream).
|
|
7
|
+
|
|
8
|
+
export class Chat {
|
|
9
|
+
constructor(socketManager) {
|
|
10
|
+
this._socket = socketManager;
|
|
11
|
+
this._listeners = new Map();
|
|
12
|
+
|
|
13
|
+
this._socket.on('stream-chat-message', (data) => this._emit('message', data));
|
|
14
|
+
this._socket.on('hand-raised', (data) => this._emit('handRaised', data));
|
|
15
|
+
this._socket.on('hand-lowered', (data) => this._emit('handLowered', data));
|
|
16
|
+
this._socket.on('contributor-approved', (data) => this._emit('contributorApproved', data));
|
|
17
|
+
this._socket.on('contributor-revoked', (data) => this._emit('contributorRevoked', data));
|
|
18
|
+
this._socket.on('you-are-now-contributor', (data) => this._emit('youAreNowContributor', data));
|
|
19
|
+
this._socket.on('you-are-no-longer-contributor', (data) => this._emit('youAreNoLongerContributor', data));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
on(event, handler) {
|
|
23
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
24
|
+
this._listeners.get(event).add(handler);
|
|
25
|
+
return () => this._listeners.get(event)?.delete(handler);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_emit(event, data) {
|
|
29
|
+
this._listeners.get(event)?.forEach((h) => h(data));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Send a chat message into a live stream's room. Max 500 chars, rate-limited server-side (5 msgs / 10s). */
|
|
33
|
+
send(roomId, text) {
|
|
34
|
+
this._socket.emit('stream-chat-message', { roomId, text });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Viewer signals wanting to come on-stage / get publish access. */
|
|
38
|
+
raiseHand(roomId) {
|
|
39
|
+
this._socket.emit('raise-hand', { roomId });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
lowerHand(roomId) {
|
|
43
|
+
this._socket.emit('lower-hand', { roomId });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Broadcaster/moderator only — server rejects this otherwise. */
|
|
47
|
+
approveContributor(roomId, targetUserId) {
|
|
48
|
+
this._socket.emit('approve-contributor', { roomId, targetUserId });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
revokeContributor(roomId, targetUserId) {
|
|
52
|
+
this._socket.emit('revoke-contributor', { roomId, targetUserId });
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/index.js
|
|
2
|
+
//
|
|
3
|
+
// Public entry point. This is the only file a developer should ever
|
|
4
|
+
// import from — everything in socket.js / calls.js / etc. is an
|
|
5
|
+
// internal implementation detail they never need to touch directly.
|
|
6
|
+
|
|
7
|
+
import { SocketManager } from './socket.js';
|
|
8
|
+
import { Calls } from './calls.js';
|
|
9
|
+
import { Rooms } from './rooms.js';
|
|
10
|
+
import { Chat } from './chat.js';
|
|
11
|
+
import { Streaming } from './streaming.js';
|
|
12
|
+
import { MediaClips } from './mediaClips.js';
|
|
13
|
+
|
|
14
|
+
export class AnonOtFConnect {
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
* @param {string} opts.serverUrl
|
|
18
|
+
* Your AnonOtF server's base URL (e.g. "https://api.yourapp.com").
|
|
19
|
+
* @param {string} opts.token
|
|
20
|
+
* A short-lived JWT minted by YOUR backend via
|
|
21
|
+
* POST /api/apps/:appId/socket-token. Never embed your raw
|
|
22
|
+
* x-api-key in client-side code — that key can create/delete
|
|
23
|
+
* apps and read everything; the socket token is scoped to one
|
|
24
|
+
* user and expires.
|
|
25
|
+
* @param {string} [opts.apiBase]
|
|
26
|
+
* Base URL for a REST proxy on YOUR backend (rooms, media clips,
|
|
27
|
+
* streaming tokens). Defaults to `${serverUrl}/api`, but most
|
|
28
|
+
* real integrations should point this at their OWN backend,
|
|
29
|
+
* which then forwards to AnonOtF with the x-api-key attached
|
|
30
|
+
* server-side — see README for why.
|
|
31
|
+
* @param {() => Promise<RTCConfiguration>} [opts.fetchIceServers]
|
|
32
|
+
* Optional — return your GET /api/ice response here for TURN
|
|
33
|
+
* support. Falls back to public STUN only.
|
|
34
|
+
*/
|
|
35
|
+
constructor({ serverUrl, token, apiBase, fetchIceServers }) {
|
|
36
|
+
const resolvedApiBase = apiBase || `${serverUrl.replace(/\/$/, '')}/api`;
|
|
37
|
+
|
|
38
|
+
this._socketManager = new SocketManager({ serverUrl, token });
|
|
39
|
+
|
|
40
|
+
this.calls = new Calls(this._socketManager, { fetchIceServers });
|
|
41
|
+
this.rooms = new Rooms(this._socketManager, { apiBase: resolvedApiBase });
|
|
42
|
+
this.chat = new Chat(this._socketManager);
|
|
43
|
+
this.streaming = new Streaming({ apiBase: resolvedApiBase });
|
|
44
|
+
this.mediaClips = new MediaClips({ apiBase: resolvedApiBase });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Opens the underlying Socket.IO connection. Call once, after constructing. */
|
|
48
|
+
connect() {
|
|
49
|
+
this._socketManager.connect();
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
disconnect() {
|
|
54
|
+
this._socketManager.disconnect();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get connected() {
|
|
58
|
+
return this._socketManager.connected;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Announce presence and receive the current online-user list back. */
|
|
62
|
+
register({ name, photo, emoji, about } = {}) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
this._socketManager.emit('register', { name, photo, emoji, about }, (onlineUsers) => resolve(onlineUsers));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
updateProfile({ name, photo, emoji, about } = {}) {
|
|
69
|
+
this._socketManager.emit('update-profile', { name, photo, emoji, about });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Keep presence alive — call roughly every 60-90s while connected. */
|
|
73
|
+
heartbeat() {
|
|
74
|
+
this._socketManager.emit('heartbeat');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { Calls, Rooms, Chat, Streaming, MediaClips };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/mediaClips.js
|
|
2
|
+
//
|
|
3
|
+
// Voice notes and call recordings. Upload goes through your backend
|
|
4
|
+
// proxy (apiBase) so the multipart request carries your x-api-key
|
|
5
|
+
// server-side, never in the browser.
|
|
6
|
+
|
|
7
|
+
export class MediaClips {
|
|
8
|
+
constructor({ apiBase }) {
|
|
9
|
+
this._apiBase = apiBase;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Upload a recorded clip (from MediaRecorder, for example).
|
|
14
|
+
* @param {Blob} blob
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {'voice_note'|'call_recording'} opts.type
|
|
17
|
+
* @param {'audio'|'video'} opts.mediaType
|
|
18
|
+
* @param {string} opts.fromUserId
|
|
19
|
+
* @param {string} [opts.roomId] Room-scoped — provide this OR toUserId, not both
|
|
20
|
+
* @param {string} [opts.toUserId] DM-scoped
|
|
21
|
+
* @param {number} [opts.durationSeconds]
|
|
22
|
+
*/
|
|
23
|
+
async upload(blob, { type, mediaType, fromUserId, roomId, toUserId, durationSeconds }) {
|
|
24
|
+
const form = new FormData();
|
|
25
|
+
form.append('file', blob);
|
|
26
|
+
form.append('type', type);
|
|
27
|
+
form.append('mediaType', mediaType);
|
|
28
|
+
form.append('fromUserId', fromUserId);
|
|
29
|
+
if (roomId) form.append('roomId', roomId);
|
|
30
|
+
if (toUserId) form.append('toUserId', toUserId);
|
|
31
|
+
if (durationSeconds != null) form.append('durationSeconds', String(durationSeconds));
|
|
32
|
+
|
|
33
|
+
const res = await fetch(`${this._apiBase}/media-clips`, { method: 'POST', body: form });
|
|
34
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Upload failed: ${res.status}`);
|
|
35
|
+
return res.json();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async listForRoom(roomId, userId) {
|
|
39
|
+
const res = await fetch(`${this._apiBase}/rooms/${roomId}/media-clips?userId=${encodeURIComponent(userId)}`);
|
|
40
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to list room clips: ${res.status}`);
|
|
41
|
+
return res.json();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async listForDM(otherUserId, userId) {
|
|
45
|
+
const res = await fetch(`${this._apiBase}/media-clips/dm/${otherUserId}?userId=${encodeURIComponent(userId)}`);
|
|
46
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to list DM clips: ${res.status}`);
|
|
47
|
+
return res.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Returns a playable URL (the underlying endpoint streams the file once access is verified). */
|
|
51
|
+
getPlaybackUrl(clipId, userId) {
|
|
52
|
+
return `${this._apiBase}/media-clips/${clipId}?userId=${encodeURIComponent(userId)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async delete(clipId, userId) {
|
|
56
|
+
const res = await fetch(`${this._apiBase}/media-clips/${clipId}?userId=${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
|
57
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to delete clip: ${res.status}`);
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/rooms.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/rooms.js
|
|
2
|
+
//
|
|
3
|
+
// Thin wrapper over /api/rooms/* REST endpoints and the join-room /
|
|
4
|
+
// leave-room socket events. REST calls here go through YOUR backend
|
|
5
|
+
// (see note on `apiBase` below) rather than carrying a raw API key
|
|
6
|
+
// in the browser.
|
|
7
|
+
|
|
8
|
+
export class Rooms {
|
|
9
|
+
/**
|
|
10
|
+
* @param {import('./socket.js').SocketManager} socketManager
|
|
11
|
+
* @param {object} opts
|
|
12
|
+
* @param {string} opts.apiBase
|
|
13
|
+
* Base URL for YOUR backend's proxy of the AnonOtF REST API
|
|
14
|
+
* (e.g. "/api/anonotf"). This SDK never sends your raw x-api-key
|
|
15
|
+
* from the browser — your backend should hold that key and proxy
|
|
16
|
+
* these requests, attaching the key server-side.
|
|
17
|
+
*/
|
|
18
|
+
constructor(socketManager, { apiBase }) {
|
|
19
|
+
this._socket = socketManager;
|
|
20
|
+
this._apiBase = apiBase;
|
|
21
|
+
this._listeners = new Map();
|
|
22
|
+
|
|
23
|
+
this._socket.on('room-joined', (data) => this._emit('joined', data));
|
|
24
|
+
this._socket.on('user-joined', (data) => this._emit('userJoined', data));
|
|
25
|
+
this._socket.on('user-left', (data) => this._emit('userLeft', data));
|
|
26
|
+
this._socket.on('error', (data) => this._emit('error', data));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
on(event, handler) {
|
|
30
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
31
|
+
this._listeners.get(event).add(handler);
|
|
32
|
+
return () => this._listeners.get(event)?.delete(handler);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_emit(event, data) {
|
|
36
|
+
this._listeners.get(event)?.forEach((h) => h(data));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Join a room over the live socket connection (fastest path, used for calls/streams). */
|
|
40
|
+
join(roomId) {
|
|
41
|
+
this._socket.emit('join-room', { roomId });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Leave whichever room this socket is currently in. */
|
|
45
|
+
leave() {
|
|
46
|
+
this._socket.emit('leave-room');
|
|
47
|
+
}
|
|
48
|
+
|
|
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 } = {}) {
|
|
51
|
+
const res = await fetch(`${this._apiBase}/rooms`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ roomName, maxParticipants }),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to create room: ${res.status}`);
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async listParticipants(roomId) {
|
|
61
|
+
const res = await fetch(`${this._apiBase}/rooms/${roomId}/participants`);
|
|
62
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to list participants: ${res.status}`);
|
|
63
|
+
return res.json();
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/socket.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/socket.js
|
|
2
|
+
//
|
|
3
|
+
// Internal — not exported from the package. Every other module
|
|
4
|
+
// (calls.js, rooms.js, chat.js) gets a reference to this and calls
|
|
5
|
+
// .emit()/.on() through it, rather than touching socket.io-client
|
|
6
|
+
// directly. This is the one place that knows the actual event names
|
|
7
|
+
// the AnonOtF server expects, so if those ever change, only this
|
|
8
|
+
// file (plus whichever module uses that specific event) needs an edit.
|
|
9
|
+
|
|
10
|
+
import { io } from 'socket.io-client';
|
|
11
|
+
|
|
12
|
+
export class SocketManager {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {string} opts.serverUrl Your AnonOtF server's base URL (e.g. https://api.yourapp.com)
|
|
16
|
+
* @param {string} opts.token A short-lived JWT minted by YOUR backend via
|
|
17
|
+
* POST /api/apps/:appId/socket-token — never
|
|
18
|
+
* put your raw x-api-key in client-side code.
|
|
19
|
+
*/
|
|
20
|
+
constructor({ serverUrl, token }) {
|
|
21
|
+
if (!serverUrl) throw new Error('[AnonOtFConnect] serverUrl is required');
|
|
22
|
+
if (!token) throw new Error('[AnonOtFConnect] token is required (mint one server-side via POST /api/apps/:appId/socket-token)');
|
|
23
|
+
|
|
24
|
+
this.serverUrl = serverUrl;
|
|
25
|
+
this.token = token;
|
|
26
|
+
this.socket = null;
|
|
27
|
+
this._listeners = new Map(); // event -> Set<handler>, replayed onto socket.io-client on (re)connect
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect() {
|
|
31
|
+
if (this.socket) return this.socket;
|
|
32
|
+
|
|
33
|
+
this.socket = io(this.serverUrl, {
|
|
34
|
+
auth: { token: this.token },
|
|
35
|
+
transports: ['websocket'],
|
|
36
|
+
reconnection: true,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Re-attach any handlers that were registered before connect(),
|
|
40
|
+
// or that need to survive a reconnect (socket.io-client keeps
|
|
41
|
+
// listeners across reconnects automatically, but we replay here
|
|
42
|
+
// too so .on() calls made before connect() aren't lost).
|
|
43
|
+
for (const [event, handlers] of this._listeners) {
|
|
44
|
+
for (const handler of handlers) this.socket.on(event, handler);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return this.socket;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
on(event, handler) {
|
|
51
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
52
|
+
this._listeners.get(event).add(handler);
|
|
53
|
+
if (this.socket) this.socket.on(event, handler);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
off(event, handler) {
|
|
57
|
+
this._listeners.get(event)?.delete(handler);
|
|
58
|
+
if (this.socket) this.socket.off(event, handler);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
emit(event, payload, callback) {
|
|
62
|
+
if (!this.socket) {
|
|
63
|
+
throw new Error(`[AnonOtFConnect] Cannot emit "${event}" before connecting`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof callback === 'function') {
|
|
66
|
+
this.socket.emit(event, payload, callback);
|
|
67
|
+
} else {
|
|
68
|
+
this.socket.emit(event, payload);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
disconnect() {
|
|
73
|
+
this.socket?.disconnect();
|
|
74
|
+
this.socket = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get connected() {
|
|
78
|
+
return !!this.socket?.connected;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/streaming.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// src/streaming.js
|
|
2
|
+
//
|
|
3
|
+
// One-to-many broadcast (live streaming), backed by LiveKit. This is
|
|
4
|
+
// the only module that talks to a server OTHER than your AnonOtF
|
|
5
|
+
// server — once it has a token, the actual audio/video flows browser
|
|
6
|
+
// <-> LiveKit directly, never through your Node server.
|
|
7
|
+
//
|
|
8
|
+
// Token flow: this module calls YOUR backend (apiBase), which in turn
|
|
9
|
+
// calls AnonOtF's POST /api/streams/:roomId/token using your x-api-key
|
|
10
|
+
// server-side. The browser never sees your x-api-key or your LiveKit
|
|
11
|
+
// API secret — only the short-lived per-user token that comes back.
|
|
12
|
+
|
|
13
|
+
import { Room, RoomEvent, createLocalTracks } from 'livekit-client';
|
|
14
|
+
|
|
15
|
+
export class Streaming {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} opts
|
|
18
|
+
* @param {string} opts.apiBase Base URL for your backend's proxy (see rooms.js for the same pattern)
|
|
19
|
+
*/
|
|
20
|
+
constructor({ apiBase }) {
|
|
21
|
+
this._apiBase = apiBase;
|
|
22
|
+
this._room = null;
|
|
23
|
+
this._listeners = new Map();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
on(event, handler) {
|
|
27
|
+
if (!this._listeners.has(event)) this._listeners.set(event, new Set());
|
|
28
|
+
this._listeners.get(event).add(handler);
|
|
29
|
+
return () => this._listeners.get(event)?.delete(handler);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_emit(event, data) {
|
|
33
|
+
this._listeners.get(event)?.forEach((h) => h(data));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Join a live stream as a viewer, the broadcaster, or an approved
|
|
38
|
+
* contributor — the server decides which based on your room role,
|
|
39
|
+
* you don't pass that in here.
|
|
40
|
+
*/
|
|
41
|
+
async join(roomId, userId) {
|
|
42
|
+
const res = await fetch(`${this._apiBase}/streams/${roomId}/token`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ userId }),
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) throw new Error(`[AnonOtFConnect] Failed to get streaming token: ${res.status}`);
|
|
48
|
+
const { token, url, role } = await res.json();
|
|
49
|
+
|
|
50
|
+
this._room = new Room();
|
|
51
|
+
|
|
52
|
+
this._room
|
|
53
|
+
.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
|
54
|
+
this._emit('trackSubscribed', { track, participant: participant.identity });
|
|
55
|
+
})
|
|
56
|
+
.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
|
|
57
|
+
this._emit('trackUnsubscribed', { track, participant: participant.identity });
|
|
58
|
+
})
|
|
59
|
+
.on(RoomEvent.ParticipantConnected, (participant) => {
|
|
60
|
+
this._emit('viewerJoined', { userId: participant.identity });
|
|
61
|
+
})
|
|
62
|
+
.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
63
|
+
this._emit('viewerLeft', { userId: participant.identity });
|
|
64
|
+
})
|
|
65
|
+
.on(RoomEvent.Disconnected, () => this._emit('disconnected'));
|
|
66
|
+
|
|
67
|
+
await this._room.connect(url, token);
|
|
68
|
+
this._emit('joined', { roomId, role });
|
|
69
|
+
|
|
70
|
+
// Broadcaster/contributor: publish camera+mic immediately. Plain
|
|
71
|
+
// viewers get canPublish: false server-side, so this is skipped
|
|
72
|
+
// for them automatically — calling publishTracks would just fail.
|
|
73
|
+
if (role === 'broadcaster' || role === 'contributor') {
|
|
74
|
+
const tracks = await createLocalTracks({ audio: true, video: true });
|
|
75
|
+
await Promise.all(tracks.map((t) => this._room.localParticipant.publishTrack(t)));
|
|
76
|
+
this._emit('publishing', { roomId });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { role };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async leave() {
|
|
83
|
+
await this._room?.disconnect();
|
|
84
|
+
this._room = null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
get room() {
|
|
88
|
+
return this._room;
|
|
89
|
+
}
|
|
90
|
+
}
|