@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 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
+ }
@@ -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
+ }