@treyorr/voca-client 0.2.1 → 0.4.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
  */
@@ -23,6 +24,8 @@ export interface Peer {
23
24
  connection: RTCPeerConnection;
24
25
  audioLevel: number;
25
26
  stream?: MediaStream;
27
+ remoteMuted?: boolean;
28
+ localMuted?: boolean;
26
29
  }
27
30
  interface VocaEvents {
28
31
  'status': (status: ConnectionStatus) => void;
@@ -36,7 +39,17 @@ interface VocaEvents {
36
39
  'peer-audio-level': (peerId: string, level: number) => void;
37
40
  'local-audio-level': (level: number) => void;
38
41
  'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
42
+ 'peer-mute': (peerId: string, isMuted: boolean) => void;
43
+ 'peer-local-mute': (peerId: string, isMuted: boolean) => void;
39
44
  }
45
+ /**
46
+ * Validate password format for room creation.
47
+ * Returns null if valid, or an error message if invalid.
48
+ *
49
+ * @param password - The password to validate
50
+ * @returns Error message if invalid, null if valid
51
+ */
52
+ export declare function validatePassword(password: string): string | null;
40
53
  export declare class VocaClient {
41
54
  peers: Map<string, Peer>;
42
55
  localStream: MediaStream | null;
@@ -79,6 +92,7 @@ export declare class VocaClient {
79
92
  connect(): Promise<void>;
80
93
  disconnect(): void;
81
94
  toggleMute(): boolean;
95
+ togglePeerMute(peerId: string): boolean;
82
96
  private setupMediaAndAudio;
83
97
  private setupAudioAnalysis;
84
98
  /**
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- var W=()=>({emit(h,...j){for(let B=this.events[h]||[],q=0,z=B.length;q<z;q++)B[q](...j)},events:{},on(h,j){return(this.events[h]||=[]).push(j),()=>{this.events[h]=this.events[h]?.filter((B)=>j!==B)}}});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"},K={[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"};function Z(h,j){return{code:h,message:j??K[h]}}var $=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class O{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 j=O.getHttpUrl(h.serverUrl);if(!j)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 q=await fetch(`${j}/api/room`,{method:"POST",headers:B,body:JSON.stringify(h)});if(!q.ok){let H=await q.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(H.message||"Failed to create room")}let{room:z}=await q.json();return new O(z,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,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(K[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(K[G.MICROPHONE_NOT_FOUND]);if(j.name==="NotAllowedError"||j.message.includes("Permission denied"))throw Error(K[G.MICROPHONE_PERMISSION_DENIED]);throw j}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let j=window.AudioContext||window.webkitAudioContext;this.audioContext=new j;let B=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,B.connect(this.analyser);let q=new Uint8Array(this.analyser.frequencyBinCount),z=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(q);let H=q.reduce((P,N)=>P+N,0)/q.length,J=Math.min(H/128,1);this.events.emit("local-audio-level",J),this.animationFrame=requestAnimationFrame(z)};z()}getSocketUrl(){let j=`${O.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`;if(this.config.apiKey)j+=`?apiKey=${encodeURIComponent(this.config.apiKey)}`;return j}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 j=this.config.reconnect?.enabled!==!1,B=this.config.reconnect?.maxAttempts??5,q=this.config.reconnect?.baseDelayMs??1000;if(j&&this.shouldReconnect&&this.reconnectAttempts<B){this.status="reconnecting",this.events.emit("status","reconnecting");let z=Math.min(q*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},z)}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 B=JSON.parse(j.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 j=this.peers.get(h.from);if(j)await j.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(q){console.warn("[Voca] Invalid ICE candidate:",q)}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,B){let q=new RTCPeerConnection({iceServers:this.iceServers});if(q.onicecandidate=(z)=>{if(z.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(z.candidate)})},q.ontrack=(z)=>{this.events.emit("track",h,z.track,z.streams[0]),this.setupRemoteAudio(h,z.streams[0]);let H=this.peers.get(h);if(H)H.stream=z.streams[0]},this.localStream?.getTracks().forEach((z)=>q.addTrack(z,this.localStream)),this.peers.set(h,{id:h,connection:q,audioLevel:0}),this.events.emit("peer-joined",h),j){let z=await q.createOffer();await q.setLocalDescription(z),this.send({type:"offer",to:h,sdp:z.sdp})}else if(B){await q.setRemoteDescription({type:"offer",sdp:B});let z=await q.createAnswer();await q.setLocalDescription(z),this.send({type:"answer",to:h,sdp:z.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,j){if(!this.audioContext){let J=window.AudioContext||window.webkitAudioContext;this.audioContext=new J}let B=this.audioContext.createMediaStreamSource(j),q=this.audioContext.createAnalyser();q.fftSize=256,B.connect(q),B.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:B,analyser:q});let z=new Uint8Array(q.frequencyBinCount),H=()=>{let J=this.peers.get(h);if(!J)return;q.getByteFrequencyData(z);let P=z.reduce((X,Y)=>X+Y,0)/z.length,N=Math.min(P/128,1);this.events.emit("peer-audio-level",h,N),J.audioLevel=N,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{Z as createVocaError,K as VocaErrorMessages,G as VocaErrorCode,O as VocaClient};
1
+ var $=()=>({emit(h,...j){for(let B=this.events[h]||[],q=0,z=B.length;q<z;q++)B[q](...j)},events:{},on(h,j){return(this.events[h]||=[]).push(j),()=>{this.events[h]=this.events[h]?.filter((B)=>j!==B)}}});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"},W={[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 Q(h,j){return{code:h,message:j??W[h]}}function N(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 T=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class X{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=$();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=T;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let j=X.getHttpUrl(h.serverUrl);if(!j)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 q=`${j}/api/room`,z=new URLSearchParams;if(h.password)z.append("password",h.password);if(z.toString())q+=`?${z.toString()}`;let H=await fetch(q,{method:"POST",headers:B});if(!H.ok){let Y=await H.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(Y.message||"Failed to create room")}let{room:K,password:O}=await H.json(),J={...h,password:O||h.password};return new X(K,J)}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,this.send({type:"mute",muted:this.isMuted});return this.isMuted}togglePeerMute(h){let j=this.peers.get(h);if(!j)return!1;let q=!(j.localMuted??!1);j.localMuted=q;let z=this.peerAnalysers.get(h);if(z)z.gainNode.gain.value=q?0:1;return this.events.emit("peer-local-mute",h,q),q}async setupMediaAndAudio(){if(typeof window<"u"&&!window.isSecureContext)throw Error(W[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(W[G.MICROPHONE_NOT_FOUND]);if(j.name==="NotAllowedError"||j.message.includes("Permission denied"))throw Error(W[G.MICROPHONE_PERMISSION_DENIED]);throw j}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let j=window.AudioContext||window.webkitAudioContext;this.audioContext=new j;let B=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,B.connect(this.analyser);let q=new Uint8Array(this.analyser.frequencyBinCount),z=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(q);let H=q.reduce((O,J)=>O+J,0)/q.length,K=Math.min(H/128,1);this.events.emit("local-audio-level",K),this.animationFrame=requestAnimationFrame(z)};z()}getSocketUrl(){let j=`${X.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`,B=new URLSearchParams;if(this.config.apiKey)B.append("apiKey",this.config.apiKey);if(this.config.password)B.append("password",this.config.password);if(B.toString())j+=`?${B.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,B=this.config.reconnect?.maxAttempts??5,q=this.config.reconnect?.baseDelayMs??1000;if(j&&this.shouldReconnect&&this.reconnectAttempts<B){this.status="reconnecting",this.events.emit("status","reconnecting");let z=Math.min(q*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},z)}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 B=JSON.parse(j.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":if(await this.createPeer(h.from,!0),this.isMuted)this.send({type:"mute",to:h.from,muted:!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 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.send({type:"pong"});break;case"leave":this.removePeer(h.from);break;case"mute":let q=this.peers.get(h.from);if(q)q.remoteMuted=h.muted??!1,this.events.emit("peer-mute",h.from,q.remoteMuted);break;case"error":this.handleError(h.code??"unknown",h.message??"Unknown error");break}}async createPeer(h,j,B){let q=new RTCPeerConnection({iceServers:this.iceServers});if(q.onicecandidate=(z)=>{if(z.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(z.candidate)})},q.ontrack=(z)=>{this.events.emit("track",h,z.track,z.streams[0]),this.setupRemoteAudio(h,z.streams[0]);let H=this.peers.get(h);if(H)H.stream=z.streams[0]},this.localStream?.getTracks().forEach((z)=>q.addTrack(z,this.localStream)),this.peers.set(h,{id:h,connection:q,audioLevel:0,remoteMuted:!1,localMuted:!1}),this.events.emit("peer-joined",h),j){let z=await q.createOffer();await q.setLocalDescription(z),this.send({type:"offer",to:h,sdp:z.sdp})}else if(B){await q.setRemoteDescription({type:"offer",sdp:B});let z=await q.createAnswer();await q.setLocalDescription(z),this.send({type:"answer",to:h,sdp:z.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(),B.gainNode.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 B=this.audioContext.createMediaStreamSource(j),q=this.audioContext.createAnalyser();q.fftSize=256;let z=this.audioContext.createGain();if(this.peers.get(h)?.localMuted)z.gain.value=0;B.connect(q),q.connect(z),z.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:B,analyser:q,gainNode:z});let K=new Uint8Array(q.frequencyBinCount),O=()=>{let J=this.peers.get(h);if(!J)return;q.getByteFrequencyData(K);let Y=K.reduce((F,L)=>F+L,0)/K.length,Z=Math.min(Y/128,1);this.events.emit("peer-audio-level",h,Z),J.audioLevel=Z,requestAnimationFrame(O)};O()}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{N as validatePassword,Q as createVocaError,W as VocaErrorMessages,G as VocaErrorCode,X as VocaClient};
2
2
 
3
- //# debugId=9F9AE64B14E4D4DE64756E2164756E21
3
+ //# debugId=5284B6158D19334E64756E2164756E21
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.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"
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 remoteMuted?: boolean;\n localMuted?: boolean;\n}\n\ntype SignalMessage = {\n from: string;\n type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error' | 'mute';\n peer_id?: string;\n to?: string;\n sdp?: string;\n candidate?: string;\n code?: string;\n message?: string;\n muted?: boolean;\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 'peer-mute': (peerId: string, isMuted: boolean) => void;\n 'peer-local-mute': (peerId: string, isMuted: boolean) => 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; gainNode: GainNode }> = 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 // Broadcast our mute state to everyone else\n this.send({ type: 'mute', muted: this.isMuted });\n }\n return this.isMuted;\n }\n\n public togglePeerMute(peerId: string) {\n const peer = this.peers.get(peerId);\n if (!peer) return false;\n\n const isCurrentlyMuted = peer.localMuted ?? false;\n const newMutedState = !isCurrentlyMuted;\n peer.localMuted = newMutedState;\n\n // Update the gain node\n const audioNodes = this.peerAnalysers.get(peerId);\n if (audioNodes) {\n // Mute by dropping gain to 0, unmute by setting back to 1\n audioNodes.gainNode.gain.value = newMutedState ? 0 : 1;\n }\n\n this.events.emit('peer-local-mute', peerId, newMutedState);\n return newMutedState;\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 if (this.isMuted) {\n // Send our mute state specifically to the joined peer\n this.send({ type: 'mute', to: msg.from, muted: true });\n }\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 'mute':\n const mutePeer = this.peers.get(msg.from);\n if (mutePeer) {\n mutePeer.remoteMuted = msg.muted ?? false;\n this.events.emit('peer-mute', msg.from, mutePeer.remoteMuted);\n }\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, remoteMuted: false, localMuted: false });\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 audio.gainNode.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\n // Create a GainNode for local muting\n const gainNode = this.audioContext.createGain();\n\n // Check if the peer is already locally muted (in case they reconnect)\n const peer = this.peers.get(peerId);\n if (peer?.localMuted) {\n gainNode.gain.value = 0;\n }\n\n source.connect(analyser);\n analyser.connect(gainNode);\n\n // CRITICAL: Connect to speakers so audio is actually played!\n gainNode.connect(this.audioContext.destination);\n\n // Store for cleanup when peer leaves\n this.peerAnalysers.set(peerId, { source, analyser, gainNode });\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": "9F9AE64B14E4D4DE64756E2164756E21",
9
+ "mappings": "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",
10
+ "debugId": "5284B6158D19334E64756E2164756E21",
11
11
  "names": []
12
12
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@treyorr/voca-client",
3
- "version": "0.2.1",
4
- "description": "Core TypeScript SDK for Voca Signaling",
3
+ "version": "0.4.0",
4
+ "description": "Voca WebRTC Client SDK",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -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
  */
@@ -27,17 +28,20 @@ export interface Peer {
27
28
  connection: RTCPeerConnection;
28
29
  audioLevel: number;
29
30
  stream?: MediaStream;
31
+ remoteMuted?: boolean;
32
+ localMuted?: boolean;
30
33
  }
31
34
 
32
35
  type SignalMessage = {
33
36
  from: string;
34
- type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';
37
+ type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error' | 'mute';
35
38
  peer_id?: string;
36
39
  to?: string;
37
40
  sdp?: string;
38
41
  candidate?: string;
39
42
  code?: string;
40
43
  message?: string;
44
+ muted?: boolean;
41
45
  // Protocol versioning
42
46
  version?: string;
43
47
  client?: string;
@@ -52,6 +56,26 @@ interface VocaEvents {
52
56
  'peer-audio-level': (peerId: string, level: number) => void;
53
57
  'local-audio-level': (level: number) => void;
54
58
  'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
59
+ 'peer-mute': (peerId: string, isMuted: boolean) => void;
60
+ 'peer-local-mute': (peerId: string, isMuted: boolean) => void;
61
+ }
62
+
63
+ /**
64
+ * Validate password format for room creation.
65
+ * Returns null if valid, or an error message if invalid.
66
+ *
67
+ * @param password - The password to validate
68
+ * @returns Error message if invalid, null if valid
69
+ */
70
+ export function validatePassword(password: string): string | null {
71
+ if (!password) return null; // Empty is valid (no password)
72
+ if (password.length < 4 || password.length > 12) {
73
+ return 'Password must be 4-12 characters';
74
+ }
75
+ if (!/^[a-zA-Z0-9]+$/.test(password)) {
76
+ return 'Password must contain only letters and numbers';
77
+ }
78
+ return null;
55
79
  }
56
80
 
57
81
  const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
@@ -80,7 +104,7 @@ export class VocaClient {
80
104
  private shouldReconnect = true;
81
105
 
82
106
  // Audio analysis nodes per peer (for cleanup)
83
- private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();
107
+ private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode; gainNode: GainNode }> = new Map();
84
108
 
85
109
  /**
86
110
  * Create a new room and return a VocaClient connected to it.
@@ -104,10 +128,19 @@ export class VocaClient {
104
128
  headers['x-api-key'] = config.apiKey;
105
129
  }
106
130
 
107
- const response = await fetch(`${httpUrl}/api/room`, {
131
+ // Build URL with optional password query param
132
+ let url = `${httpUrl}/api/room`;
133
+ const params = new URLSearchParams();
134
+ if (config.password) {
135
+ params.append('password', config.password);
136
+ }
137
+ if (params.toString()) {
138
+ url += `?${params.toString()}`;
139
+ }
140
+
141
+ const response = await fetch(url, {
108
142
  method: 'POST',
109
143
  headers,
110
- body: JSON.stringify(config)
111
144
  });
112
145
 
113
146
  if (!response.ok) {
@@ -115,8 +148,10 @@ export class VocaClient {
115
148
  throw new Error(error.message || 'Failed to create room');
116
149
  }
117
150
 
118
- const { room } = await response.json();
119
- return new VocaClient(room, config);
151
+ const { room, password } = await response.json();
152
+ // Use the password from response (in case server modified it) or from config
153
+ const roomConfig = { ...config, password: password || config.password };
154
+ return new VocaClient(room, roomConfig);
120
155
  }
121
156
 
122
157
  /**
@@ -205,10 +240,31 @@ export class VocaClient {
205
240
  if (track) {
206
241
  track.enabled = !track.enabled;
207
242
  this.isMuted = !track.enabled;
243
+ // Broadcast our mute state to everyone else
244
+ this.send({ type: 'mute', muted: this.isMuted });
208
245
  }
209
246
  return this.isMuted;
210
247
  }
211
248
 
249
+ public togglePeerMute(peerId: string) {
250
+ const peer = this.peers.get(peerId);
251
+ if (!peer) return false;
252
+
253
+ const isCurrentlyMuted = peer.localMuted ?? false;
254
+ const newMutedState = !isCurrentlyMuted;
255
+ peer.localMuted = newMutedState;
256
+
257
+ // Update the gain node
258
+ const audioNodes = this.peerAnalysers.get(peerId);
259
+ if (audioNodes) {
260
+ // Mute by dropping gain to 0, unmute by setting back to 1
261
+ audioNodes.gainNode.gain.value = newMutedState ? 0 : 1;
262
+ }
263
+
264
+ this.events.emit('peer-local-mute', peerId, newMutedState);
265
+ return newMutedState;
266
+ }
267
+
212
268
 
213
269
 
214
270
  private async setupMediaAndAudio() {
@@ -274,9 +330,20 @@ export class VocaClient {
274
330
  // Build URL: base + path + query params
275
331
  let fullUrl = `${wsUrl}/ws/${this.roomId}`;
276
332
 
333
+ const params = new URLSearchParams();
334
+
277
335
  // Append apiKey if present
278
336
  if (this.config.apiKey) {
279
- fullUrl += `?apiKey=${encodeURIComponent(this.config.apiKey)}`;
337
+ params.append('apiKey', this.config.apiKey);
338
+ }
339
+
340
+ // Append password if present
341
+ if (this.config.password) {
342
+ params.append('password', this.config.password);
343
+ }
344
+
345
+ if (params.toString()) {
346
+ fullUrl += `?${params.toString()}`;
280
347
  }
281
348
 
282
349
  return fullUrl;
@@ -289,7 +356,7 @@ export class VocaClient {
289
356
 
290
357
  this.ws.onopen = () => {
291
358
  // Send hello message with version info
292
- this.send({ type: 'hello', version: '1.0', client: '@treyorr/voca-client' });
359
+ this.send({ type: 'hello', version: '0.3.0', client: '@treyorr/voca-client' });
293
360
  this.status = 'connected';
294
361
  this.events.emit('status', 'connected');
295
362
  // Reset reconnect attempts on successful connection
@@ -339,6 +406,10 @@ export class VocaClient {
339
406
  break;
340
407
  case 'join':
341
408
  await this.createPeer(msg.from, true);
409
+ if (this.isMuted) {
410
+ // Send our mute state specifically to the joined peer
411
+ this.send({ type: 'mute', to: msg.from, muted: true });
412
+ }
342
413
  break;
343
414
  case 'offer':
344
415
  await this.createPeer(msg.from, false, msg.sdp);
@@ -365,6 +436,13 @@ export class VocaClient {
365
436
  case 'leave':
366
437
  this.removePeer(msg.from);
367
438
  break;
439
+ case 'mute':
440
+ const mutePeer = this.peers.get(msg.from);
441
+ if (mutePeer) {
442
+ mutePeer.remoteMuted = msg.muted ?? false;
443
+ this.events.emit('peer-mute', msg.from, mutePeer.remoteMuted);
444
+ }
445
+ break;
368
446
  case 'error':
369
447
  this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');
370
448
  break;
@@ -389,7 +467,7 @@ export class VocaClient {
389
467
  // Add local tracks
390
468
  this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));
391
469
 
392
- this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });
470
+ this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0, remoteMuted: false, localMuted: false });
393
471
  this.events.emit('peer-joined', peerId);
394
472
 
395
473
  if (isInitiator) {
@@ -414,6 +492,7 @@ export class VocaClient {
414
492
  if (audio) {
415
493
  audio.source.disconnect();
416
494
  audio.analyser.disconnect();
495
+ audio.gainNode.disconnect();
417
496
  this.peerAnalysers.delete(peerId);
418
497
  }
419
498
 
@@ -429,13 +508,24 @@ export class VocaClient {
429
508
  const source = this.audioContext.createMediaStreamSource(stream);
430
509
  const analyser = this.audioContext.createAnalyser();
431
510
  analyser.fftSize = 256;
511
+
512
+ // Create a GainNode for local muting
513
+ const gainNode = this.audioContext.createGain();
514
+
515
+ // Check if the peer is already locally muted (in case they reconnect)
516
+ const peer = this.peers.get(peerId);
517
+ if (peer?.localMuted) {
518
+ gainNode.gain.value = 0;
519
+ }
520
+
432
521
  source.connect(analyser);
522
+ analyser.connect(gainNode);
433
523
 
434
524
  // CRITICAL: Connect to speakers so audio is actually played!
435
- source.connect(this.audioContext.destination);
525
+ gainNode.connect(this.audioContext.destination);
436
526
 
437
527
  // Store for cleanup when peer leaves
438
- this.peerAnalysers.set(peerId, { source, analyser });
528
+ this.peerAnalysers.set(peerId, { source, analyser, gainNode });
439
529
 
440
530
  const data = new Uint8Array(analyser.frequencyBinCount);
441
531
  const update = () => {