@treyorr/voca-client 0.2.0 → 0.3.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 CHANGED
@@ -26,6 +26,7 @@ import { VocaClient } from '@treyorr/voca-client';
26
26
  const client = await VocaClient.createRoom({
27
27
  serverUrl: 'https://voca.vc',
28
28
  apiKey: 'your-api-key', // Get this from voca.vc/docs
29
+ password: 'secret123', // Optional: 4-12 alphanumeric chars
29
30
  });
30
31
 
31
32
  console.log('Share this room ID:', client.roomId);
@@ -40,6 +41,7 @@ await client.connect();
40
41
  const client = new VocaClient('room-id-here', {
41
42
  serverUrl: 'https://voca.vc',
42
43
  apiKey: 'your-api-key',
44
+ password: 'secret123', // Required if room is password-protected
43
45
  });
44
46
 
45
47
  await client.connect();
@@ -51,6 +53,7 @@ await client.connect();
51
53
  |--------|----------|-------------|
52
54
  | `serverUrl` | **Yes** | Server URL (e.g., `https://voca.vc` or your self-hosted server) |
53
55
  | `apiKey` | No* | API key for authentication (*required for voca.vc) |
56
+ | `password` | No | Room password (4-12 alphanumeric characters) |
54
57
  | `reconnect.enabled` | No | Auto-reconnect on disconnect (default: `true`) |
55
58
  | `reconnect.maxAttempts` | No | Max reconnection attempts (default: `5`) |
56
59
 
@@ -64,6 +67,7 @@ await client.connect();
64
67
  | `disconnect()` | Leave room and cleanup |
65
68
  | `toggleMute()` | Toggle mute, returns new state |
66
69
  | `on(event, callback)` | Subscribe to events |
70
+ | `validatePassword(password)` | Validate password format, returns error or null |
67
71
 
68
72
  ## Events
69
73
 
package/dist/errors.d.ts CHANGED
@@ -16,6 +16,8 @@ export declare const VocaErrorCode: {
16
16
  readonly INSECURE_CONTEXT: "insecure_context";
17
17
  readonly INVALID_MESSAGE: "invalid_message";
18
18
  readonly PEER_NOT_FOUND: "peer_not_found";
19
+ readonly INVALID_PASSWORD: "invalid_password";
20
+ readonly PASSWORD_REQUIRED: "password_required";
19
21
  };
20
22
  export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
21
23
  /**
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export interface VocaConfig {
6
6
  iceServers?: RTCIceServer[];
7
7
  serverUrl?: string;
8
8
  apiKey?: string;
9
+ password?: string;
9
10
  /**
10
11
  * Reconnection options. Enabled by default.
11
12
  */
@@ -37,6 +38,14 @@ interface VocaEvents {
37
38
  'local-audio-level': (level: number) => void;
38
39
  'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
39
40
  }
41
+ /**
42
+ * Validate password format for room creation.
43
+ * Returns null if valid, or an error message if invalid.
44
+ *
45
+ * @param password - The password to validate
46
+ * @returns Error message if invalid, null if valid
47
+ */
48
+ export declare function validatePassword(password: string): string | null;
40
49
  export declare class VocaClient {
41
50
  peers: Map<string, Peer>;
42
51
  localStream: MediaStream | null;
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- var W=()=>({emit(h,...q){for(let G=this.events[h]||[],z=0,B=G.length;z<B;z++)G[z](...q)},events:{},on(h,q){return(this.events[h]||=[]).push(q),()=>{this.events[h]=this.events[h]?.filter((G)=>q!==G)}}});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 P{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=W();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=P.getHttpUrl(h.serverUrl);if(!q)throw Error("VocaConfig.serverUrl is required in non-browser environments");let G={"Content-Type":"application/json"};if(h.apiKey)G["x-api-key"]=h.apiKey;let z=await fetch(`${q}/api/room`,{method:"POST",headers:G,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:B}=await z.json();return new P(B,h)}static getHttpUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol}//${window.location.host}`;return""}if(h.startsWith("wss://"))return h.replace("wss://","https://");if(h.startsWith("ws://"))return h.replace("ws://","http://");return h}static getWsUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;throw Error("VocaConfig.serverUrl is required in non-browser environments")}if(h.startsWith("https://"))return h.replace("https://","wss://");if(h.startsWith("http://"))return h.replace("http://","ws://");return 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 G=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,G.connect(this.analyser);let z=new Uint8Array(this.analyser.frequencyBinCount),B=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(z);let J=z.reduce((Q,O)=>Q+O,0)/z.length,K=Math.min(J/128,1);this.events.emit("local-audio-level",K),this.animationFrame=requestAnimationFrame(B)};B()}getSocketUrl(){let q=`${P.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`;if(this.config.apiKey)q+=`?apiKey=${encodeURIComponent(this.config.apiKey)}`;return q}connectSocket(){let h=this.getSocketUrl();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,G=this.config.reconnect?.maxAttempts??5,z=this.config.reconnect?.baseDelayMs??1000;if(q&&this.shouldReconnect&&this.reconnectAttempts<G){this.status="reconnecting",this.events.emit("status","reconnecting");let B=Math.min(z*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},B)}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 G=JSON.parse(q.data);this.handleSignal(G)}}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 G=this.peers.get(h.from);if(G)try{await G.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,G){let z=new RTCPeerConnection({iceServers:this.iceServers});if(z.onicecandidate=(B)=>{if(B.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(B.candidate)})},z.ontrack=(B)=>{this.events.emit("track",h,B.track,B.streams[0]),this.setupRemoteAudio(h,B.streams[0]);let J=this.peers.get(h);if(J)J.stream=B.streams[0]},this.localStream?.getTracks().forEach((B)=>z.addTrack(B,this.localStream)),this.peers.set(h,{id:h,connection:z,audioLevel:0}),this.events.emit("peer-joined",h),q){let B=await z.createOffer();await z.setLocalDescription(B),this.send({type:"offer",to:h,sdp:B.sdp})}else if(G){await z.setRemoteDescription({type:"offer",sdp:G});let B=await z.createAnswer();await z.setLocalDescription(B),this.send({type:"answer",to:h,sdp:B.sdp})}}removePeer(h){this.peers.get(h)?.connection.close(),this.peers.delete(h);let G=this.peerAnalysers.get(h);if(G)G.source.disconnect(),G.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 G=this.audioContext.createMediaStreamSource(q),z=this.audioContext.createAnalyser();z.fftSize=256,G.connect(z),G.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:G,analyser:z});let B=new Uint8Array(z.frequencyBinCount),J=()=>{let K=this.peers.get(h);if(!K)return;z.getByteFrequencyData(B);let Q=B.reduce((X,Y)=>X+Y,0)/B.length,O=Math.min(Q/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,P as VocaClient};
1
+ var X=()=>({emit(h,...j){for(let q=this.events[h]||[],z=0,B=q.length;z<B;z++)q[z](...j)},events:{},on(h,j){return(this.events[h]||=[]).push(j),()=>{this.events[h]=this.events[h]?.filter((q)=>j!==q)}}});var G={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",INVALID_PASSWORD:"invalid_password",PASSWORD_REQUIRED:"password_required"},N={[G.ROOM_NOT_FOUND]:"Room not found",[G.ROOM_FULL]:"Room is at maximum capacity",[G.MAX_ROOMS_REACHED]:"Maximum number of rooms reached",[G.INVALID_ROOM_ID]:"Invalid room ID format",[G.CONNECTION_FAILED]:"Failed to connect to signaling server",[G.WEBSOCKET_ERROR]:"WebSocket connection error",[G.HEARTBEAT_TIMEOUT]:"Connection lost due to heartbeat timeout",[G.MICROPHONE_NOT_FOUND]:"No microphone found. Please connect a microphone and try again.",[G.MICROPHONE_PERMISSION_DENIED]:"Microphone permission denied. Please allow microphone access.",[G.INSECURE_CONTEXT]:"HTTPS is required for microphone access",[G.INVALID_MESSAGE]:"Invalid signaling message received",[G.PEER_NOT_FOUND]:"Peer not found in room",[G.INVALID_PASSWORD]:"Incorrect password",[G.PASSWORD_REQUIRED]:"This room requires a password"};function Z(h,j){return{code:h,message:j??N[h]}}function D(h){if(!h)return null;if(h.length<4||h.length>12)return"Password must be 4-12 characters";if(!/^[a-zA-Z0-9]+$/.test(h))return"Password must contain only letters and numbers";return null}var $=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class P{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=X();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=$;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let j=P.getHttpUrl(h.serverUrl);if(!j)throw Error("VocaConfig.serverUrl is required in non-browser environments");let q={"Content-Type":"application/json"};if(h.apiKey)q["x-api-key"]=h.apiKey;let z=`${j}/api/room`,B=new URLSearchParams;if(h.password)B.append("password",h.password);if(B.toString())z+=`?${B.toString()}`;let H=await fetch(z,{method:"POST",headers:q});if(!H.ok){let W=await H.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(W.message||"Failed to create room")}let{room:J,password:O}=await H.json(),K={...h,password:O||h.password};return new P(J,K)}static getHttpUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol}//${window.location.host}`;return""}if(h.startsWith("wss://"))return h.replace("wss://","https://");if(h.startsWith("ws://"))return h.replace("ws://","http://");return h}static getWsUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;throw Error("VocaConfig.serverUrl is required in non-browser environments")}if(h.startsWith("https://"))return h.replace("https://","wss://");if(h.startsWith("http://"))return h.replace("http://","ws://");return h}constructor(h,j={}){if(this.roomId=h,this.config=j,j.iceServers)this.iceServers=j.iceServers}on(h,j){return this.events.on(h,j)}async connect(){this.status="connecting",this.events.emit("status","connecting");try{await this.setupMediaAndAudio(),this.connectSocket()}catch(h){throw this.handleError(G.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[G.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(j){if(j.name==="NotFoundError"||j.message.includes("Requested device not found"))throw Error(N[G.MICROPHONE_NOT_FOUND]);if(j.name==="NotAllowedError"||j.message.includes("Permission denied"))throw Error(N[G.MICROPHONE_PERMISSION_DENIED]);throw j}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let j=window.AudioContext||window.webkitAudioContext;this.audioContext=new j;let q=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,q.connect(this.analyser);let z=new Uint8Array(this.analyser.frequencyBinCount),B=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(z);let H=z.reduce((O,K)=>O+K,0)/z.length,J=Math.min(H/128,1);this.events.emit("local-audio-level",J),this.animationFrame=requestAnimationFrame(B)};B()}getSocketUrl(){let j=`${P.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`,q=new URLSearchParams;if(this.config.apiKey)q.append("apiKey",this.config.apiKey);if(this.config.password)q.append("password",this.config.password);if(q.toString())j+=`?${q.toString()}`;return j}connectSocket(){let h=this.getSocketUrl();this.ws=new WebSocket(h),this.ws.onopen=()=>{this.send({type:"hello",version:"0.3.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 j=this.config.reconnect?.enabled!==!1,q=this.config.reconnect?.maxAttempts??5,z=this.config.reconnect?.baseDelayMs??1000;if(j&&this.shouldReconnect&&this.reconnectAttempts<q){this.status="reconnecting",this.events.emit("status","reconnecting");let B=Math.min(z*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},B)}else this.status="disconnected",this.events.emit("status","disconnected")},this.ws.onerror=()=>this.handleError(G.WEBSOCKET_ERROR,"WebSocket connection failed"),this.ws.onmessage=(j)=>{let q=JSON.parse(j.data);this.handleSignal(q)}}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 j=this.peers.get(h.from);if(j)await j.connection.setRemoteDescription({type:"answer",sdp:h.sdp});break;case"ice":let q=this.peers.get(h.from);if(q)try{await q.connection.addIceCandidate(JSON.parse(h.candidate))}catch(z){console.warn("[Voca] Invalid ICE candidate:",z)}break;case"ping":this.send({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,j,q){let z=new RTCPeerConnection({iceServers:this.iceServers});if(z.onicecandidate=(B)=>{if(B.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(B.candidate)})},z.ontrack=(B)=>{this.events.emit("track",h,B.track,B.streams[0]),this.setupRemoteAudio(h,B.streams[0]);let H=this.peers.get(h);if(H)H.stream=B.streams[0]},this.localStream?.getTracks().forEach((B)=>z.addTrack(B,this.localStream)),this.peers.set(h,{id:h,connection:z,audioLevel:0}),this.events.emit("peer-joined",h),j){let B=await z.createOffer();await z.setLocalDescription(B),this.send({type:"offer",to:h,sdp:B.sdp})}else if(q){await z.setRemoteDescription({type:"offer",sdp:q});let B=await z.createAnswer();await z.setLocalDescription(B),this.send({type:"answer",to:h,sdp:B.sdp})}}removePeer(h){this.peers.get(h)?.connection.close(),this.peers.delete(h);let q=this.peerAnalysers.get(h);if(q)q.source.disconnect(),q.analyser.disconnect(),this.peerAnalysers.delete(h);this.events.emit("peer-left",h)}setupRemoteAudio(h,j){if(!this.audioContext){let J=window.AudioContext||window.webkitAudioContext;this.audioContext=new J}let q=this.audioContext.createMediaStreamSource(j),z=this.audioContext.createAnalyser();z.fftSize=256,q.connect(z),q.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:q,analyser:z});let B=new Uint8Array(z.frequencyBinCount),H=()=>{let J=this.peers.get(h);if(!J)return;z.getByteFrequencyData(B);let O=B.reduce((W,Y)=>W+Y,0)/B.length,K=Math.min(O/128,1);this.events.emit("peer-audio-level",h,K),J.audioLevel=K,requestAnimationFrame(H)};H()}send(h){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;this.ws.send(JSON.stringify({from:"",...h}))}handleError(h,j){this.status=h===G.ROOM_FULL||h==="room_full"?"full":"error",this.events.emit("status",this.status),this.events.emit("error",{code:h,message:j})}}export{D as validatePassword,Z as createVocaError,N as VocaErrorMessages,G as VocaErrorCode,P as VocaClient};
2
2
 
3
- //# debugId=CA791DEBDD59EDAD64756E2164756E21
3
+ //# debugId=FA20E2EBA674C21264756E2164756E21
package/dist/index.js.map CHANGED
@@ -3,10 +3,10 @@
3
3
  "sources": ["../../../node_modules/.bun/nanoevents@9.1.0/node_modules/nanoevents/index.js", "../src/errors.ts", "../src/index.ts"],
4
4
  "sourcesContent": [
5
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';\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 const httpUrl = VocaClient.getHttpUrl(config.serverUrl);\n\n if (!httpUrl) {\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(`${httpUrl}/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 /**\n * Derive an HTTP/HTTPS URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: https:// or http:// URL\n */\n private static getHttpUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n return `${window.location.protocol}//${window.location.host}`;\n }\n return '';\n }\n // Normalize to HTTP(S)\n if (serverUrl.startsWith('wss://')) return serverUrl.replace('wss://', 'https://');\n if (serverUrl.startsWith('ws://')) return serverUrl.replace('ws://', 'http://');\n return serverUrl;\n }\n\n /**\n * Derive a WebSocket URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: wss:// or ws:// URL\n */\n private static getWsUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n return `${protocol}//${window.location.host}`;\n }\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n // Normalize to WS(S)\n if (serverUrl.startsWith('https://')) return serverUrl.replace('https://', 'wss://');\n if (serverUrl.startsWith('http://')) return serverUrl.replace('http://', 'ws://');\n return serverUrl;\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 }\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 * Build the full WebSocket URL for connecting to a room.\n */\n private getSocketUrl(): string {\n const wsUrl = VocaClient.getWsUrl(this.config.serverUrl);\n\n // Build URL: base + path + query params\n let fullUrl = `${wsUrl}/ws/${this.roomId}`;\n\n // Append apiKey if present\n if (this.config.apiKey) {\n fullUrl += `?apiKey=${encodeURIComponent(this.config.apiKey)}`;\n }\n\n return fullUrl;\n }\n\n private connectSocket() {\n const socketUrl = this.getSocketUrl();\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"
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\n // Password errors\n INVALID_PASSWORD: 'invalid_password',\n PASSWORD_REQUIRED: 'password_required',\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 [VocaErrorCode.INVALID_PASSWORD]: 'Incorrect password',\n [VocaErrorCode.PASSWORD_REQUIRED]: 'This room requires a password',\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';\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 password?: string; // optional room password for protected rooms\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\n/**\n * Validate password format for room creation.\n * Returns null if valid, or an error message if invalid.\n * \n * @param password - The password to validate\n * @returns Error message if invalid, null if valid\n */\nexport function validatePassword(password: string): string | null {\n if (!password) return null; // Empty is valid (no password)\n if (password.length < 4 || password.length > 12) {\n return 'Password must be 4-12 characters';\n }\n if (!/^[a-zA-Z0-9]+$/.test(password)) {\n return 'Password must contain only letters and numbers';\n }\n return null;\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 const httpUrl = VocaClient.getHttpUrl(config.serverUrl);\n\n if (!httpUrl) {\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 // Build URL with optional password query param\n let url = `${httpUrl}/api/room`;\n const params = new URLSearchParams();\n if (config.password) {\n params.append('password', config.password);\n }\n if (params.toString()) {\n url += `?${params.toString()}`;\n }\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\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, password } = await response.json();\n // Use the password from response (in case server modified it) or from config\n const roomConfig = { ...config, password: password || config.password };\n return new VocaClient(room, roomConfig);\n }\n\n /**\n * Derive an HTTP/HTTPS URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: https:// or http:// URL\n */\n private static getHttpUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n return `${window.location.protocol}//${window.location.host}`;\n }\n return '';\n }\n // Normalize to HTTP(S)\n if (serverUrl.startsWith('wss://')) return serverUrl.replace('wss://', 'https://');\n if (serverUrl.startsWith('ws://')) return serverUrl.replace('ws://', 'http://');\n return serverUrl;\n }\n\n /**\n * Derive a WebSocket URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: wss:// or ws:// URL\n */\n private static getWsUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n return `${protocol}//${window.location.host}`;\n }\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n // Normalize to WS(S)\n if (serverUrl.startsWith('https://')) return serverUrl.replace('https://', 'wss://');\n if (serverUrl.startsWith('http://')) return serverUrl.replace('http://', 'ws://');\n return serverUrl;\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 }\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 * Build the full WebSocket URL for connecting to a room.\n */\n private getSocketUrl(): string {\n const wsUrl = VocaClient.getWsUrl(this.config.serverUrl);\n\n // Build URL: base + path + query params\n let fullUrl = `${wsUrl}/ws/${this.roomId}`;\n\n const params = new URLSearchParams();\n\n // Append apiKey if present\n if (this.config.apiKey) {\n params.append('apiKey', this.config.apiKey);\n }\n\n // Append password if present\n if (this.config.password) {\n params.append('password', this.config.password);\n }\n\n if (params.toString()) {\n fullUrl += `?${params.toString()}`;\n }\n\n return fullUrl;\n }\n\n private connectSocket() {\n const socketUrl = this.getSocketUrl();\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: '0.3.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.send({ 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 || this.ws.readyState !== WebSocket.OPEN) {\n // Queue or drop - for now we drop since this is a signaling race condition\n return;\n }\n this.ws.send(JSON.stringify({ from: '', ...msg }));\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
8
  ],
9
- "mappings": "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",
10
- "debugId": "CA791DEBDD59EDAD64756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "FA20E2EBA674C21264756E2164756E21",
11
11
  "names": []
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treyorr/voca-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Core TypeScript SDK for Voca Signaling",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2
- import { VocaClient } from '../index';
2
+ import { VocaClient, validatePassword, VocaErrorCode } from '../index';
3
3
 
4
4
  // Mock WebSocket
5
5
  class MockWebSocket {
@@ -85,7 +85,7 @@ describe('VocaClient', () => {
85
85
 
86
86
  it('should accept custom config', () => {
87
87
  const client = new VocaClient('test-room', {
88
- signalingUrl: 'wss://custom.example.com',
88
+ serverUrl: 'wss://custom.example.com',
89
89
  });
90
90
  expect(client).toBeDefined();
91
91
  });
@@ -135,6 +135,100 @@ describe('VocaClient', () => {
135
135
 
136
136
  await expect(VocaClient.createRoom()).rejects.toThrow();
137
137
  });
138
+
139
+ it('should include password in API request if provided', async () => {
140
+ const fetchMock = mock(() => Promise.resolve({
141
+ ok: true,
142
+ json: () => Promise.resolve({ room: 'new-room', password: 'testpassword' }),
143
+ } as Response));
144
+ globalThis.fetch = fetchMock;
145
+
146
+ await VocaClient.createRoom({ password: 'testpassword' });
147
+
148
+ const [url, options] = (fetchMock as any).mock.calls[0];
149
+ expect(url).toContain('password=testpassword');
150
+ });
151
+ });
152
+
153
+ describe('validatePassword', () => {
154
+ it('should return null for valid passwords', () => {
155
+ expect(validatePassword('trey123')).toBeNull();
156
+ expect(validatePassword('voca')).toBeNull();
157
+ });
158
+
159
+ it('should return null for empty/undefined (optional)', () => {
160
+ expect(validatePassword('')).toBeNull();
161
+ });
162
+
163
+ it('should reject passwords that are too short', () => {
164
+ expect(validatePassword('abc')).toBe('Password must be 4-12 characters');
165
+ });
166
+
167
+ it('should reject passwords that are too long', () => {
168
+ expect(validatePassword('toolongpassword123')).toBe('Password must be 4-12 characters');
169
+ });
170
+
171
+ it('should reject invalid characters', () => {
172
+ expect(validatePassword('trey!123')).toBe('Password must contain only letters and numbers');
173
+ });
174
+ });
175
+
176
+ describe('password handling', () => {
177
+ it('should include password in WebSocket URL', async () => {
178
+ const client = new VocaClient('test-room', {
179
+ password: 'secretpassword',
180
+ });
181
+ await client.connect();
182
+
183
+ // @ts-ignore - access private ws to check URL
184
+ expect(client.ws.url).toContain('password=secretpassword');
185
+ });
186
+
187
+ it('should emit error when password is required', async () => {
188
+ const client = new VocaClient('test-room');
189
+ const errorHandler = mock();
190
+ client.on('error', errorHandler);
191
+
192
+ await client.connect();
193
+
194
+ // Simulate server sending password_required error
195
+ // @ts-ignore - trigger onmessage
196
+ client.ws.onmessage({
197
+ data: JSON.stringify({
198
+ from: 'server',
199
+ type: 'error',
200
+ code: 'password_required',
201
+ message: 'Password required'
202
+ })
203
+ });
204
+
205
+ expect(errorHandler).toHaveBeenCalled();
206
+ const [error] = errorHandler.mock.calls[0];
207
+ expect(error.code).toBe(VocaErrorCode.PASSWORD_REQUIRED);
208
+ });
209
+
210
+ it('should emit error when password is invalid', async () => {
211
+ const client = new VocaClient('test-room', { password: 'wrong' });
212
+ const errorHandler = mock();
213
+ client.on('error', errorHandler);
214
+
215
+ await client.connect();
216
+
217
+ // Simulate server sending invalid_password error
218
+ // @ts-ignore - trigger onmessage
219
+ client.ws.onmessage({
220
+ data: JSON.stringify({
221
+ from: 'server',
222
+ type: 'error',
223
+ code: 'invalid_password',
224
+ message: 'Invalid password'
225
+ })
226
+ });
227
+
228
+ expect(errorHandler).toHaveBeenCalled();
229
+ const [error] = errorHandler.mock.calls[0];
230
+ expect(error.code).toBe(VocaErrorCode.INVALID_PASSWORD);
231
+ });
138
232
  });
139
233
 
140
234
  describe('connect', () => {
package/src/errors.ts CHANGED
@@ -24,6 +24,10 @@ export const VocaErrorCode = {
24
24
  // Signaling errors
25
25
  INVALID_MESSAGE: 'invalid_message',
26
26
  PEER_NOT_FOUND: 'peer_not_found',
27
+
28
+ // Password errors
29
+ INVALID_PASSWORD: 'invalid_password',
30
+ PASSWORD_REQUIRED: 'password_required',
27
31
  } as const;
28
32
 
29
33
  export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
@@ -44,6 +48,8 @@ export const VocaErrorMessages: Record<VocaErrorCode, string> = {
44
48
  [VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',
45
49
  [VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',
46
50
  [VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',
51
+ [VocaErrorCode.INVALID_PASSWORD]: 'Incorrect password',
52
+ [VocaErrorCode.PASSWORD_REQUIRED]: 'This room requires a password',
47
53
  };
48
54
 
49
55
  /**
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export interface VocaConfig {
9
9
  iceServers?: RTCIceServer[];
10
10
  serverUrl?: string; // e.g. "ws://localhost:3001" or "wss://voca.vc"
11
11
  apiKey?: string; // optional API key for signaling server auth
12
+ password?: string; // optional room password for protected rooms
12
13
  /**
13
14
  * Reconnection options. Enabled by default.
14
15
  */
@@ -54,6 +55,24 @@ interface VocaEvents {
54
55
  'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
55
56
  }
56
57
 
58
+ /**
59
+ * Validate password format for room creation.
60
+ * Returns null if valid, or an error message if invalid.
61
+ *
62
+ * @param password - The password to validate
63
+ * @returns Error message if invalid, null if valid
64
+ */
65
+ export function validatePassword(password: string): string | null {
66
+ if (!password) return null; // Empty is valid (no password)
67
+ if (password.length < 4 || password.length > 12) {
68
+ return 'Password must be 4-12 characters';
69
+ }
70
+ if (!/^[a-zA-Z0-9]+$/.test(password)) {
71
+ return 'Password must contain only letters and numbers';
72
+ }
73
+ return null;
74
+ }
75
+
57
76
  const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
58
77
  { urls: 'stun:stun.l.google.com:19302' },
59
78
  { urls: 'stun:stun1.l.google.com:19302' },
@@ -104,10 +123,19 @@ export class VocaClient {
104
123
  headers['x-api-key'] = config.apiKey;
105
124
  }
106
125
 
107
- const response = await fetch(`${httpUrl}/api/room`, {
126
+ // Build URL with optional password query param
127
+ let url = `${httpUrl}/api/room`;
128
+ const params = new URLSearchParams();
129
+ if (config.password) {
130
+ params.append('password', config.password);
131
+ }
132
+ if (params.toString()) {
133
+ url += `?${params.toString()}`;
134
+ }
135
+
136
+ const response = await fetch(url, {
108
137
  method: 'POST',
109
138
  headers,
110
- body: JSON.stringify(config)
111
139
  });
112
140
 
113
141
  if (!response.ok) {
@@ -115,8 +143,10 @@ export class VocaClient {
115
143
  throw new Error(error.message || 'Failed to create room');
116
144
  }
117
145
 
118
- const { room } = await response.json();
119
- return new VocaClient(room, config);
146
+ const { room, password } = await response.json();
147
+ // Use the password from response (in case server modified it) or from config
148
+ const roomConfig = { ...config, password: password || config.password };
149
+ return new VocaClient(room, roomConfig);
120
150
  }
121
151
 
122
152
  /**
@@ -274,9 +304,20 @@ export class VocaClient {
274
304
  // Build URL: base + path + query params
275
305
  let fullUrl = `${wsUrl}/ws/${this.roomId}`;
276
306
 
307
+ const params = new URLSearchParams();
308
+
277
309
  // Append apiKey if present
278
310
  if (this.config.apiKey) {
279
- fullUrl += `?apiKey=${encodeURIComponent(this.config.apiKey)}`;
311
+ params.append('apiKey', this.config.apiKey);
312
+ }
313
+
314
+ // Append password if present
315
+ if (this.config.password) {
316
+ params.append('password', this.config.password);
317
+ }
318
+
319
+ if (params.toString()) {
320
+ fullUrl += `?${params.toString()}`;
280
321
  }
281
322
 
282
323
  return fullUrl;
@@ -289,7 +330,7 @@ export class VocaClient {
289
330
 
290
331
  this.ws.onopen = () => {
291
332
  // Send hello message with version info
292
- this.send({ type: 'hello', version: '1.0', client: '@treyorr/voca-client' });
333
+ this.send({ type: 'hello', version: '0.3.0', client: '@treyorr/voca-client' });
293
334
  this.status = 'connected';
294
335
  this.events.emit('status', 'connected');
295
336
  // Reset reconnect attempts on successful connection
@@ -360,7 +401,7 @@ export class VocaClient {
360
401
  }
361
402
  break;
362
403
  case 'ping':
363
- this.ws?.send(JSON.stringify({ from: '', type: 'pong' }));
404
+ this.send({ type: 'pong' });
364
405
  break;
365
406
  case 'leave':
366
407
  this.removePeer(msg.from);
@@ -457,9 +498,11 @@ export class VocaClient {
457
498
  }
458
499
 
459
500
  private send(msg: Partial<SignalMessage>) {
460
- if (this.ws?.readyState === WebSocket.OPEN) {
461
- this.ws.send(JSON.stringify({ from: '', ...msg }));
501
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
502
+ // Queue or drop - for now we drop since this is a signaling race condition
503
+ return;
462
504
  }
505
+ this.ws.send(JSON.stringify({ from: '', ...msg }));
463
506
  }
464
507
 
465
508
  private handleError(code: VocaErrorCode | string, message: string) {