@treyorr/voca-client 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,118 @@
1
+ # @treyorr/voca-client
2
+
3
+ Core TypeScript SDK for Voca WebRTC signaling.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @treyorr/voca-client
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### Option 1: Create a New Room
14
+
15
+ ```typescript
16
+ import { VocaClient } from '@treyorr/voca-client';
17
+
18
+ // Create a room and get a connected client
19
+ const client = await VocaClient.createRoom({
20
+ serverUrl: 'wss://your-server.com',
21
+ });
22
+
23
+ console.log('Room ID:', client.roomId); // Share this with others
24
+ await client.connect();
25
+ ```
26
+
27
+ ### Option 2: Join an Existing Room
28
+
29
+ ```typescript
30
+ import { VocaClient } from '@treyorr/voca-client';
31
+
32
+ const client = new VocaClient('abc123', {
33
+ serverUrl: 'wss://your-server.com',
34
+ });
35
+
36
+ await client.connect();
37
+ ```
38
+
39
+ ## Common Use Cases
40
+
41
+ ### Voice Chat App (Create + Share Link)
42
+ ```typescript
43
+ const client = await VocaClient.createRoom();
44
+ const inviteLink = `https://myapp.com/room/${client.roomId}`;
45
+ // Share inviteLink with friends
46
+ await client.connect();
47
+ ```
48
+
49
+ ### Game Lobby (Join by Code)
50
+ ```typescript
51
+ const roomCode = prompt('Enter room code:');
52
+ const client = new VocaClient(roomCode);
53
+ await client.connect();
54
+ ```
55
+
56
+ ### Check if Room is Full Before Joining
57
+ ```typescript
58
+ const response = await fetch(`/api/room/${roomId}`);
59
+ const { exists, full, peers, capacity } = await response.json();
60
+
61
+ if (!exists) {
62
+ alert('Room not found');
63
+ } else if (full) {
64
+ alert(`Room is full (${peers}/${capacity})`);
65
+ } else {
66
+ const client = new VocaClient(roomId);
67
+ await client.connect();
68
+ }
69
+ ```
70
+
71
+ ## API Reference
72
+
73
+ ### `VocaClient.createRoom(config?)`
74
+
75
+ Creates a new room on the server and returns a client instance.
76
+
77
+ ```typescript
78
+ const client = await VocaClient.createRoom({
79
+ serverUrl: 'wss://your-server.com', // Required in Node.js
80
+ });
81
+ ```
82
+
83
+ ### `new VocaClient(roomId, config?)`
84
+
85
+ Creates a client for joining an existing room.
86
+
87
+ ### Config Options
88
+
89
+ | Option | Type | Description |
90
+ |--------|------|-------------|
91
+ | `serverUrl` | string | WebSocket server URL |
92
+ | `turnApiKey` | string | TURN API key for NAT traversal |
93
+ | `reconnect.enabled` | boolean | Enable auto-reconnect (default: true) |
94
+ | `reconnect.maxAttempts` | number | Max reconnection attempts (default: 5) |
95
+ | `reconnect.baseDelayMs` | number | Base delay for exponential backoff |
96
+
97
+ ### Methods
98
+
99
+ | Method | Returns | Description |
100
+ |--------|---------|-------------|
101
+ | `connect()` | Promise | Connect to room |
102
+ | `disconnect()` | void | Leave room |
103
+ | `toggleMute()` | boolean | Toggle mute, returns new state |
104
+ | `on(event, callback)` | Function | Subscribe to events |
105
+
106
+ ### Events
107
+
108
+ - `status` - Connection status changes
109
+ - `error` - Error events with typed codes
110
+ - `warning` - Non-fatal warnings
111
+ - `peer-joined` - Peer joined the room
112
+ - `peer-left` - Peer left the room
113
+ - `peer-audio-level` - Peer audio level (0-1)
114
+ - `local-audio-level` - Local audio level (0-1)
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Voca Error Codes
3
+ *
4
+ * Unified error codes used across all Voca SDKs and the signaling server.
5
+ */
6
+ export declare const VocaErrorCode: {
7
+ readonly ROOM_NOT_FOUND: "room_not_found";
8
+ readonly ROOM_FULL: "room_full";
9
+ readonly MAX_ROOMS_REACHED: "max_rooms_reached";
10
+ readonly INVALID_ROOM_ID: "invalid_room_id";
11
+ readonly CONNECTION_FAILED: "connection_failed";
12
+ readonly WEBSOCKET_ERROR: "websocket_error";
13
+ readonly HEARTBEAT_TIMEOUT: "heartbeat_timeout";
14
+ readonly MICROPHONE_NOT_FOUND: "microphone_not_found";
15
+ readonly MICROPHONE_PERMISSION_DENIED: "microphone_permission_denied";
16
+ readonly INSECURE_CONTEXT: "insecure_context";
17
+ readonly INVALID_MESSAGE: "invalid_message";
18
+ readonly PEER_NOT_FOUND: "peer_not_found";
19
+ };
20
+ export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
21
+ /**
22
+ * Human-readable error messages for each error code
23
+ */
24
+ export declare const VocaErrorMessages: Record<VocaErrorCode, string>;
25
+ /**
26
+ * Voca error with code and message
27
+ */
28
+ export interface VocaError {
29
+ code: VocaErrorCode;
30
+ message: string;
31
+ }
32
+ /**
33
+ * Create a VocaError from a code
34
+ */
35
+ export declare function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError;
@@ -0,0 +1,83 @@
1
+ import { type VocaError } from './errors';
2
+ export { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';
3
+ export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';
4
+ export interface VocaConfig {
5
+ debug?: boolean;
6
+ iceServers?: RTCIceServer[];
7
+ serverUrl?: string;
8
+ apiKey?: string;
9
+ /**
10
+ * Reconnection options. Enabled by default.
11
+ */
12
+ reconnect?: {
13
+ /** Enable automatic reconnection. Default: true */
14
+ enabled?: boolean;
15
+ /** Maximum reconnection attempts. Default: 5 */
16
+ maxAttempts?: number;
17
+ /** Base delay in milliseconds. Default: 1000 */
18
+ baseDelayMs?: number;
19
+ };
20
+ }
21
+ export interface Peer {
22
+ id: string;
23
+ connection: RTCPeerConnection;
24
+ audioLevel: number;
25
+ stream?: MediaStream;
26
+ }
27
+ interface VocaEvents {
28
+ 'status': (status: ConnectionStatus) => void;
29
+ 'error': (error: VocaError) => void;
30
+ 'warning': (warning: {
31
+ code: string;
32
+ message: string;
33
+ }) => void;
34
+ 'peer-joined': (peerId: string) => void;
35
+ 'peer-left': (peerId: string) => void;
36
+ 'peer-audio-level': (peerId: string, level: number) => void;
37
+ 'local-audio-level': (level: number) => void;
38
+ 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
39
+ }
40
+ export declare class VocaClient {
41
+ peers: Map<string, Peer>;
42
+ localStream: MediaStream | null;
43
+ isMuted: boolean;
44
+ status: ConnectionStatus;
45
+ roomId: string;
46
+ private events;
47
+ private ws;
48
+ private audioContext;
49
+ private analyser;
50
+ private animationFrame;
51
+ private iceServers;
52
+ private config;
53
+ private reconnectAttempts;
54
+ private reconnectTimeout;
55
+ private shouldReconnect;
56
+ private peerAnalysers;
57
+ /**
58
+ * Create a new room and return a VocaClient connected to it.
59
+ * This is a convenience method that handles room creation via the API.
60
+ *
61
+ * @param config - VocaClient configuration (serverUrl required for non-browser environments)
62
+ * @returns Promise<VocaClient> - A new client instance for the created room
63
+ */
64
+ static createRoom(config?: VocaConfig): Promise<VocaClient>;
65
+ constructor(roomId: string, config?: VocaConfig);
66
+ on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]): import("nanoevents", { with: { "resolution-mode": "import" } }).Unsubscribe;
67
+ connect(): Promise<void>;
68
+ disconnect(): void;
69
+ toggleMute(): boolean;
70
+ private setupMediaAndAudio;
71
+ private setupAudioAnalysis;
72
+ /**
73
+ * Infer the WebSocket server URL from config or environment.
74
+ */
75
+ private getServerUrl;
76
+ private connectSocket;
77
+ private handleSignal;
78
+ private createPeer;
79
+ private removePeer;
80
+ private setupRemoteAudio;
81
+ private send;
82
+ private handleError;
83
+ }
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ var Q=()=>({emit(h,...q){for(let B=this.events[h]||[],z=0,G=B.length;z<G;z++)B[z](...q)},events:{},on(h,q){return(this.events[h]||=[]).push(q),()=>{this.events[h]=this.events[h]?.filter((B)=>q!==B)}}});var H={ROOM_NOT_FOUND:"room_not_found",ROOM_FULL:"room_full",MAX_ROOMS_REACHED:"max_rooms_reached",INVALID_ROOM_ID:"invalid_room_id",CONNECTION_FAILED:"connection_failed",WEBSOCKET_ERROR:"websocket_error",HEARTBEAT_TIMEOUT:"heartbeat_timeout",MICROPHONE_NOT_FOUND:"microphone_not_found",MICROPHONE_PERMISSION_DENIED:"microphone_permission_denied",INSECURE_CONTEXT:"insecure_context",INVALID_MESSAGE:"invalid_message",PEER_NOT_FOUND:"peer_not_found"},N={[H.ROOM_NOT_FOUND]:"Room not found",[H.ROOM_FULL]:"Room is at maximum capacity",[H.MAX_ROOMS_REACHED]:"Maximum number of rooms reached",[H.INVALID_ROOM_ID]:"Invalid room ID format",[H.CONNECTION_FAILED]:"Failed to connect to signaling server",[H.WEBSOCKET_ERROR]:"WebSocket connection error",[H.HEARTBEAT_TIMEOUT]:"Connection lost due to heartbeat timeout",[H.MICROPHONE_NOT_FOUND]:"No microphone found. Please connect a microphone and try again.",[H.MICROPHONE_PERMISSION_DENIED]:"Microphone permission denied. Please allow microphone access.",[H.INSECURE_CONTEXT]:"HTTPS is required for microphone access",[H.INVALID_MESSAGE]:"Invalid signaling message received",[H.PEER_NOT_FOUND]:"Peer not found in room"};function Z(h,q){return{code:h,message:q??N[h]}}var $=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class W{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=Q();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=$;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let q=h.serverUrl?h.serverUrl.replace(/^ws/,"http"):typeof window<"u"?`${window.location.protocol}//${window.location.host}`:"";if(!q)throw Error("VocaConfig.serverUrl is required in non-browser environments");let B={"Content-Type":"application/json"};if(h.apiKey)B["x-api-key"]=h.apiKey;let z=await fetch(`${q}/api/room`,{method:"POST",headers:B,body:JSON.stringify(h)});if(!z.ok){let J=await z.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(J.message||"Failed to create room")}let{room:G}=await z.json();return new W(G,h)}constructor(h,q={}){if(this.roomId=h,this.config=q,q.iceServers)this.iceServers=q.iceServers}on(h,q){return this.events.on(h,q)}async connect(){this.status="connecting",this.events.emit("status","connecting");try{await this.setupMediaAndAudio(),this.connectSocket()}catch(h){throw this.handleError(H.CONNECTION_FAILED,h instanceof Error?h.message:"Failed to connect"),h}}disconnect(){if(this.shouldReconnect=!1,this.reconnectTimeout)clearTimeout(this.reconnectTimeout),this.reconnectTimeout=null;if(this.animationFrame)cancelAnimationFrame(this.animationFrame);if(this.peers.forEach((h)=>h.connection.close()),this.peers.clear(),this.ws?.close(),this.localStream?.getTracks().forEach((h)=>h.stop()),this.audioContext&&this.audioContext.state!=="closed")this.audioContext.close().catch((h)=>console.warn("Error closing AudioContext",h));this.status="disconnected",this.events.emit("status","disconnected")}toggleMute(){let h=this.localStream?.getAudioTracks()[0];if(h)h.enabled=!h.enabled,this.isMuted=!h.enabled;return this.isMuted}async setupMediaAndAudio(){if(typeof window<"u"&&!window.isSecureContext)throw Error(N[H.INSECURE_CONTEXT]);try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,latency:0,channelCount:1},video:!1})}catch(h){try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:!0,video:!1})}catch(q){if(q.name==="NotFoundError"||q.message.includes("Requested device not found"))throw Error(N[H.MICROPHONE_NOT_FOUND]);if(q.name==="NotAllowedError"||q.message.includes("Permission denied"))throw Error(N[H.MICROPHONE_PERMISSION_DENIED]);throw q}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let q=window.AudioContext||window.webkitAudioContext;this.audioContext=new q;let B=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,B.connect(this.analyser);let z=new Uint8Array(this.analyser.frequencyBinCount),G=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(z);let J=z.reduce((P,O)=>P+O,0)/z.length,K=Math.min(J/128,1);this.events.emit("local-audio-level",K),this.animationFrame=requestAnimationFrame(G)};G()}getServerUrl(){let h,q=this.config;if(typeof window>"u"){if(!q.serverUrl)throw Error("VocaConfig.serverUrl is required in non-browser environments");h=q.serverUrl}else if(q.serverUrl)h=q.serverUrl;else h=`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;if(q.apiKey){let B=h.includes("?")?"&":"?";h+=`${B}apiKey=${encodeURIComponent(q.apiKey)}`}return`${h}/ws/${this.roomId}`}connectSocket(){let h=this.getServerUrl();this.ws=new WebSocket(h),this.ws.onopen=()=>{this.send({type:"hello",version:"1.0",client:"@treyorr/voca-client"}),this.status="connected",this.events.emit("status","connected"),this.reconnectAttempts=0},this.ws.onclose=()=>{if(this.status==="full"||this.status==="error")return;let q=this.config.reconnect?.enabled!==!1,B=this.config.reconnect?.maxAttempts??5,z=this.config.reconnect?.baseDelayMs??1000;if(q&&this.shouldReconnect&&this.reconnectAttempts<B){this.status="reconnecting",this.events.emit("status","reconnecting");let G=Math.min(z*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},G)}else this.status="disconnected",this.events.emit("status","disconnected")},this.ws.onerror=()=>this.handleError(H.WEBSOCKET_ERROR,"WebSocket connection failed"),this.ws.onmessage=(q)=>{let B=JSON.parse(q.data);this.handleSignal(B)}}async handleSignal(h){switch(h.type){case"welcome":console.debug("[Voca] Protocol version:",h.version,"Peer ID:",h.peer_id);break;case"join":await this.createPeer(h.from,!0);break;case"offer":await this.createPeer(h.from,!1,h.sdp);break;case"answer":let q=this.peers.get(h.from);if(q)await q.connection.setRemoteDescription({type:"answer",sdp:h.sdp});break;case"ice":let B=this.peers.get(h.from);if(B)try{await B.connection.addIceCandidate(JSON.parse(h.candidate))}catch(z){console.warn("[Voca] Invalid ICE candidate:",z)}break;case"ping":this.ws?.send(JSON.stringify({from:"",type:"pong"}));break;case"leave":this.removePeer(h.from);break;case"error":this.handleError(h.code??"unknown",h.message??"Unknown error");break}}async createPeer(h,q,B){let z=new RTCPeerConnection({iceServers:this.iceServers});if(z.onicecandidate=(G)=>{if(G.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(G.candidate)})},z.ontrack=(G)=>{this.events.emit("track",h,G.track,G.streams[0]),this.setupRemoteAudio(h,G.streams[0]);let J=this.peers.get(h);if(J)J.stream=G.streams[0]},this.localStream?.getTracks().forEach((G)=>z.addTrack(G,this.localStream)),this.peers.set(h,{id:h,connection:z,audioLevel:0}),this.events.emit("peer-joined",h),q){let G=await z.createOffer();await z.setLocalDescription(G),this.send({type:"offer",to:h,sdp:G.sdp})}else if(B){await z.setRemoteDescription({type:"offer",sdp:B});let G=await z.createAnswer();await z.setLocalDescription(G),this.send({type:"answer",to:h,sdp:G.sdp})}}removePeer(h){this.peers.get(h)?.connection.close(),this.peers.delete(h);let B=this.peerAnalysers.get(h);if(B)B.source.disconnect(),B.analyser.disconnect(),this.peerAnalysers.delete(h);this.events.emit("peer-left",h)}setupRemoteAudio(h,q){if(!this.audioContext){let K=window.AudioContext||window.webkitAudioContext;this.audioContext=new K}let B=this.audioContext.createMediaStreamSource(q),z=this.audioContext.createAnalyser();z.fftSize=256,B.connect(z),B.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:B,analyser:z});let G=new Uint8Array(z.frequencyBinCount),J=()=>{let K=this.peers.get(h);if(!K)return;z.getByteFrequencyData(G);let P=G.reduce((X,Y)=>X+Y,0)/G.length,O=Math.min(P/128,1);this.events.emit("peer-audio-level",h,O),K.audioLevel=O,requestAnimationFrame(J)};J()}send(h){if(this.ws?.readyState===WebSocket.OPEN)this.ws.send(JSON.stringify({from:"",...h}))}handleError(h,q){this.status=h===H.ROOM_FULL||h==="room_full"?"full":"error",this.events.emit("status",this.status),this.events.emit("error",{code:h,message:q})}}export{Z as createVocaError,N as VocaErrorMessages,H as VocaErrorCode,W as VocaClient};
2
+
3
+ //# debugId=6D5B0730A9EDB40E64756E2164756E21
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../node_modules/.bun/nanoevents@9.1.0/node_modules/nanoevents/index.js", "../src/errors.ts", "../src/index.ts"],
4
+ "sourcesContent": [
5
+ "export let createNanoEvents = () => ({\n emit(event, ...args) {\n for (\n let callbacks = this.events[event] || [],\n i = 0,\n length = callbacks.length;\n i < length;\n i++\n ) {\n callbacks[i](...args)\n }\n },\n events: {},\n on(event, cb) {\n ;(this.events[event] ||= []).push(cb)\n return () => {\n this.events[event] = this.events[event]?.filter(i => cb !== i)\n }\n }\n})\n",
6
+ "/**\n * Voca Error Codes\n * \n * Unified error codes used across all Voca SDKs and the signaling server.\n */\n\nexport const VocaErrorCode = {\n // Room errors\n ROOM_NOT_FOUND: 'room_not_found',\n ROOM_FULL: 'room_full',\n MAX_ROOMS_REACHED: 'max_rooms_reached',\n INVALID_ROOM_ID: 'invalid_room_id',\n\n // Connection errors\n CONNECTION_FAILED: 'connection_failed',\n WEBSOCKET_ERROR: 'websocket_error',\n HEARTBEAT_TIMEOUT: 'heartbeat_timeout',\n\n // Media errors\n MICROPHONE_NOT_FOUND: 'microphone_not_found',\n MICROPHONE_PERMISSION_DENIED: 'microphone_permission_denied',\n INSECURE_CONTEXT: 'insecure_context',\n\n // Signaling errors\n INVALID_MESSAGE: 'invalid_message',\n PEER_NOT_FOUND: 'peer_not_found',\n} as const;\n\nexport type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];\n\n/**\n * Human-readable error messages for each error code\n */\nexport const VocaErrorMessages: Record<VocaErrorCode, string> = {\n [VocaErrorCode.ROOM_NOT_FOUND]: 'Room not found',\n [VocaErrorCode.ROOM_FULL]: 'Room is at maximum capacity',\n [VocaErrorCode.MAX_ROOMS_REACHED]: 'Maximum number of rooms reached',\n [VocaErrorCode.INVALID_ROOM_ID]: 'Invalid room ID format',\n [VocaErrorCode.CONNECTION_FAILED]: 'Failed to connect to signaling server',\n [VocaErrorCode.WEBSOCKET_ERROR]: 'WebSocket connection error',\n [VocaErrorCode.HEARTBEAT_TIMEOUT]: 'Connection lost due to heartbeat timeout',\n [VocaErrorCode.MICROPHONE_NOT_FOUND]: 'No microphone found. Please connect a microphone and try again.',\n [VocaErrorCode.MICROPHONE_PERMISSION_DENIED]: 'Microphone permission denied. Please allow microphone access.',\n [VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',\n [VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',\n [VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',\n};\n\n/**\n * Voca error with code and message\n */\nexport interface VocaError {\n code: VocaErrorCode;\n message: string;\n}\n\n/**\n * Create a VocaError from a code\n */\nexport function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError {\n return {\n code,\n message: customMessage ?? VocaErrorMessages[code],\n };\n}\n",
7
+ "import { createNanoEvents } from 'nanoevents';\nimport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\n\nexport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\n\nexport type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';\n\nexport interface VocaConfig {\n debug?: boolean;\n iceServers?: RTCIceServer[];\n serverUrl?: string; // e.g. \"ws://localhost:3001\" or \"wss://voca.vc\"\n apiKey?: string; // optional API key for signaling server auth\n /**\n * Reconnection options. Enabled by default.\n */\n reconnect?: {\n /** Enable automatic reconnection. Default: true */\n enabled?: boolean;\n /** Maximum reconnection attempts. Default: 5 */\n maxAttempts?: number;\n /** Base delay in milliseconds. Default: 1000 */\n baseDelayMs?: number;\n };\n}\n\nexport interface Peer {\n id: string;\n connection: RTCPeerConnection;\n audioLevel: number;\n stream?: MediaStream;\n}\n\ntype SignalMessage = {\n from: string;\n type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';\n peer_id?: string;\n to?: string;\n sdp?: string;\n candidate?: string;\n code?: string;\n message?: string;\n // Protocol versioning\n version?: string;\n client?: string;\n};\n\ninterface VocaEvents {\n 'status': (status: ConnectionStatus) => void;\n 'error': (error: VocaError) => void;\n 'warning': (warning: { code: string; message: string }) => void;\n 'peer-joined': (peerId: string) => void;\n 'peer-left': (peerId: string) => void;\n 'peer-audio-level': (peerId: string, level: number) => void;\n 'local-audio-level': (level: number) => void;\n 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;\n}\n\nconst DEFAULT_ICE_SERVERS: RTCIceServer[] = [\n { urls: 'stun:stun.l.google.com:19302' },\n { urls: 'stun:stun1.l.google.com:19302' },\n];\n\nexport class VocaClient {\n public peers: Map<string, Peer> = new Map();\n public localStream: MediaStream | null = null;\n public isMuted: boolean = false;\n public status: ConnectionStatus = 'connecting';\n public roomId: string;\n\n private events = createNanoEvents<VocaEvents>();\n private ws: WebSocket | null = null;\n private audioContext: AudioContext | null = null;\n private analyser: AnalyserNode | null = null;\n private animationFrame: number | null = null;\n private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;\n private config: VocaConfig;\n\n // Reconnection state\n private reconnectAttempts = 0;\n private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;\n private shouldReconnect = true;\n\n // Audio analysis nodes per peer (for cleanup)\n private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();\n\n /**\n * Create a new room and return a VocaClient connected to it.\n * This is a convenience method that handles room creation via the API.\n * \n * @param config - VocaClient configuration (serverUrl required for non-browser environments)\n * @returns Promise<VocaClient> - A new client instance for the created room\n */\n static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {\n // Determine the HTTP base URL from serverUrl or window.location\n const serverUrl = config.serverUrl\n ? config.serverUrl.replace(/^ws/, 'http')\n : (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : '');\n\n if (!serverUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n\n const headers: HeadersInit = {\n 'Content-Type': 'application/json',\n };\n\n if (config.apiKey) {\n headers['x-api-key'] = config.apiKey;\n }\n\n const response = await fetch(`${serverUrl}/api/room`, {\n method: 'POST',\n headers,\n body: JSON.stringify(config)\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));\n throw new Error(error.message || 'Failed to create room');\n }\n\n const { room } = await response.json();\n return new VocaClient(room, config);\n }\n\n constructor(roomId: string, config: VocaConfig = {}) {\n this.roomId = roomId;\n this.config = config;\n if (config.iceServers) this.iceServers = config.iceServers;\n // turnServers is no longer part of VocaConfig, removed this line\n }\n\n public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {\n return this.events.on(event, callback);\n }\n\n public async connect(): Promise<void> {\n this.status = 'connecting';\n this.events.emit('status', 'connecting');\n\n try {\n // Use default STUN servers (Google's public STUN servers)\n await this.setupMediaAndAudio();\n this.connectSocket();\n } catch (err) {\n this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');\n throw err;\n }\n }\n\n public disconnect() {\n // Prevent reconnection attempts\n this.shouldReconnect = false;\n if (this.reconnectTimeout) {\n clearTimeout(this.reconnectTimeout);\n this.reconnectTimeout = null;\n }\n\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n this.peers.forEach((p) => p.connection.close());\n this.peers.clear();\n this.ws?.close();\n this.localStream?.getTracks().forEach((t) => t.stop());\n if (this.audioContext && this.audioContext.state !== 'closed') {\n this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));\n }\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n\n public toggleMute() {\n const track = this.localStream?.getAudioTracks()[0];\n if (track) {\n track.enabled = !track.enabled;\n this.isMuted = !track.enabled;\n }\n return this.isMuted;\n }\n\n\n\n private async setupMediaAndAudio() {\n // Only verify secure context in browsers\n if (typeof window !== 'undefined' && !window.isSecureContext) {\n throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);\n }\n\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n latency: 0,\n channelCount: 1\n } as any,\n video: false,\n });\n } catch (err) {\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });\n } catch (retryErr: any) {\n if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);\n }\n if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);\n }\n throw retryErr;\n }\n }\n\n this.setupAudioAnalysis(this.localStream!);\n }\n\n private setupAudioAnalysis(stream: MediaStream) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n const source = this.audioContext.createMediaStreamSource(stream);\n this.analyser = this.audioContext.createAnalyser();\n this.analyser.fftSize = 256;\n source.connect(this.analyser);\n\n const dataArray = new Uint8Array(this.analyser.frequencyBinCount);\n const update = () => {\n if (!this.analyser) return;\n this.analyser.getByteFrequencyData(dataArray);\n const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;\n const level = Math.min(avg / 128, 1);\n this.events.emit('local-audio-level', level);\n this.animationFrame = requestAnimationFrame(update);\n };\n update();\n }\n\n /**\n * Infer the WebSocket server URL from config or environment.\n */\n private getServerUrl(): string {\n let url: string;\n const config = this.config;\n\n // Non-browser environments require explicit serverUrl\n if (typeof window === 'undefined') {\n if (!config.serverUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n url = config.serverUrl;\n } else {\n if (config.serverUrl) {\n url = config.serverUrl;\n } else {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n url = `${protocol}//${window.location.host}`;\n }\n }\n\n // Append apiKey if present\n if (config.apiKey) {\n const separator = url.includes('?') ? '&' : '?';\n url += `${separator}apiKey=${encodeURIComponent(config.apiKey)}`;\n }\n\n return `${url}/ws/${this.roomId}`;\n }\n\n private connectSocket() {\n const socketUrl = this.getServerUrl();\n\n this.ws = new WebSocket(socketUrl);\n\n this.ws.onopen = () => {\n // Send hello message with version info\n this.send({ type: 'hello', version: '1.0', client: '@treyorr/voca-client' });\n this.status = 'connected';\n this.events.emit('status', 'connected');\n // Reset reconnect attempts on successful connection\n this.reconnectAttempts = 0;\n };\n\n this.ws.onclose = () => {\n // Don't reconnect on terminal states\n if (this.status === 'full' || this.status === 'error') {\n return;\n }\n\n const reconnectEnabled = this.config.reconnect?.enabled !== false;\n const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;\n const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;\n\n if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {\n this.status = 'reconnecting';\n this.events.emit('status', 'reconnecting');\n\n // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);\n this.reconnectAttempts++;\n\n this.reconnectTimeout = setTimeout(() => {\n this.connectSocket();\n }, delay);\n } else {\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n };\n\n this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');\n\n this.ws.onmessage = (e) => {\n const msg: SignalMessage = JSON.parse(e.data);\n this.handleSignal(msg);\n };\n }\n\n private async handleSignal(msg: SignalMessage) {\n switch (msg.type) {\n case 'welcome':\n // Protocol handshake complete - peer_id is managed server-side\n console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);\n break;\n case 'join':\n await this.createPeer(msg.from, true);\n break;\n case 'offer':\n await this.createPeer(msg.from, false, msg.sdp);\n break;\n case 'answer':\n const peer = this.peers.get(msg.from);\n if (peer) {\n await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });\n }\n break;\n case 'ice':\n const p = this.peers.get(msg.from);\n if (p) {\n try {\n await p.connection.addIceCandidate(JSON.parse(msg.candidate!));\n } catch (e) {\n console.warn('[Voca] Invalid ICE candidate:', e);\n }\n }\n break;\n case 'ping':\n this.ws?.send(JSON.stringify({ from: '', type: 'pong' }));\n break;\n case 'leave':\n this.removePeer(msg.from);\n break;\n case 'error':\n this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');\n break;\n }\n }\n\n private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {\n const pc = new RTCPeerConnection({ iceServers: this.iceServers });\n\n pc.onicecandidate = (e) => {\n if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });\n };\n\n pc.ontrack = (e) => {\n // Emit track event so UI can handle the audio element/stream\n this.events.emit('track', peerId, e.track, e.streams[0]);\n this.setupRemoteAudio(peerId, e.streams[0]);\n const peer = this.peers.get(peerId);\n if (peer) peer.stream = e.streams[0];\n };\n\n // Add local tracks\n this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));\n\n this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });\n this.events.emit('peer-joined', peerId);\n\n if (isInitiator) {\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.send({ type: 'offer', to: peerId, sdp: offer.sdp });\n } else if (remoteSdp) {\n await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n this.send({ type: 'answer', to: peerId, sdp: answer.sdp });\n }\n }\n\n private removePeer(peerId: string) {\n const peer = this.peers.get(peerId);\n peer?.connection.close();\n this.peers.delete(peerId);\n\n // Clean up audio analysis nodes\n const audio = this.peerAnalysers.get(peerId);\n if (audio) {\n audio.source.disconnect();\n audio.analyser.disconnect();\n this.peerAnalysers.delete(peerId);\n }\n\n this.events.emit('peer-left', peerId);\n }\n\n private setupRemoteAudio(peerId: string, stream: MediaStream) {\n if (!this.audioContext) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n }\n\n const source = this.audioContext.createMediaStreamSource(stream);\n const analyser = this.audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // CRITICAL: Connect to speakers so audio is actually played!\n source.connect(this.audioContext.destination);\n\n // Store for cleanup when peer leaves\n this.peerAnalysers.set(peerId, { source, analyser });\n\n const data = new Uint8Array(analyser.frequencyBinCount);\n const update = () => {\n const peer = this.peers.get(peerId);\n if (!peer) return;\n\n analyser.getByteFrequencyData(data);\n const avg = data.reduce((a, b) => a + b, 0) / data.length;\n const level = Math.min(avg / 128, 1);\n\n this.events.emit('peer-audio-level', peerId, level);\n\n // Store in peer object as well for convenience\n peer.audioLevel = level;\n\n requestAnimationFrame(update);\n };\n update();\n }\n\n private send(msg: Partial<SignalMessage>) {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ from: '', ...msg }));\n }\n }\n\n private handleError(code: VocaErrorCode | string, message: string) {\n this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';\n this.events.emit('status', this.status);\n this.events.emit('error', { code: code as VocaErrorCode, message });\n }\n}\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "6D5B0730A9EDB40E64756E2164756E21",
11
+ "names": []
12
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@treyorr/voca-client",
3
+ "version": "0.1.0",
4
+ "description": "Core TypeScript SDK for Voca Signaling",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "bun build src/index.ts --outdir dist --format esm --sourcemap=external --minify && tsc --emitDeclarationOnly --outDir dist",
17
+ "dev": "bun build src/index.ts --outdir dist --format esm --watch",
18
+ "lint": "tsc --noEmit",
19
+ "test": "bun test",
20
+ "test:watch": "bun test --watch"
21
+ },
22
+ "keywords": [
23
+ "webrtc",
24
+ "signaling",
25
+ "voca"
26
+ ],
27
+ "author": "Trey Orr",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/treyorr/voca.git",
32
+ "directory": "packages/voca-client"
33
+ },
34
+ "homepage": "https://voca.vc",
35
+ "bugs": "https://github.com/treyorr/voca/issues",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "typescript": "^5.9.3"
41
+ },
42
+ "dependencies": {
43
+ "nanoevents": "^9.1.0"
44
+ }
45
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { VocaClient } from '../index';
3
+
4
+ // Mock WebSocket
5
+ class MockWebSocket {
6
+ static OPEN = 1;
7
+ static CLOSED = 3;
8
+
9
+ readyState = MockWebSocket.OPEN;
10
+ onopen: (() => void) | null = null;
11
+ onclose: (() => void) | null = null;
12
+ onmessage: ((e: { data: string }) => void) | null = null;
13
+ onerror: (() => void) | null = null;
14
+
15
+ constructor(public url: string) {
16
+ // Simulate async connection
17
+ setTimeout(() => this.onopen?.(), 0);
18
+ }
19
+
20
+ send = mock();
21
+ close = mock(() => {
22
+ this.readyState = MockWebSocket.CLOSED;
23
+ this.onclose?.();
24
+ });
25
+ }
26
+
27
+ // Mock navigator.mediaDevices
28
+ const createMockMediaStream = () => {
29
+ const trackState = { enabled: true };
30
+ const mockTrack = {
31
+ stop: mock(),
32
+ };
33
+ Object.defineProperty(mockTrack, 'enabled', {
34
+ get: () => trackState.enabled,
35
+ set: (v: boolean) => { trackState.enabled = v; },
36
+ });
37
+
38
+ return {
39
+ getTracks: () => [mockTrack],
40
+ getAudioTracks: () => [mockTrack],
41
+ };
42
+ };
43
+
44
+ const mockGetUserMedia = mock().mockImplementation(() => Promise.resolve(createMockMediaStream()));
45
+
46
+ // Mock AudioContext
47
+ class MockAudioContext {
48
+ state = 'running';
49
+ createMediaStreamSource = mock(() => ({ connect: mock() }));
50
+ createAnalyser = mock(() => ({
51
+ fftSize: 256,
52
+ frequencyBinCount: 128,
53
+ getByteFrequencyData: mock(),
54
+ }));
55
+ close = mock().mockResolvedValue(undefined);
56
+ }
57
+
58
+ // Setup global mocks
59
+ beforeEach(() => {
60
+ (globalThis as any).WebSocket = MockWebSocket;
61
+ (globalThis as any).navigator = {
62
+ mediaDevices: { getUserMedia: mockGetUserMedia },
63
+ };
64
+ (globalThis as any).AudioContext = MockAudioContext;
65
+ (globalThis as any).window = {
66
+ location: { protocol: 'https:', host: 'localhost:3001', origin: 'https://localhost:3001' },
67
+ isSecureContext: true,
68
+ AudioContext: MockAudioContext,
69
+ };
70
+ (globalThis as any).requestAnimationFrame = mock();
71
+ (globalThis as any).cancelAnimationFrame = mock();
72
+ });
73
+
74
+ afterEach(() => {
75
+ // Bun doesn't have restoreAllMocks, but we can manually clean up
76
+ mockGetUserMedia.mockClear();
77
+ });
78
+
79
+ describe('VocaClient', () => {
80
+ describe('constructor', () => {
81
+ it('should create a client with roomId', () => {
82
+ const client = new VocaClient('test-room');
83
+ expect(client).toBeDefined();
84
+ });
85
+
86
+ it('should accept custom config', () => {
87
+ const client = new VocaClient('test-room', {
88
+ signalingUrl: 'wss://custom.example.com',
89
+ });
90
+ expect(client).toBeDefined();
91
+ });
92
+
93
+ it('should accept custom config with apiKey', () => {
94
+ const client = new VocaClient('test-room', {
95
+ apiKey: 'test-key',
96
+ });
97
+ expect(client).toBeDefined();
98
+ });
99
+
100
+ it('should include apiKey in WebSocket URL', () => {
101
+ const client = new VocaClient('test-room', {
102
+ apiKey: 'test-api-key',
103
+ });
104
+ expect(client).toBeDefined();
105
+ });
106
+ });
107
+
108
+ describe('createRoom', () => {
109
+ it('should create a room via API and return client', async () => {
110
+ globalThis.fetch = mock(() => Promise.resolve({
111
+ ok: true,
112
+ json: () => Promise.resolve({ roomId: 'new-room' }),
113
+ } as Response));
114
+
115
+ const client = await VocaClient.createRoom();
116
+ expect(client).toBeDefined();
117
+ });
118
+
119
+ it('should include x-api-key header when apiKey is configured', async () => {
120
+ const fetchMock = mock(() => Promise.resolve({
121
+ ok: true,
122
+ json: () => Promise.resolve({ roomId: 'new-room' }),
123
+ } as Response));
124
+ globalThis.fetch = fetchMock;
125
+
126
+ await VocaClient.createRoom({ apiKey: 'test-key' });
127
+ expect(fetchMock).toHaveBeenCalled();
128
+ });
129
+
130
+ it('should throw on API error', async () => {
131
+ globalThis.fetch = mock(() => Promise.resolve({
132
+ ok: false,
133
+ status: 500,
134
+ } as Response));
135
+
136
+ await expect(VocaClient.createRoom()).rejects.toThrow();
137
+ });
138
+ });
139
+
140
+ describe('connect', () => {
141
+ it('should emit status events during connection', async () => {
142
+ const client = new VocaClient('test-room');
143
+ const statusEvents: string[] = [];
144
+
145
+ client.on('status', (status) => {
146
+ statusEvents.push(status);
147
+ });
148
+
149
+ await client.connect();
150
+
151
+ expect(statusEvents.length).toBeGreaterThan(0);
152
+ });
153
+ });
154
+
155
+ describe('disconnect', () => {
156
+ it('should clean up resources on disconnect', async () => {
157
+ const client = new VocaClient('test-room');
158
+ await client.connect();
159
+ client.disconnect();
160
+
161
+ expect(client).toBeDefined();
162
+ });
163
+ });
164
+
165
+ describe('toggleMute', () => {
166
+ it('should return mute state when called', async () => {
167
+ const client = new VocaClient('test-room');
168
+ await client.connect();
169
+
170
+ const result = client.toggleMute();
171
+ expect(typeof result).toBe('boolean');
172
+ });
173
+ });
174
+
175
+ describe('event emitter', () => {
176
+ it('should allow subscribing to events', () => {
177
+ const client = new VocaClient('test-room');
178
+ const handler = mock();
179
+
180
+ client.on('status', handler);
181
+ expect(handler).not.toHaveBeenCalled();
182
+ });
183
+ });
184
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Voca Error Codes
3
+ *
4
+ * Unified error codes used across all Voca SDKs and the signaling server.
5
+ */
6
+
7
+ export const VocaErrorCode = {
8
+ // Room errors
9
+ ROOM_NOT_FOUND: 'room_not_found',
10
+ ROOM_FULL: 'room_full',
11
+ MAX_ROOMS_REACHED: 'max_rooms_reached',
12
+ INVALID_ROOM_ID: 'invalid_room_id',
13
+
14
+ // Connection errors
15
+ CONNECTION_FAILED: 'connection_failed',
16
+ WEBSOCKET_ERROR: 'websocket_error',
17
+ HEARTBEAT_TIMEOUT: 'heartbeat_timeout',
18
+
19
+ // Media errors
20
+ MICROPHONE_NOT_FOUND: 'microphone_not_found',
21
+ MICROPHONE_PERMISSION_DENIED: 'microphone_permission_denied',
22
+ INSECURE_CONTEXT: 'insecure_context',
23
+
24
+ // Signaling errors
25
+ INVALID_MESSAGE: 'invalid_message',
26
+ PEER_NOT_FOUND: 'peer_not_found',
27
+ } as const;
28
+
29
+ export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
30
+
31
+ /**
32
+ * Human-readable error messages for each error code
33
+ */
34
+ export const VocaErrorMessages: Record<VocaErrorCode, string> = {
35
+ [VocaErrorCode.ROOM_NOT_FOUND]: 'Room not found',
36
+ [VocaErrorCode.ROOM_FULL]: 'Room is at maximum capacity',
37
+ [VocaErrorCode.MAX_ROOMS_REACHED]: 'Maximum number of rooms reached',
38
+ [VocaErrorCode.INVALID_ROOM_ID]: 'Invalid room ID format',
39
+ [VocaErrorCode.CONNECTION_FAILED]: 'Failed to connect to signaling server',
40
+ [VocaErrorCode.WEBSOCKET_ERROR]: 'WebSocket connection error',
41
+ [VocaErrorCode.HEARTBEAT_TIMEOUT]: 'Connection lost due to heartbeat timeout',
42
+ [VocaErrorCode.MICROPHONE_NOT_FOUND]: 'No microphone found. Please connect a microphone and try again.',
43
+ [VocaErrorCode.MICROPHONE_PERMISSION_DENIED]: 'Microphone permission denied. Please allow microphone access.',
44
+ [VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',
45
+ [VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',
46
+ [VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',
47
+ };
48
+
49
+ /**
50
+ * Voca error with code and message
51
+ */
52
+ export interface VocaError {
53
+ code: VocaErrorCode;
54
+ message: string;
55
+ }
56
+
57
+ /**
58
+ * Create a VocaError from a code
59
+ */
60
+ export function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError {
61
+ return {
62
+ code,
63
+ message: customMessage ?? VocaErrorMessages[code],
64
+ };
65
+ }
package/src/index.ts ADDED
@@ -0,0 +1,452 @@
1
+ import { createNanoEvents } from 'nanoevents';
2
+ import { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';
3
+
4
+ export { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';
5
+
6
+ export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';
7
+
8
+ export interface VocaConfig {
9
+ debug?: boolean;
10
+ iceServers?: RTCIceServer[];
11
+ serverUrl?: string; // e.g. "ws://localhost:3001" or "wss://voca.vc"
12
+ apiKey?: string; // optional API key for signaling server auth
13
+ /**
14
+ * Reconnection options. Enabled by default.
15
+ */
16
+ reconnect?: {
17
+ /** Enable automatic reconnection. Default: true */
18
+ enabled?: boolean;
19
+ /** Maximum reconnection attempts. Default: 5 */
20
+ maxAttempts?: number;
21
+ /** Base delay in milliseconds. Default: 1000 */
22
+ baseDelayMs?: number;
23
+ };
24
+ }
25
+
26
+ export interface Peer {
27
+ id: string;
28
+ connection: RTCPeerConnection;
29
+ audioLevel: number;
30
+ stream?: MediaStream;
31
+ }
32
+
33
+ type SignalMessage = {
34
+ from: string;
35
+ type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';
36
+ peer_id?: string;
37
+ to?: string;
38
+ sdp?: string;
39
+ candidate?: string;
40
+ code?: string;
41
+ message?: string;
42
+ // Protocol versioning
43
+ version?: string;
44
+ client?: string;
45
+ };
46
+
47
+ interface VocaEvents {
48
+ 'status': (status: ConnectionStatus) => void;
49
+ 'error': (error: VocaError) => void;
50
+ 'warning': (warning: { code: string; message: string }) => void;
51
+ 'peer-joined': (peerId: string) => void;
52
+ 'peer-left': (peerId: string) => void;
53
+ 'peer-audio-level': (peerId: string, level: number) => void;
54
+ 'local-audio-level': (level: number) => void;
55
+ 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
56
+ }
57
+
58
+ const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
59
+ { urls: 'stun:stun.l.google.com:19302' },
60
+ { urls: 'stun:stun1.l.google.com:19302' },
61
+ ];
62
+
63
+ export class VocaClient {
64
+ public peers: Map<string, Peer> = new Map();
65
+ public localStream: MediaStream | null = null;
66
+ public isMuted: boolean = false;
67
+ public status: ConnectionStatus = 'connecting';
68
+ public roomId: string;
69
+
70
+ private events = createNanoEvents<VocaEvents>();
71
+ private ws: WebSocket | null = null;
72
+ private audioContext: AudioContext | null = null;
73
+ private analyser: AnalyserNode | null = null;
74
+ private animationFrame: number | null = null;
75
+ private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;
76
+ private config: VocaConfig;
77
+
78
+ // Reconnection state
79
+ private reconnectAttempts = 0;
80
+ private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
81
+ private shouldReconnect = true;
82
+
83
+ // Audio analysis nodes per peer (for cleanup)
84
+ private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();
85
+
86
+ /**
87
+ * Create a new room and return a VocaClient connected to it.
88
+ * This is a convenience method that handles room creation via the API.
89
+ *
90
+ * @param config - VocaClient configuration (serverUrl required for non-browser environments)
91
+ * @returns Promise<VocaClient> - A new client instance for the created room
92
+ */
93
+ static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {
94
+ // Determine the HTTP base URL from serverUrl or window.location
95
+ const serverUrl = config.serverUrl
96
+ ? config.serverUrl.replace(/^ws/, 'http')
97
+ : (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : '');
98
+
99
+ if (!serverUrl) {
100
+ throw new Error('VocaConfig.serverUrl is required in non-browser environments');
101
+ }
102
+
103
+ const headers: HeadersInit = {
104
+ 'Content-Type': 'application/json',
105
+ };
106
+
107
+ if (config.apiKey) {
108
+ headers['x-api-key'] = config.apiKey;
109
+ }
110
+
111
+ const response = await fetch(`${serverUrl}/api/room`, {
112
+ method: 'POST',
113
+ headers,
114
+ body: JSON.stringify(config)
115
+ });
116
+
117
+ if (!response.ok) {
118
+ const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));
119
+ throw new Error(error.message || 'Failed to create room');
120
+ }
121
+
122
+ const { room } = await response.json();
123
+ return new VocaClient(room, config);
124
+ }
125
+
126
+ constructor(roomId: string, config: VocaConfig = {}) {
127
+ this.roomId = roomId;
128
+ this.config = config;
129
+ if (config.iceServers) this.iceServers = config.iceServers;
130
+ // turnServers is no longer part of VocaConfig, removed this line
131
+ }
132
+
133
+ public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {
134
+ return this.events.on(event, callback);
135
+ }
136
+
137
+ public async connect(): Promise<void> {
138
+ this.status = 'connecting';
139
+ this.events.emit('status', 'connecting');
140
+
141
+ try {
142
+ // Use default STUN servers (Google's public STUN servers)
143
+ await this.setupMediaAndAudio();
144
+ this.connectSocket();
145
+ } catch (err) {
146
+ this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');
147
+ throw err;
148
+ }
149
+ }
150
+
151
+ public disconnect() {
152
+ // Prevent reconnection attempts
153
+ this.shouldReconnect = false;
154
+ if (this.reconnectTimeout) {
155
+ clearTimeout(this.reconnectTimeout);
156
+ this.reconnectTimeout = null;
157
+ }
158
+
159
+ if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
160
+ this.peers.forEach((p) => p.connection.close());
161
+ this.peers.clear();
162
+ this.ws?.close();
163
+ this.localStream?.getTracks().forEach((t) => t.stop());
164
+ if (this.audioContext && this.audioContext.state !== 'closed') {
165
+ this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));
166
+ }
167
+ this.status = 'disconnected';
168
+ this.events.emit('status', 'disconnected');
169
+ }
170
+
171
+ public toggleMute() {
172
+ const track = this.localStream?.getAudioTracks()[0];
173
+ if (track) {
174
+ track.enabled = !track.enabled;
175
+ this.isMuted = !track.enabled;
176
+ }
177
+ return this.isMuted;
178
+ }
179
+
180
+
181
+
182
+ private async setupMediaAndAudio() {
183
+ // Only verify secure context in browsers
184
+ if (typeof window !== 'undefined' && !window.isSecureContext) {
185
+ throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);
186
+ }
187
+
188
+ try {
189
+ this.localStream = await navigator.mediaDevices.getUserMedia({
190
+ audio: {
191
+ echoCancellation: true,
192
+ noiseSuppression: true,
193
+ autoGainControl: true,
194
+ latency: 0,
195
+ channelCount: 1
196
+ } as any,
197
+ video: false,
198
+ });
199
+ } catch (err) {
200
+ try {
201
+ this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
202
+ } catch (retryErr: any) {
203
+ if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {
204
+ throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);
205
+ }
206
+ if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {
207
+ throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);
208
+ }
209
+ throw retryErr;
210
+ }
211
+ }
212
+
213
+ this.setupAudioAnalysis(this.localStream!);
214
+ }
215
+
216
+ private setupAudioAnalysis(stream: MediaStream) {
217
+ const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
218
+ this.audioContext = new AudioContextClass();
219
+ const source = this.audioContext.createMediaStreamSource(stream);
220
+ this.analyser = this.audioContext.createAnalyser();
221
+ this.analyser.fftSize = 256;
222
+ source.connect(this.analyser);
223
+
224
+ const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
225
+ const update = () => {
226
+ if (!this.analyser) return;
227
+ this.analyser.getByteFrequencyData(dataArray);
228
+ const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
229
+ const level = Math.min(avg / 128, 1);
230
+ this.events.emit('local-audio-level', level);
231
+ this.animationFrame = requestAnimationFrame(update);
232
+ };
233
+ update();
234
+ }
235
+
236
+ /**
237
+ * Infer the WebSocket server URL from config or environment.
238
+ */
239
+ private getServerUrl(): string {
240
+ let url: string;
241
+ const config = this.config;
242
+
243
+ // Non-browser environments require explicit serverUrl
244
+ if (typeof window === 'undefined') {
245
+ if (!config.serverUrl) {
246
+ throw new Error('VocaConfig.serverUrl is required in non-browser environments');
247
+ }
248
+ url = config.serverUrl;
249
+ } else {
250
+ if (config.serverUrl) {
251
+ url = config.serverUrl;
252
+ } else {
253
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
254
+ url = `${protocol}//${window.location.host}`;
255
+ }
256
+ }
257
+
258
+ // Append apiKey if present
259
+ if (config.apiKey) {
260
+ const separator = url.includes('?') ? '&' : '?';
261
+ url += `${separator}apiKey=${encodeURIComponent(config.apiKey)}`;
262
+ }
263
+
264
+ return `${url}/ws/${this.roomId}`;
265
+ }
266
+
267
+ private connectSocket() {
268
+ const socketUrl = this.getServerUrl();
269
+
270
+ this.ws = new WebSocket(socketUrl);
271
+
272
+ this.ws.onopen = () => {
273
+ // Send hello message with version info
274
+ this.send({ type: 'hello', version: '1.0', client: '@treyorr/voca-client' });
275
+ this.status = 'connected';
276
+ this.events.emit('status', 'connected');
277
+ // Reset reconnect attempts on successful connection
278
+ this.reconnectAttempts = 0;
279
+ };
280
+
281
+ this.ws.onclose = () => {
282
+ // Don't reconnect on terminal states
283
+ if (this.status === 'full' || this.status === 'error') {
284
+ return;
285
+ }
286
+
287
+ const reconnectEnabled = this.config.reconnect?.enabled !== false;
288
+ const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;
289
+ const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;
290
+
291
+ if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {
292
+ this.status = 'reconnecting';
293
+ this.events.emit('status', 'reconnecting');
294
+
295
+ // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)
296
+ const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);
297
+ this.reconnectAttempts++;
298
+
299
+ this.reconnectTimeout = setTimeout(() => {
300
+ this.connectSocket();
301
+ }, delay);
302
+ } else {
303
+ this.status = 'disconnected';
304
+ this.events.emit('status', 'disconnected');
305
+ }
306
+ };
307
+
308
+ this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');
309
+
310
+ this.ws.onmessage = (e) => {
311
+ const msg: SignalMessage = JSON.parse(e.data);
312
+ this.handleSignal(msg);
313
+ };
314
+ }
315
+
316
+ private async handleSignal(msg: SignalMessage) {
317
+ switch (msg.type) {
318
+ case 'welcome':
319
+ // Protocol handshake complete - peer_id is managed server-side
320
+ console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);
321
+ break;
322
+ case 'join':
323
+ await this.createPeer(msg.from, true);
324
+ break;
325
+ case 'offer':
326
+ await this.createPeer(msg.from, false, msg.sdp);
327
+ break;
328
+ case 'answer':
329
+ const peer = this.peers.get(msg.from);
330
+ if (peer) {
331
+ await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });
332
+ }
333
+ break;
334
+ case 'ice':
335
+ const p = this.peers.get(msg.from);
336
+ if (p) {
337
+ try {
338
+ await p.connection.addIceCandidate(JSON.parse(msg.candidate!));
339
+ } catch (e) {
340
+ console.warn('[Voca] Invalid ICE candidate:', e);
341
+ }
342
+ }
343
+ break;
344
+ case 'ping':
345
+ this.ws?.send(JSON.stringify({ from: '', type: 'pong' }));
346
+ break;
347
+ case 'leave':
348
+ this.removePeer(msg.from);
349
+ break;
350
+ case 'error':
351
+ this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');
352
+ break;
353
+ }
354
+ }
355
+
356
+ private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {
357
+ const pc = new RTCPeerConnection({ iceServers: this.iceServers });
358
+
359
+ pc.onicecandidate = (e) => {
360
+ if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });
361
+ };
362
+
363
+ pc.ontrack = (e) => {
364
+ // Emit track event so UI can handle the audio element/stream
365
+ this.events.emit('track', peerId, e.track, e.streams[0]);
366
+ this.setupRemoteAudio(peerId, e.streams[0]);
367
+ const peer = this.peers.get(peerId);
368
+ if (peer) peer.stream = e.streams[0];
369
+ };
370
+
371
+ // Add local tracks
372
+ this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));
373
+
374
+ this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });
375
+ this.events.emit('peer-joined', peerId);
376
+
377
+ if (isInitiator) {
378
+ const offer = await pc.createOffer();
379
+ await pc.setLocalDescription(offer);
380
+ this.send({ type: 'offer', to: peerId, sdp: offer.sdp });
381
+ } else if (remoteSdp) {
382
+ await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });
383
+ const answer = await pc.createAnswer();
384
+ await pc.setLocalDescription(answer);
385
+ this.send({ type: 'answer', to: peerId, sdp: answer.sdp });
386
+ }
387
+ }
388
+
389
+ private removePeer(peerId: string) {
390
+ const peer = this.peers.get(peerId);
391
+ peer?.connection.close();
392
+ this.peers.delete(peerId);
393
+
394
+ // Clean up audio analysis nodes
395
+ const audio = this.peerAnalysers.get(peerId);
396
+ if (audio) {
397
+ audio.source.disconnect();
398
+ audio.analyser.disconnect();
399
+ this.peerAnalysers.delete(peerId);
400
+ }
401
+
402
+ this.events.emit('peer-left', peerId);
403
+ }
404
+
405
+ private setupRemoteAudio(peerId: string, stream: MediaStream) {
406
+ if (!this.audioContext) {
407
+ const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
408
+ this.audioContext = new AudioContextClass();
409
+ }
410
+
411
+ const source = this.audioContext.createMediaStreamSource(stream);
412
+ const analyser = this.audioContext.createAnalyser();
413
+ analyser.fftSize = 256;
414
+ source.connect(analyser);
415
+
416
+ // CRITICAL: Connect to speakers so audio is actually played!
417
+ source.connect(this.audioContext.destination);
418
+
419
+ // Store for cleanup when peer leaves
420
+ this.peerAnalysers.set(peerId, { source, analyser });
421
+
422
+ const data = new Uint8Array(analyser.frequencyBinCount);
423
+ const update = () => {
424
+ const peer = this.peers.get(peerId);
425
+ if (!peer) return;
426
+
427
+ analyser.getByteFrequencyData(data);
428
+ const avg = data.reduce((a, b) => a + b, 0) / data.length;
429
+ const level = Math.min(avg / 128, 1);
430
+
431
+ this.events.emit('peer-audio-level', peerId, level);
432
+
433
+ // Store in peer object as well for convenience
434
+ peer.audioLevel = level;
435
+
436
+ requestAnimationFrame(update);
437
+ };
438
+ update();
439
+ }
440
+
441
+ private send(msg: Partial<SignalMessage>) {
442
+ if (this.ws?.readyState === WebSocket.OPEN) {
443
+ this.ws.send(JSON.stringify({ from: '', ...msg }));
444
+ }
445
+ }
446
+
447
+ private handleError(code: VocaErrorCode | string, message: string) {
448
+ this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';
449
+ this.events.emit('status', this.status);
450
+ this.events.emit('error', { code: code as VocaErrorCode, message });
451
+ }
452
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "outDir": "dist",
11
+ "declaration": true
12
+ },
13
+ "include": [
14
+ "src"
15
+ ],
16
+ "exclude": [
17
+ "src/**/*.test.ts",
18
+ "src/**/__tests__/**/*"
19
+ ]
20
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: true,
7
+ },
8
+ });