@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 +4 -0
- package/dist/errors.d.ts +2 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +4 -4
- package/package.json +2 -2
- package/src/__tests__/client.test.ts +96 -2
- package/src/errors.ts +6 -0
- package/src/index.ts +101 -11
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=
|
|
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": "AAAO,IAAI,EAAmB,KAAO,CACnC,IAAI,CAAC,KAAU,EAAM,CACnB,QACM,EAAY,KAAK,OAAO,IAAU,CAAC,EACrC,EAAI,EACJ,EAAS,EAAU,OACrB,EAAI,EACJ,IAEA,EAAU,GAAG,GAAG,CAAI,GAGxB,OAAQ,CAAC,EACT,EAAE,CAAC,EAAO,EAAI,CAEZ,OADE,KAAK,OAAO,KAAW,CAAC,GAAG,KAAK,CAAE,EAC7B,IAAM,CACX,KAAK,OAAO,GAAS,KAAK,OAAO,IAAQ,OAAO,KAAK,IAAO,CAAC,GAGnE,GCbO,IAAM,EAAgB,CAEzB,eAAgB,iBAChB,UAAW,YACX,kBAAmB,oBACnB,gBAAiB,kBAGjB,kBAAmB,oBACnB,gBAAiB,kBACjB,kBAAmB,oBAGnB,qBAAsB,uBACtB,6BAA8B,+BAC9B,iBAAkB,mBAGlB,gBAAiB,kBACjB,eAAgB,
|
|
10
|
-
"debugId": "
|
|
9
|
+
"mappings": "AAAO,IAAI,EAAmB,KAAO,CACnC,IAAI,CAAC,KAAU,EAAM,CACnB,QACM,EAAY,KAAK,OAAO,IAAU,CAAC,EACrC,EAAI,EACJ,EAAS,EAAU,OACrB,EAAI,EACJ,IAEA,EAAU,GAAG,GAAG,CAAI,GAGxB,OAAQ,CAAC,EACT,EAAE,CAAC,EAAO,EAAI,CAEZ,OADE,KAAK,OAAO,KAAW,CAAC,GAAG,KAAK,CAAE,EAC7B,IAAM,CACX,KAAK,OAAO,GAAS,KAAK,OAAO,IAAQ,OAAO,KAAK,IAAO,CAAC,GAGnE,GCbO,IAAM,EAAgB,CAEzB,eAAgB,iBAChB,UAAW,YACX,kBAAmB,oBACnB,gBAAiB,kBAGjB,kBAAmB,oBACnB,gBAAiB,kBACjB,kBAAmB,oBAGnB,qBAAsB,uBACtB,6BAA8B,+BAC9B,iBAAkB,mBAGlB,gBAAiB,kBACjB,eAAgB,iBAGhB,iBAAkB,mBAClB,kBAAmB,mBACvB,EAOa,EAAmD,EAC3D,EAAc,gBAAiB,kBAC/B,EAAc,WAAY,+BAC1B,EAAc,mBAAoB,mCAClC,EAAc,iBAAkB,0BAChC,EAAc,mBAAoB,yCAClC,EAAc,iBAAkB,8BAChC,EAAc,mBAAoB,4CAClC,EAAc,sBAAuB,mEACrC,EAAc,8BAA+B,iEAC7C,EAAc,kBAAmB,2CACjC,EAAc,iBAAkB,sCAChC,EAAc,gBAAiB,0BAC/B,EAAc,kBAAmB,sBACjC,EAAc,mBAAoB,+BACvC,EAaO,SAAS,CAAe,CAAC,EAAqB,EAAmC,CACpF,MAAO,CACH,OACA,QAAS,GAAiB,EAAkB,EAChD,ECAG,SAAS,CAAgB,CAAC,EAAiC,CAC9D,GAAI,CAAC,EAAU,OAAO,KACtB,GAAI,EAAS,OAAS,GAAK,EAAS,OAAS,GACzC,MAAO,mCAEX,GAAI,CAAC,iBAAiB,KAAK,CAAQ,EAC/B,MAAO,iDAEX,OAAO,KAGX,IAAM,EAAsC,CACxC,CAAE,KAAM,8BAA+B,EACvC,CAAE,KAAM,+BAAgC,CAC5C,EAEO,MAAM,CAAW,CACb,MAA2B,IAAI,IAC/B,YAAkC,KAClC,QAAmB,GACnB,OAA2B,aAC3B,OAEC,OAAS,EAA6B,EACtC,GAAuB,KACvB,aAAoC,KACpC,SAAgC,KAChC,eAAgC,KAChC,WAA6B,EAC7B,OAGA,kBAAoB,EACpB,iBAAyD,KACzD,gBAAkB,GAGlB,cAAiH,IAAI,gBAShH,WAAU,CAAC,EAAqB,CAAC,EAAwB,CAClE,IAAM,EAAU,EAAW,WAAW,EAAO,SAAS,EAEtD,GAAI,CAAC,EACD,MAAU,MAAM,8DAA8D,EAGlF,IAAM,EAAuB,CACzB,eAAgB,kBACpB,EAEA,GAAI,EAAO,OACP,EAAQ,aAAe,EAAO,OAIlC,IAAI,EAAM,GAAG,aACP,EAAS,IAAI,gBACnB,GAAI,EAAO,SACP,EAAO,OAAO,WAAY,EAAO,QAAQ,EAE7C,GAAI,EAAO,SAAS,EAChB,GAAO,IAAI,EAAO,SAAS,IAG/B,IAAM,EAAW,MAAM,MAAM,EAAK,CAC9B,OAAQ,OACR,SACJ,CAAC,EAED,GAAI,CAAC,EAAS,GAAI,CACd,IAAM,EAAQ,MAAM,EAAS,KAAK,EAAE,MAAM,KAAO,CAAE,MAAO,UAAW,QAAS,uBAAwB,EAAE,EACxG,MAAU,MAAM,EAAM,SAAW,uBAAuB,EAG5D,IAAQ,OAAM,YAAa,MAAM,EAAS,KAAK,EAEzC,EAAa,IAAK,EAAQ,SAAU,GAAY,EAAO,QAAS,EACtE,OAAO,IAAI,EAAW,EAAM,CAAU,QAQ3B,WAAU,CAAC,EAA4B,CAClD,GAAI,CAAC,EAAW,CACZ,GAAI,OAAO,OAAW,IAClB,MAAO,GAAG,OAAO,SAAS,aAAa,OAAO,SAAS,OAE3D,MAAO,GAGX,GAAI,EAAU,WAAW,QAAQ,EAAG,OAAO,EAAU,QAAQ,SAAU,UAAU,EACjF,GAAI,EAAU,WAAW,OAAO,EAAG,OAAO,EAAU,QAAQ,QAAS,SAAS,EAC9E,OAAO,QAQI,SAAQ,CAAC,EAA4B,CAChD,GAAI,CAAC,EAAW,CACZ,GAAI,OAAO,OAAW,IAElB,MAAO,GADU,OAAO,SAAS,WAAa,SAAW,OAAS,UAC3C,OAAO,SAAS,OAE3C,MAAU,MAAM,8DAA8D,EAGlF,GAAI,EAAU,WAAW,UAAU,EAAG,OAAO,EAAU,QAAQ,WAAY,QAAQ,EACnF,GAAI,EAAU,WAAW,SAAS,EAAG,OAAO,EAAU,QAAQ,UAAW,OAAO,EAChF,OAAO,EAGX,WAAW,CAAC,EAAgB,EAAqB,CAAC,EAAG,CAGjD,GAFA,KAAK,OAAS,EACd,KAAK,OAAS,EACV,EAAO,WAAY,KAAK,WAAa,EAAO,WAG7C,EAA8B,CAAC,EAAU,EAAyB,CACrE,OAAO,KAAK,OAAO,GAAG,EAAO,CAAQ,OAG5B,QAAO,EAAkB,CAClC,KAAK,OAAS,aACd,KAAK,OAAO,KAAK,SAAU,YAAY,EAEvC,GAAI,CAEA,MAAM,KAAK,mBAAmB,EAC9B,KAAK,cAAc,EACrB,MAAO,EAAK,CAEV,MADA,KAAK,YAAY,EAAc,kBAAmB,aAAe,MAAQ,EAAI,QAAU,mBAAmB,EACpG,GAIP,UAAU,EAAG,CAGhB,GADA,KAAK,gBAAkB,GACnB,KAAK,iBACL,aAAa,KAAK,gBAAgB,EAClC,KAAK,iBAAmB,KAG5B,GAAI,KAAK,eAAgB,qBAAqB,KAAK,cAAc,EAKjE,GAJA,KAAK,MAAM,QAAQ,CAAC,IAAM,EAAE,WAAW,MAAM,CAAC,EAC9C,KAAK,MAAM,MAAM,EACjB,KAAK,IAAI,MAAM,EACf,KAAK,aAAa,UAAU,EAAE,QAAQ,CAAC,IAAM,EAAE,KAAK,CAAC,EACjD,KAAK,cAAgB,KAAK,aAAa,QAAU,SACjD,KAAK,aAAa,MAAM,EAAE,MAAM,KAAK,QAAQ,KAAK,6BAA8B,CAAC,CAAC,EAEtF,KAAK,OAAS,eACd,KAAK,OAAO,KAAK,SAAU,cAAc,EAGtC,UAAU,EAAG,CAChB,IAAM,EAAQ,KAAK,aAAa,eAAe,EAAE,GACjD,GAAI,EACA,EAAM,QAAU,CAAC,EAAM,QACvB,KAAK,QAAU,CAAC,EAAM,QAEtB,KAAK,KAAK,CAAE,KAAM,OAAQ,MAAO,KAAK,OAAQ,CAAC,EAEnD,OAAO,KAAK,QAGT,cAAc,CAAC,EAAgB,CAClC,IAAM,EAAO,KAAK,MAAM,IAAI,CAAM,EAClC,GAAI,CAAC,EAAM,MAAO,GAGlB,IAAM,EAAgB,EADG,EAAK,YAAc,IAE5C,EAAK,WAAa,EAGlB,IAAM,EAAa,KAAK,cAAc,IAAI,CAAM,EAChD,GAAI,EAEA,EAAW,SAAS,KAAK,MAAQ,EAAgB,EAAI,EAIzD,OADA,KAAK,OAAO,KAAK,kBAAmB,EAAQ,CAAa,EAClD,OAKG,mBAAkB,EAAG,CAE/B,GAAI,OAAO,OAAW,KAAe,CAAC,OAAO,gBACzC,MAAU,MAAM,EAAkB,EAAc,iBAAiB,EAGrE,GAAI,CACA,KAAK,YAAc,MAAM,UAAU,aAAa,aAAa,CACzD,MAAO,CACH,iBAAkB,GAClB,iBAAkB,GAClB,gBAAiB,GACjB,QAAS,EACT,aAAc,CAClB,EACA,MAAO,EACX,CAAC,EACH,MAAO,EAAK,CACV,GAAI,CACA,KAAK,YAAc,MAAM,UAAU,aAAa,aAAa,CAAE,MAAO,GAAM,MAAO,EAAM,CAAC,EAC5F,MAAO,EAAe,CACpB,GAAI,EAAS,OAAS,iBAAmB,EAAS,QAAQ,SAAS,4BAA4B,EAC3F,MAAU,MAAM,EAAkB,EAAc,qBAAqB,EAEzE,GAAI,EAAS,OAAS,mBAAqB,EAAS,QAAQ,SAAS,mBAAmB,EACpF,MAAU,MAAM,EAAkB,EAAc,6BAA6B,EAEjF,MAAM,GAId,KAAK,mBAAmB,KAAK,WAAY,EAGrC,kBAAkB,CAAC,EAAqB,CAC5C,IAAM,EAAoB,OAAO,cAAiB,OAAe,mBACjE,KAAK,aAAe,IAAI,EACxB,IAAM,EAAS,KAAK,aAAa,wBAAwB,CAAM,EAC/D,KAAK,SAAW,KAAK,aAAa,eAAe,EACjD,KAAK,SAAS,QAAU,IACxB,EAAO,QAAQ,KAAK,QAAQ,EAE5B,IAAM,EAAY,IAAI,WAAW,KAAK,SAAS,iBAAiB,EAC1D,EAAS,IAAM,CACjB,GAAI,CAAC,KAAK,SAAU,OACpB,KAAK,SAAS,qBAAqB,CAAS,EAC5C,IAAM,EAAM,EAAU,OAAO,CAAC,EAAG,IAAM,EAAI,EAAG,CAAC,EAAI,EAAU,OACvD,EAAQ,KAAK,IAAI,EAAM,IAAK,CAAC,EACnC,KAAK,OAAO,KAAK,oBAAqB,CAAK,EAC3C,KAAK,eAAiB,sBAAsB,CAAM,GAEtD,EAAO,EAMH,YAAY,EAAW,CAI3B,IAAI,EAAU,GAHA,EAAW,SAAS,KAAK,OAAO,SAAS,QAG1B,KAAK,SAE5B,EAAS,IAAI,gBAGnB,GAAI,KAAK,OAAO,OACZ,EAAO,OAAO,SAAU,KAAK,OAAO,MAAM,EAI9C,GAAI,KAAK,OAAO,SACZ,EAAO,OAAO,WAAY,KAAK,OAAO,QAAQ,EAGlD,GAAI,EAAO,SAAS,EAChB,GAAW,IAAI,EAAO,SAAS,IAGnC,OAAO,EAGH,aAAa,EAAG,CACpB,IAAM,EAAY,KAAK,aAAa,EAEpC,KAAK,GAAK,IAAI,UAAU,CAAS,EAEjC,KAAK,GAAG,OAAS,IAAM,CAEnB,KAAK,KAAK,CAAE,KAAM,QAAS,QAAS,QAAS,OAAQ,sBAAuB,CAAC,EAC7E,KAAK,OAAS,YACd,KAAK,OAAO,KAAK,SAAU,WAAW,EAEtC,KAAK,kBAAoB,GAG7B,KAAK,GAAG,QAAU,IAAM,CAEpB,GAAI,KAAK,SAAW,QAAU,KAAK,SAAW,QAC1C,OAGJ,IAAM,EAAmB,KAAK,OAAO,WAAW,UAAY,GACtD,EAAc,KAAK,OAAO,WAAW,aAAe,EACpD,EAAY,KAAK,OAAO,WAAW,aAAe,KAExD,GAAI,GAAoB,KAAK,iBAAmB,KAAK,kBAAoB,EAAa,CAClF,KAAK,OAAS,eACd,KAAK,OAAO,KAAK,SAAU,cAAc,EAGzC,IAAM,EAAQ,KAAK,IAAI,EAAY,KAAK,IAAI,EAAG,KAAK,iBAAiB,EAAG,KAAK,EAC7E,KAAK,oBAEL,KAAK,iBAAmB,WAAW,IAAM,CACrC,KAAK,cAAc,GACpB,CAAK,EAER,UAAK,OAAS,eACd,KAAK,OAAO,KAAK,SAAU,cAAc,GAIjD,KAAK,GAAG,QAAU,IAAM,KAAK,YAAY,EAAc,gBAAiB,6BAA6B,EAErG,KAAK,GAAG,UAAY,CAAC,IAAM,CACvB,IAAM,EAAqB,KAAK,MAAM,EAAE,IAAI,EAC5C,KAAK,aAAa,CAAG,QAIf,aAAY,CAAC,EAAoB,CAC3C,OAAQ,EAAI,UACH,UAED,QAAQ,MAAM,2BAA4B,EAAI,QAAS,WAAY,EAAI,OAAO,EAC9E,UACC,OAED,GADA,MAAM,KAAK,WAAW,EAAI,KAAM,EAAI,EAChC,KAAK,QAEL,KAAK,KAAK,CAAE,KAAM,OAAQ,GAAI,EAAI,KAAM,MAAO,EAAK,CAAC,EAEzD,UACC,QACD,MAAM,KAAK,WAAW,EAAI,KAAM,GAAO,EAAI,GAAG,EAC9C,UACC,SACD,IAAM,EAAO,KAAK,MAAM,IAAI,EAAI,IAAI,EACpC,GAAI,EACA,MAAM,EAAK,WAAW,qBAAqB,CAAE,KAAM,SAAU,IAAK,EAAI,GAAK,CAAC,EAEhF,UACC,MACD,IAAM,EAAI,KAAK,MAAM,IAAI,EAAI,IAAI,EACjC,GAAI,EACA,GAAI,CACA,MAAM,EAAE,WAAW,gBAAgB,KAAK,MAAM,EAAI,SAAU,CAAC,EAC/D,MAAO,EAAG,CACR,QAAQ,KAAK,gCAAiC,CAAC,EAGvD,UACC,OACD,KAAK,KAAK,CAAE,KAAM,MAAO,CAAC,EAC1B,UACC,QACD,KAAK,WAAW,EAAI,IAAI,EACxB,UACC,OACD,IAAM,EAAW,KAAK,MAAM,IAAI,EAAI,IAAI,EACxC,GAAI,EACA,EAAS,YAAc,EAAI,OAAS,GACpC,KAAK,OAAO,KAAK,YAAa,EAAI,KAAM,EAAS,WAAW,EAEhE,UACC,QACD,KAAK,YAAY,EAAI,MAAQ,UAAW,EAAI,SAAW,eAAe,EACtE,YAIE,WAAU,CAAC,EAAgB,EAAsB,EAAoB,CAC/E,IAAM,EAAK,IAAI,kBAAkB,CAAE,WAAY,KAAK,UAAW,CAAC,EAoBhE,GAlBA,EAAG,eAAiB,CAAC,IAAM,CACvB,GAAI,EAAE,UAAW,KAAK,KAAK,CAAE,KAAM,MAAO,GAAI,EAAQ,UAAW,KAAK,UAAU,EAAE,SAAS,CAAE,CAAC,GAGlG,EAAG,QAAU,CAAC,IAAM,CAEhB,KAAK,OAAO,KAAK,QAAS,EAAQ,EAAE,MAAO,EAAE,QAAQ,EAAE,EACvD,KAAK,iBAAiB,EAAQ,EAAE,QAAQ,EAAE,EAC1C,IAAM,EAAO,KAAK,MAAM,IAAI,CAAM,EAClC,GAAI,EAAM,EAAK,OAAS,EAAE,QAAQ,IAItC,KAAK,aAAa,UAAU,EAAE,QAAQ,CAAC,IAAU,EAAG,SAAS,EAAO,KAAK,WAAY,CAAC,EAEtF,KAAK,MAAM,IAAI,EAAQ,CAAE,GAAI,EAAQ,WAAY,EAAI,WAAY,EAAG,YAAa,GAAO,WAAY,EAAM,CAAC,EAC3G,KAAK,OAAO,KAAK,cAAe,CAAM,EAElC,EAAa,CACb,IAAM,EAAQ,MAAM,EAAG,YAAY,EACnC,MAAM,EAAG,oBAAoB,CAAK,EAClC,KAAK,KAAK,CAAE,KAAM,QAAS,GAAI,EAAQ,IAAK,EAAM,GAAI,CAAC,EACpD,QAAI,EAAW,CAClB,MAAM,EAAG,qBAAqB,CAAE,KAAM,QAAS,IAAK,CAAU,CAAC,EAC/D,IAAM,EAAS,MAAM,EAAG,aAAa,EACrC,MAAM,EAAG,oBAAoB,CAAM,EACnC,KAAK,KAAK,CAAE,KAAM,SAAU,GAAI,EAAQ,IAAK,EAAO,GAAI,CAAC,GAIzD,UAAU,CAAC,EAAgB,CAClB,KAAK,MAAM,IAAI,CAAM,GAC5B,WAAW,MAAM,EACvB,KAAK,MAAM,OAAO,CAAM,EAGxB,IAAM,EAAQ,KAAK,cAAc,IAAI,CAAM,EAC3C,GAAI,EACA,EAAM,OAAO,WAAW,EACxB,EAAM,SAAS,WAAW,EAC1B,EAAM,SAAS,WAAW,EAC1B,KAAK,cAAc,OAAO,CAAM,EAGpC,KAAK,OAAO,KAAK,YAAa,CAAM,EAGhC,gBAAgB,CAAC,EAAgB,EAAqB,CAC1D,GAAI,CAAC,KAAK,aAAc,CACpB,IAAM,EAAoB,OAAO,cAAiB,OAAe,mBACjE,KAAK,aAAe,IAAI,EAG5B,IAAM,EAAS,KAAK,aAAa,wBAAwB,CAAM,EACzD,EAAW,KAAK,aAAa,eAAe,EAClD,EAAS,QAAU,IAGnB,IAAM,EAAW,KAAK,aAAa,WAAW,EAI9C,GADa,KAAK,MAAM,IAAI,CAAM,GACxB,WACN,EAAS,KAAK,MAAQ,EAG1B,EAAO,QAAQ,CAAQ,EACvB,EAAS,QAAQ,CAAQ,EAGzB,EAAS,QAAQ,KAAK,aAAa,WAAW,EAG9C,KAAK,cAAc,IAAI,EAAQ,CAAE,SAAQ,WAAU,UAAS,CAAC,EAE7D,IAAM,EAAO,IAAI,WAAW,EAAS,iBAAiB,EAChD,EAAS,IAAM,CACjB,IAAM,EAAO,KAAK,MAAM,IAAI,CAAM,EAClC,GAAI,CAAC,EAAM,OAEX,EAAS,qBAAqB,CAAI,EAClC,IAAM,EAAM,EAAK,OAAO,CAAC,EAAG,IAAM,EAAI,EAAG,CAAC,EAAI,EAAK,OAC7C,EAAQ,KAAK,IAAI,EAAM,IAAK,CAAC,EAEnC,KAAK,OAAO,KAAK,mBAAoB,EAAQ,CAAK,EAGlD,EAAK,WAAa,EAElB,sBAAsB,CAAM,GAEhC,EAAO,EAGH,IAAI,CAAC,EAA6B,CACtC,GAAI,CAAC,KAAK,IAAM,KAAK,GAAG,aAAe,UAAU,KAE7C,OAEJ,KAAK,GAAG,KAAK,KAAK,UAAU,CAAE,KAAM,MAAO,CAAI,CAAC,CAAC,EAG7C,WAAW,CAAC,EAA8B,EAAiB,CAC/D,KAAK,OAAS,IAAS,EAAc,WAAa,IAAS,YAAc,OAAS,QAClF,KAAK,OAAO,KAAK,SAAU,KAAK,MAAM,EACtC,KAAK,OAAO,KAAK,QAAS,CAAE,KAAM,EAAuB,SAAQ,CAAC,EAE1E",
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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 = () => {
|