@treyorr/voca-client 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/errors.d.ts +2 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/__tests__/client.test.ts +96 -2
- package/src/errors.ts +6 -0
- package/src/index.ts +52 -9
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ import { VocaClient } from '@treyorr/voca-client';
|
|
|
26
26
|
const client = await VocaClient.createRoom({
|
|
27
27
|
serverUrl: 'https://voca.vc',
|
|
28
28
|
apiKey: 'your-api-key', // Get this from voca.vc/docs
|
|
29
|
+
password: 'secret123', // Optional: 4-12 alphanumeric chars
|
|
29
30
|
});
|
|
30
31
|
|
|
31
32
|
console.log('Share this room ID:', client.roomId);
|
|
@@ -40,6 +41,7 @@ await client.connect();
|
|
|
40
41
|
const client = new VocaClient('room-id-here', {
|
|
41
42
|
serverUrl: 'https://voca.vc',
|
|
42
43
|
apiKey: 'your-api-key',
|
|
44
|
+
password: 'secret123', // Required if room is password-protected
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
await client.connect();
|
|
@@ -51,6 +53,7 @@ await client.connect();
|
|
|
51
53
|
|--------|----------|-------------|
|
|
52
54
|
| `serverUrl` | **Yes** | Server URL (e.g., `https://voca.vc` or your self-hosted server) |
|
|
53
55
|
| `apiKey` | No* | API key for authentication (*required for voca.vc) |
|
|
56
|
+
| `password` | No | Room password (4-12 alphanumeric characters) |
|
|
54
57
|
| `reconnect.enabled` | No | Auto-reconnect on disconnect (default: `true`) |
|
|
55
58
|
| `reconnect.maxAttempts` | No | Max reconnection attempts (default: `5`) |
|
|
56
59
|
|
|
@@ -64,6 +67,7 @@ await client.connect();
|
|
|
64
67
|
| `disconnect()` | Leave room and cleanup |
|
|
65
68
|
| `toggleMute()` | Toggle mute, returns new state |
|
|
66
69
|
| `on(event, callback)` | Subscribe to events |
|
|
70
|
+
| `validatePassword(password)` | Validate password format, returns error or null |
|
|
67
71
|
|
|
68
72
|
## Events
|
|
69
73
|
|
package/dist/errors.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export declare const VocaErrorCode: {
|
|
|
16
16
|
readonly INSECURE_CONTEXT: "insecure_context";
|
|
17
17
|
readonly INVALID_MESSAGE: "invalid_message";
|
|
18
18
|
readonly PEER_NOT_FOUND: "peer_not_found";
|
|
19
|
+
readonly INVALID_PASSWORD: "invalid_password";
|
|
20
|
+
readonly PASSWORD_REQUIRED: "password_required";
|
|
19
21
|
};
|
|
20
22
|
export type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];
|
|
21
23
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface VocaConfig {
|
|
|
6
6
|
iceServers?: RTCIceServer[];
|
|
7
7
|
serverUrl?: string;
|
|
8
8
|
apiKey?: string;
|
|
9
|
+
password?: string;
|
|
9
10
|
/**
|
|
10
11
|
* Reconnection options. Enabled by default.
|
|
11
12
|
*/
|
|
@@ -37,6 +38,14 @@ interface VocaEvents {
|
|
|
37
38
|
'local-audio-level': (level: number) => void;
|
|
38
39
|
'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate password format for room creation.
|
|
43
|
+
* Returns null if valid, or an error message if invalid.
|
|
44
|
+
*
|
|
45
|
+
* @param password - The password to validate
|
|
46
|
+
* @returns Error message if invalid, null if valid
|
|
47
|
+
*/
|
|
48
|
+
export declare function validatePassword(password: string): string | null;
|
|
40
49
|
export declare class VocaClient {
|
|
41
50
|
peers: Map<string, Peer>;
|
|
42
51
|
localStream: MediaStream | null;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
var
|
|
1
|
+
var X=()=>({emit(h,...j){for(let q=this.events[h]||[],z=0,B=q.length;z<B;z++)q[z](...j)},events:{},on(h,j){return(this.events[h]||=[]).push(j),()=>{this.events[h]=this.events[h]?.filter((q)=>j!==q)}}});var G={ROOM_NOT_FOUND:"room_not_found",ROOM_FULL:"room_full",MAX_ROOMS_REACHED:"max_rooms_reached",INVALID_ROOM_ID:"invalid_room_id",CONNECTION_FAILED:"connection_failed",WEBSOCKET_ERROR:"websocket_error",HEARTBEAT_TIMEOUT:"heartbeat_timeout",MICROPHONE_NOT_FOUND:"microphone_not_found",MICROPHONE_PERMISSION_DENIED:"microphone_permission_denied",INSECURE_CONTEXT:"insecure_context",INVALID_MESSAGE:"invalid_message",PEER_NOT_FOUND:"peer_not_found",INVALID_PASSWORD:"invalid_password",PASSWORD_REQUIRED:"password_required"},N={[G.ROOM_NOT_FOUND]:"Room not found",[G.ROOM_FULL]:"Room is at maximum capacity",[G.MAX_ROOMS_REACHED]:"Maximum number of rooms reached",[G.INVALID_ROOM_ID]:"Invalid room ID format",[G.CONNECTION_FAILED]:"Failed to connect to signaling server",[G.WEBSOCKET_ERROR]:"WebSocket connection error",[G.HEARTBEAT_TIMEOUT]:"Connection lost due to heartbeat timeout",[G.MICROPHONE_NOT_FOUND]:"No microphone found. Please connect a microphone and try again.",[G.MICROPHONE_PERMISSION_DENIED]:"Microphone permission denied. Please allow microphone access.",[G.INSECURE_CONTEXT]:"HTTPS is required for microphone access",[G.INVALID_MESSAGE]:"Invalid signaling message received",[G.PEER_NOT_FOUND]:"Peer not found in room",[G.INVALID_PASSWORD]:"Incorrect password",[G.PASSWORD_REQUIRED]:"This room requires a password"};function Z(h,j){return{code:h,message:j??N[h]}}function D(h){if(!h)return null;if(h.length<4||h.length>12)return"Password must be 4-12 characters";if(!/^[a-zA-Z0-9]+$/.test(h))return"Password must contain only letters and numbers";return null}var $=[{urls:"stun:stun.l.google.com:19302"},{urls:"stun:stun1.l.google.com:19302"}];class P{peers=new Map;localStream=null;isMuted=!1;status="connecting";roomId;events=X();ws=null;audioContext=null;analyser=null;animationFrame=null;iceServers=$;config;reconnectAttempts=0;reconnectTimeout=null;shouldReconnect=!0;peerAnalysers=new Map;static async createRoom(h={}){let j=P.getHttpUrl(h.serverUrl);if(!j)throw Error("VocaConfig.serverUrl is required in non-browser environments");let q={"Content-Type":"application/json"};if(h.apiKey)q["x-api-key"]=h.apiKey;let z=`${j}/api/room`,B=new URLSearchParams;if(h.password)B.append("password",h.password);if(B.toString())z+=`?${B.toString()}`;let H=await fetch(z,{method:"POST",headers:q});if(!H.ok){let W=await H.json().catch(()=>({error:"unknown",message:"Failed to create room"}));throw Error(W.message||"Failed to create room")}let{room:J,password:O}=await H.json(),K={...h,password:O||h.password};return new P(J,K)}static getHttpUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol}//${window.location.host}`;return""}if(h.startsWith("wss://"))return h.replace("wss://","https://");if(h.startsWith("ws://"))return h.replace("ws://","http://");return h}static getWsUrl(h){if(!h){if(typeof window<"u")return`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}`;throw Error("VocaConfig.serverUrl is required in non-browser environments")}if(h.startsWith("https://"))return h.replace("https://","wss://");if(h.startsWith("http://"))return h.replace("http://","ws://");return h}constructor(h,j={}){if(this.roomId=h,this.config=j,j.iceServers)this.iceServers=j.iceServers}on(h,j){return this.events.on(h,j)}async connect(){this.status="connecting",this.events.emit("status","connecting");try{await this.setupMediaAndAudio(),this.connectSocket()}catch(h){throw this.handleError(G.CONNECTION_FAILED,h instanceof Error?h.message:"Failed to connect"),h}}disconnect(){if(this.shouldReconnect=!1,this.reconnectTimeout)clearTimeout(this.reconnectTimeout),this.reconnectTimeout=null;if(this.animationFrame)cancelAnimationFrame(this.animationFrame);if(this.peers.forEach((h)=>h.connection.close()),this.peers.clear(),this.ws?.close(),this.localStream?.getTracks().forEach((h)=>h.stop()),this.audioContext&&this.audioContext.state!=="closed")this.audioContext.close().catch((h)=>console.warn("Error closing AudioContext",h));this.status="disconnected",this.events.emit("status","disconnected")}toggleMute(){let h=this.localStream?.getAudioTracks()[0];if(h)h.enabled=!h.enabled,this.isMuted=!h.enabled;return this.isMuted}async setupMediaAndAudio(){if(typeof window<"u"&&!window.isSecureContext)throw Error(N[G.INSECURE_CONTEXT]);try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,latency:0,channelCount:1},video:!1})}catch(h){try{this.localStream=await navigator.mediaDevices.getUserMedia({audio:!0,video:!1})}catch(j){if(j.name==="NotFoundError"||j.message.includes("Requested device not found"))throw Error(N[G.MICROPHONE_NOT_FOUND]);if(j.name==="NotAllowedError"||j.message.includes("Permission denied"))throw Error(N[G.MICROPHONE_PERMISSION_DENIED]);throw j}}this.setupAudioAnalysis(this.localStream)}setupAudioAnalysis(h){let j=window.AudioContext||window.webkitAudioContext;this.audioContext=new j;let q=this.audioContext.createMediaStreamSource(h);this.analyser=this.audioContext.createAnalyser(),this.analyser.fftSize=256,q.connect(this.analyser);let z=new Uint8Array(this.analyser.frequencyBinCount),B=()=>{if(!this.analyser)return;this.analyser.getByteFrequencyData(z);let H=z.reduce((O,K)=>O+K,0)/z.length,J=Math.min(H/128,1);this.events.emit("local-audio-level",J),this.animationFrame=requestAnimationFrame(B)};B()}getSocketUrl(){let j=`${P.getWsUrl(this.config.serverUrl)}/ws/${this.roomId}`,q=new URLSearchParams;if(this.config.apiKey)q.append("apiKey",this.config.apiKey);if(this.config.password)q.append("password",this.config.password);if(q.toString())j+=`?${q.toString()}`;return j}connectSocket(){let h=this.getSocketUrl();this.ws=new WebSocket(h),this.ws.onopen=()=>{this.send({type:"hello",version:"0.3.0",client:"@treyorr/voca-client"}),this.status="connected",this.events.emit("status","connected"),this.reconnectAttempts=0},this.ws.onclose=()=>{if(this.status==="full"||this.status==="error")return;let j=this.config.reconnect?.enabled!==!1,q=this.config.reconnect?.maxAttempts??5,z=this.config.reconnect?.baseDelayMs??1000;if(j&&this.shouldReconnect&&this.reconnectAttempts<q){this.status="reconnecting",this.events.emit("status","reconnecting");let B=Math.min(z*Math.pow(2,this.reconnectAttempts),30000);this.reconnectAttempts++,this.reconnectTimeout=setTimeout(()=>{this.connectSocket()},B)}else this.status="disconnected",this.events.emit("status","disconnected")},this.ws.onerror=()=>this.handleError(G.WEBSOCKET_ERROR,"WebSocket connection failed"),this.ws.onmessage=(j)=>{let q=JSON.parse(j.data);this.handleSignal(q)}}async handleSignal(h){switch(h.type){case"welcome":console.debug("[Voca] Protocol version:",h.version,"Peer ID:",h.peer_id);break;case"join":await this.createPeer(h.from,!0);break;case"offer":await this.createPeer(h.from,!1,h.sdp);break;case"answer":let j=this.peers.get(h.from);if(j)await j.connection.setRemoteDescription({type:"answer",sdp:h.sdp});break;case"ice":let q=this.peers.get(h.from);if(q)try{await q.connection.addIceCandidate(JSON.parse(h.candidate))}catch(z){console.warn("[Voca] Invalid ICE candidate:",z)}break;case"ping":this.send({type:"pong"});break;case"leave":this.removePeer(h.from);break;case"error":this.handleError(h.code??"unknown",h.message??"Unknown error");break}}async createPeer(h,j,q){let z=new RTCPeerConnection({iceServers:this.iceServers});if(z.onicecandidate=(B)=>{if(B.candidate)this.send({type:"ice",to:h,candidate:JSON.stringify(B.candidate)})},z.ontrack=(B)=>{this.events.emit("track",h,B.track,B.streams[0]),this.setupRemoteAudio(h,B.streams[0]);let H=this.peers.get(h);if(H)H.stream=B.streams[0]},this.localStream?.getTracks().forEach((B)=>z.addTrack(B,this.localStream)),this.peers.set(h,{id:h,connection:z,audioLevel:0}),this.events.emit("peer-joined",h),j){let B=await z.createOffer();await z.setLocalDescription(B),this.send({type:"offer",to:h,sdp:B.sdp})}else if(q){await z.setRemoteDescription({type:"offer",sdp:q});let B=await z.createAnswer();await z.setLocalDescription(B),this.send({type:"answer",to:h,sdp:B.sdp})}}removePeer(h){this.peers.get(h)?.connection.close(),this.peers.delete(h);let q=this.peerAnalysers.get(h);if(q)q.source.disconnect(),q.analyser.disconnect(),this.peerAnalysers.delete(h);this.events.emit("peer-left",h)}setupRemoteAudio(h,j){if(!this.audioContext){let J=window.AudioContext||window.webkitAudioContext;this.audioContext=new J}let q=this.audioContext.createMediaStreamSource(j),z=this.audioContext.createAnalyser();z.fftSize=256,q.connect(z),q.connect(this.audioContext.destination),this.peerAnalysers.set(h,{source:q,analyser:z});let B=new Uint8Array(z.frequencyBinCount),H=()=>{let J=this.peers.get(h);if(!J)return;z.getByteFrequencyData(B);let O=B.reduce((W,Y)=>W+Y,0)/B.length,K=Math.min(O/128,1);this.events.emit("peer-audio-level",h,K),J.audioLevel=K,requestAnimationFrame(H)};H()}send(h){if(!this.ws||this.ws.readyState!==WebSocket.OPEN)return;this.ws.send(JSON.stringify({from:"",...h}))}handleError(h,j){this.status=h===G.ROOM_FULL||h==="room_full"?"full":"error",this.events.emit("status",this.status),this.events.emit("error",{code:h,message:j})}}export{D as validatePassword,Z as createVocaError,N as VocaErrorMessages,G as VocaErrorCode,P as VocaClient};
|
|
2
2
|
|
|
3
|
-
//# debugId=
|
|
3
|
+
//# debugId=FA20E2EBA674C21264756E2164756E21
|
package/dist/index.js.map
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
"sources": ["../../../node_modules/.bun/nanoevents@9.1.0/node_modules/nanoevents/index.js", "../src/errors.ts", "../src/index.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"export let createNanoEvents = () => ({\n emit(event, ...args) {\n for (\n let callbacks = this.events[event] || [],\n i = 0,\n length = callbacks.length;\n i < length;\n i++\n ) {\n callbacks[i](...args)\n }\n },\n events: {},\n on(event, cb) {\n ;(this.events[event] ||= []).push(cb)\n return () => {\n this.events[event] = this.events[event]?.filter(i => cb !== i)\n }\n }\n})\n",
|
|
6
|
-
"/**\n * Voca Error Codes\n * \n * Unified error codes used across all Voca SDKs and the signaling server.\n */\n\nexport const VocaErrorCode = {\n // Room errors\n ROOM_NOT_FOUND: 'room_not_found',\n ROOM_FULL: 'room_full',\n MAX_ROOMS_REACHED: 'max_rooms_reached',\n INVALID_ROOM_ID: 'invalid_room_id',\n\n // Connection errors\n CONNECTION_FAILED: 'connection_failed',\n WEBSOCKET_ERROR: 'websocket_error',\n HEARTBEAT_TIMEOUT: 'heartbeat_timeout',\n\n // Media errors\n MICROPHONE_NOT_FOUND: 'microphone_not_found',\n MICROPHONE_PERMISSION_DENIED: 'microphone_permission_denied',\n INSECURE_CONTEXT: 'insecure_context',\n\n // Signaling errors\n INVALID_MESSAGE: 'invalid_message',\n PEER_NOT_FOUND: 'peer_not_found',\n} as const;\n\nexport type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];\n\n/**\n * Human-readable error messages for each error code\n */\nexport const VocaErrorMessages: Record<VocaErrorCode, string> = {\n [VocaErrorCode.ROOM_NOT_FOUND]: 'Room not found',\n [VocaErrorCode.ROOM_FULL]: 'Room is at maximum capacity',\n [VocaErrorCode.MAX_ROOMS_REACHED]: 'Maximum number of rooms reached',\n [VocaErrorCode.INVALID_ROOM_ID]: 'Invalid room ID format',\n [VocaErrorCode.CONNECTION_FAILED]: 'Failed to connect to signaling server',\n [VocaErrorCode.WEBSOCKET_ERROR]: 'WebSocket connection error',\n [VocaErrorCode.HEARTBEAT_TIMEOUT]: 'Connection lost due to heartbeat timeout',\n [VocaErrorCode.MICROPHONE_NOT_FOUND]: 'No microphone found. Please connect a microphone and try again.',\n [VocaErrorCode.MICROPHONE_PERMISSION_DENIED]: 'Microphone permission denied. Please allow microphone access.',\n [VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',\n [VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',\n [VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',\n};\n\n/**\n * Voca error with code and message\n */\nexport interface VocaError {\n code: VocaErrorCode;\n message: string;\n}\n\n/**\n * Create a VocaError from a code\n */\nexport function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError {\n return {\n code,\n message: customMessage ?? VocaErrorMessages[code],\n };\n}\n",
|
|
7
|
-
"import { createNanoEvents } from 'nanoevents';\nimport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\nexport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\n\nexport type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';\n\nexport interface VocaConfig {\n debug?: boolean;\n iceServers?: RTCIceServer[];\n serverUrl?: string; // e.g. \"ws://localhost:3001\" or \"wss://voca.vc\"\n apiKey?: string; // optional API key for signaling server auth\n /**\n * Reconnection options. Enabled by default.\n */\n reconnect?: {\n /** Enable automatic reconnection. Default: true */\n enabled?: boolean;\n /** Maximum reconnection attempts. Default: 5 */\n maxAttempts?: number;\n /** Base delay in milliseconds. Default: 1000 */\n baseDelayMs?: number;\n };\n}\n\nexport interface Peer {\n id: string;\n connection: RTCPeerConnection;\n audioLevel: number;\n stream?: MediaStream;\n}\n\ntype SignalMessage = {\n from: string;\n type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';\n peer_id?: string;\n to?: string;\n sdp?: string;\n candidate?: string;\n code?: string;\n message?: string;\n // Protocol versioning\n version?: string;\n client?: string;\n};\n\ninterface VocaEvents {\n 'status': (status: ConnectionStatus) => void;\n 'error': (error: VocaError) => void;\n 'warning': (warning: { code: string; message: string }) => void;\n 'peer-joined': (peerId: string) => void;\n 'peer-left': (peerId: string) => void;\n 'peer-audio-level': (peerId: string, level: number) => void;\n 'local-audio-level': (level: number) => void;\n 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;\n}\n\nconst DEFAULT_ICE_SERVERS: RTCIceServer[] = [\n { urls: 'stun:stun.l.google.com:19302' },\n { urls: 'stun:stun1.l.google.com:19302' },\n];\n\nexport class VocaClient {\n public peers: Map<string, Peer> = new Map();\n public localStream: MediaStream | null = null;\n public isMuted: boolean = false;\n public status: ConnectionStatus = 'connecting';\n public roomId: string;\n\n private events = createNanoEvents<VocaEvents>();\n private ws: WebSocket | null = null;\n private audioContext: AudioContext | null = null;\n private analyser: AnalyserNode | null = null;\n private animationFrame: number | null = null;\n private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;\n private config: VocaConfig;\n\n // Reconnection state\n private reconnectAttempts = 0;\n private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;\n private shouldReconnect = true;\n\n // Audio analysis nodes per peer (for cleanup)\n private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();\n\n /**\n * Create a new room and return a VocaClient connected to it.\n * This is a convenience method that handles room creation via the API.\n * \n * @param config - VocaClient configuration (serverUrl required for non-browser environments)\n * @returns Promise<VocaClient> - A new client instance for the created room\n */\n static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {\n const httpUrl = VocaClient.getHttpUrl(config.serverUrl);\n\n if (!httpUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n\n const headers: HeadersInit = {\n 'Content-Type': 'application/json',\n };\n\n if (config.apiKey) {\n headers['x-api-key'] = config.apiKey;\n }\n\n const response = await fetch(`${httpUrl}/api/room`, {\n method: 'POST',\n headers,\n body: JSON.stringify(config)\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));\n throw new Error(error.message || 'Failed to create room');\n }\n\n const { room } = await response.json();\n return new VocaClient(room, config);\n }\n\n /**\n * Derive an HTTP/HTTPS URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: https:// or http:// URL\n */\n private static getHttpUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n return `${window.location.protocol}//${window.location.host}`;\n }\n return '';\n }\n // Normalize to HTTP(S)\n if (serverUrl.startsWith('wss://')) return serverUrl.replace('wss://', 'https://');\n if (serverUrl.startsWith('ws://')) return serverUrl.replace('ws://', 'http://');\n return serverUrl;\n }\n\n /**\n * Derive a WebSocket URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: wss:// or ws:// URL\n */\n private static getWsUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n return `${protocol}//${window.location.host}`;\n }\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n // Normalize to WS(S)\n if (serverUrl.startsWith('https://')) return serverUrl.replace('https://', 'wss://');\n if (serverUrl.startsWith('http://')) return serverUrl.replace('http://', 'ws://');\n return serverUrl;\n }\n\n constructor(roomId: string, config: VocaConfig = {}) {\n this.roomId = roomId;\n this.config = config;\n if (config.iceServers) this.iceServers = config.iceServers;\n }\n\n public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {\n return this.events.on(event, callback);\n }\n\n public async connect(): Promise<void> {\n this.status = 'connecting';\n this.events.emit('status', 'connecting');\n\n try {\n // Use default STUN servers (Google's public STUN servers)\n await this.setupMediaAndAudio();\n this.connectSocket();\n } catch (err) {\n this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');\n throw err;\n }\n }\n\n public disconnect() {\n // Prevent reconnection attempts\n this.shouldReconnect = false;\n if (this.reconnectTimeout) {\n clearTimeout(this.reconnectTimeout);\n this.reconnectTimeout = null;\n }\n\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n this.peers.forEach((p) => p.connection.close());\n this.peers.clear();\n this.ws?.close();\n this.localStream?.getTracks().forEach((t) => t.stop());\n if (this.audioContext && this.audioContext.state !== 'closed') {\n this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));\n }\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n\n public toggleMute() {\n const track = this.localStream?.getAudioTracks()[0];\n if (track) {\n track.enabled = !track.enabled;\n this.isMuted = !track.enabled;\n }\n return this.isMuted;\n }\n\n\n\n private async setupMediaAndAudio() {\n // Only verify secure context in browsers\n if (typeof window !== 'undefined' && !window.isSecureContext) {\n throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);\n }\n\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n latency: 0,\n channelCount: 1\n } as any,\n video: false,\n });\n } catch (err) {\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });\n } catch (retryErr: any) {\n if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);\n }\n if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);\n }\n throw retryErr;\n }\n }\n\n this.setupAudioAnalysis(this.localStream!);\n }\n\n private setupAudioAnalysis(stream: MediaStream) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n const source = this.audioContext.createMediaStreamSource(stream);\n this.analyser = this.audioContext.createAnalyser();\n this.analyser.fftSize = 256;\n source.connect(this.analyser);\n\n const dataArray = new Uint8Array(this.analyser.frequencyBinCount);\n const update = () => {\n if (!this.analyser) return;\n this.analyser.getByteFrequencyData(dataArray);\n const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;\n const level = Math.min(avg / 128, 1);\n this.events.emit('local-audio-level', level);\n this.animationFrame = requestAnimationFrame(update);\n };\n update();\n }\n\n /**\n * Build the full WebSocket URL for connecting to a room.\n */\n private getSocketUrl(): string {\n const wsUrl = VocaClient.getWsUrl(this.config.serverUrl);\n\n // Build URL: base + path + query params\n let fullUrl = `${wsUrl}/ws/${this.roomId}`;\n\n // Append apiKey if present\n if (this.config.apiKey) {\n fullUrl += `?apiKey=${encodeURIComponent(this.config.apiKey)}`;\n }\n\n return fullUrl;\n }\n\n private connectSocket() {\n const socketUrl = this.getSocketUrl();\n\n this.ws = new WebSocket(socketUrl);\n\n this.ws.onopen = () => {\n // Send hello message with version info\n this.send({ type: 'hello', version: '1.0', client: '@treyorr/voca-client' });\n this.status = 'connected';\n this.events.emit('status', 'connected');\n // Reset reconnect attempts on successful connection\n this.reconnectAttempts = 0;\n };\n\n this.ws.onclose = () => {\n // Don't reconnect on terminal states\n if (this.status === 'full' || this.status === 'error') {\n return;\n }\n\n const reconnectEnabled = this.config.reconnect?.enabled !== false;\n const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;\n const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;\n\n if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {\n this.status = 'reconnecting';\n this.events.emit('status', 'reconnecting');\n\n // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);\n this.reconnectAttempts++;\n\n this.reconnectTimeout = setTimeout(() => {\n this.connectSocket();\n }, delay);\n } else {\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n };\n\n this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');\n\n this.ws.onmessage = (e) => {\n const msg: SignalMessage = JSON.parse(e.data);\n this.handleSignal(msg);\n };\n }\n\n private async handleSignal(msg: SignalMessage) {\n switch (msg.type) {\n case 'welcome':\n // Protocol handshake complete - peer_id is managed server-side\n console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);\n break;\n case 'join':\n await this.createPeer(msg.from, true);\n break;\n case 'offer':\n await this.createPeer(msg.from, false, msg.sdp);\n break;\n case 'answer':\n const peer = this.peers.get(msg.from);\n if (peer) {\n await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });\n }\n break;\n case 'ice':\n const p = this.peers.get(msg.from);\n if (p) {\n try {\n await p.connection.addIceCandidate(JSON.parse(msg.candidate!));\n } catch (e) {\n console.warn('[Voca] Invalid ICE candidate:', e);\n }\n }\n break;\n case 'ping':\n this.ws?.send(JSON.stringify({ from: '', type: 'pong' }));\n break;\n case 'leave':\n this.removePeer(msg.from);\n break;\n case 'error':\n this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');\n break;\n }\n }\n\n private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {\n const pc = new RTCPeerConnection({ iceServers: this.iceServers });\n\n pc.onicecandidate = (e) => {\n if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });\n };\n\n pc.ontrack = (e) => {\n // Emit track event so UI can handle the audio element/stream\n this.events.emit('track', peerId, e.track, e.streams[0]);\n this.setupRemoteAudio(peerId, e.streams[0]);\n const peer = this.peers.get(peerId);\n if (peer) peer.stream = e.streams[0];\n };\n\n // Add local tracks\n this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));\n\n this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });\n this.events.emit('peer-joined', peerId);\n\n if (isInitiator) {\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.send({ type: 'offer', to: peerId, sdp: offer.sdp });\n } else if (remoteSdp) {\n await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n this.send({ type: 'answer', to: peerId, sdp: answer.sdp });\n }\n }\n\n private removePeer(peerId: string) {\n const peer = this.peers.get(peerId);\n peer?.connection.close();\n this.peers.delete(peerId);\n\n // Clean up audio analysis nodes\n const audio = this.peerAnalysers.get(peerId);\n if (audio) {\n audio.source.disconnect();\n audio.analyser.disconnect();\n this.peerAnalysers.delete(peerId);\n }\n\n this.events.emit('peer-left', peerId);\n }\n\n private setupRemoteAudio(peerId: string, stream: MediaStream) {\n if (!this.audioContext) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n }\n\n const source = this.audioContext.createMediaStreamSource(stream);\n const analyser = this.audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // CRITICAL: Connect to speakers so audio is actually played!\n source.connect(this.audioContext.destination);\n\n // Store for cleanup when peer leaves\n this.peerAnalysers.set(peerId, { source, analyser });\n\n const data = new Uint8Array(analyser.frequencyBinCount);\n const update = () => {\n const peer = this.peers.get(peerId);\n if (!peer) return;\n\n analyser.getByteFrequencyData(data);\n const avg = data.reduce((a, b) => a + b, 0) / data.length;\n const level = Math.min(avg / 128, 1);\n\n this.events.emit('peer-audio-level', peerId, level);\n\n // Store in peer object as well for convenience\n peer.audioLevel = level;\n\n requestAnimationFrame(update);\n };\n update();\n }\n\n private send(msg: Partial<SignalMessage>) {\n if (this.ws?.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify({ from: '', ...msg }));\n }\n }\n\n private handleError(code: VocaErrorCode | string, message: string) {\n this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';\n this.events.emit('status', this.status);\n this.events.emit('error', { code: code as VocaErrorCode, message });\n }\n}\n"
|
|
6
|
+
"/**\n * Voca Error Codes\n * \n * Unified error codes used across all Voca SDKs and the signaling server.\n */\n\nexport const VocaErrorCode = {\n // Room errors\n ROOM_NOT_FOUND: 'room_not_found',\n ROOM_FULL: 'room_full',\n MAX_ROOMS_REACHED: 'max_rooms_reached',\n INVALID_ROOM_ID: 'invalid_room_id',\n\n // Connection errors\n CONNECTION_FAILED: 'connection_failed',\n WEBSOCKET_ERROR: 'websocket_error',\n HEARTBEAT_TIMEOUT: 'heartbeat_timeout',\n\n // Media errors\n MICROPHONE_NOT_FOUND: 'microphone_not_found',\n MICROPHONE_PERMISSION_DENIED: 'microphone_permission_denied',\n INSECURE_CONTEXT: 'insecure_context',\n\n // Signaling errors\n INVALID_MESSAGE: 'invalid_message',\n PEER_NOT_FOUND: 'peer_not_found',\n\n // Password errors\n INVALID_PASSWORD: 'invalid_password',\n PASSWORD_REQUIRED: 'password_required',\n} as const;\n\nexport type VocaErrorCode = typeof VocaErrorCode[keyof typeof VocaErrorCode];\n\n/**\n * Human-readable error messages for each error code\n */\nexport const VocaErrorMessages: Record<VocaErrorCode, string> = {\n [VocaErrorCode.ROOM_NOT_FOUND]: 'Room not found',\n [VocaErrorCode.ROOM_FULL]: 'Room is at maximum capacity',\n [VocaErrorCode.MAX_ROOMS_REACHED]: 'Maximum number of rooms reached',\n [VocaErrorCode.INVALID_ROOM_ID]: 'Invalid room ID format',\n [VocaErrorCode.CONNECTION_FAILED]: 'Failed to connect to signaling server',\n [VocaErrorCode.WEBSOCKET_ERROR]: 'WebSocket connection error',\n [VocaErrorCode.HEARTBEAT_TIMEOUT]: 'Connection lost due to heartbeat timeout',\n [VocaErrorCode.MICROPHONE_NOT_FOUND]: 'No microphone found. Please connect a microphone and try again.',\n [VocaErrorCode.MICROPHONE_PERMISSION_DENIED]: 'Microphone permission denied. Please allow microphone access.',\n [VocaErrorCode.INSECURE_CONTEXT]: 'HTTPS is required for microphone access',\n [VocaErrorCode.INVALID_MESSAGE]: 'Invalid signaling message received',\n [VocaErrorCode.PEER_NOT_FOUND]: 'Peer not found in room',\n [VocaErrorCode.INVALID_PASSWORD]: 'Incorrect password',\n [VocaErrorCode.PASSWORD_REQUIRED]: 'This room requires a password',\n};\n\n/**\n * Voca error with code and message\n */\nexport interface VocaError {\n code: VocaErrorCode;\n message: string;\n}\n\n/**\n * Create a VocaError from a code\n */\nexport function createVocaError(code: VocaErrorCode, customMessage?: string): VocaError {\n return {\n code,\n message: customMessage ?? VocaErrorMessages[code],\n };\n}\n",
|
|
7
|
+
"import { createNanoEvents } from 'nanoevents';\nimport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\nexport { VocaErrorCode, VocaErrorMessages, type VocaError, createVocaError } from './errors';\n\nexport type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'full' | 'error' | 'disconnected';\n\nexport interface VocaConfig {\n debug?: boolean;\n iceServers?: RTCIceServer[];\n serverUrl?: string; // e.g. \"ws://localhost:3001\" or \"wss://voca.vc\"\n apiKey?: string; // optional API key for signaling server auth\n password?: string; // optional room password for protected rooms\n /**\n * Reconnection options. Enabled by default.\n */\n reconnect?: {\n /** Enable automatic reconnection. Default: true */\n enabled?: boolean;\n /** Maximum reconnection attempts. Default: 5 */\n maxAttempts?: number;\n /** Base delay in milliseconds. Default: 1000 */\n baseDelayMs?: number;\n };\n}\n\nexport interface Peer {\n id: string;\n connection: RTCPeerConnection;\n audioLevel: number;\n stream?: MediaStream;\n}\n\ntype SignalMessage = {\n from: string;\n type: 'hello' | 'welcome' | 'join' | 'leave' | 'offer' | 'answer' | 'ice' | 'ping' | 'pong' | 'error';\n peer_id?: string;\n to?: string;\n sdp?: string;\n candidate?: string;\n code?: string;\n message?: string;\n // Protocol versioning\n version?: string;\n client?: string;\n};\n\ninterface VocaEvents {\n 'status': (status: ConnectionStatus) => void;\n 'error': (error: VocaError) => void;\n 'warning': (warning: { code: string; message: string }) => void;\n 'peer-joined': (peerId: string) => void;\n 'peer-left': (peerId: string) => void;\n 'peer-audio-level': (peerId: string, level: number) => void;\n 'local-audio-level': (level: number) => void;\n 'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;\n}\n\n/**\n * Validate password format for room creation.\n * Returns null if valid, or an error message if invalid.\n * \n * @param password - The password to validate\n * @returns Error message if invalid, null if valid\n */\nexport function validatePassword(password: string): string | null {\n if (!password) return null; // Empty is valid (no password)\n if (password.length < 4 || password.length > 12) {\n return 'Password must be 4-12 characters';\n }\n if (!/^[a-zA-Z0-9]+$/.test(password)) {\n return 'Password must contain only letters and numbers';\n }\n return null;\n}\n\nconst DEFAULT_ICE_SERVERS: RTCIceServer[] = [\n { urls: 'stun:stun.l.google.com:19302' },\n { urls: 'stun:stun1.l.google.com:19302' },\n];\n\nexport class VocaClient {\n public peers: Map<string, Peer> = new Map();\n public localStream: MediaStream | null = null;\n public isMuted: boolean = false;\n public status: ConnectionStatus = 'connecting';\n public roomId: string;\n\n private events = createNanoEvents<VocaEvents>();\n private ws: WebSocket | null = null;\n private audioContext: AudioContext | null = null;\n private analyser: AnalyserNode | null = null;\n private animationFrame: number | null = null;\n private iceServers: RTCIceServer[] = DEFAULT_ICE_SERVERS;\n private config: VocaConfig;\n\n // Reconnection state\n private reconnectAttempts = 0;\n private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;\n private shouldReconnect = true;\n\n // Audio analysis nodes per peer (for cleanup)\n private peerAnalysers: Map<string, { source: MediaStreamAudioSourceNode; analyser: AnalyserNode }> = new Map();\n\n /**\n * Create a new room and return a VocaClient connected to it.\n * This is a convenience method that handles room creation via the API.\n * \n * @param config - VocaClient configuration (serverUrl required for non-browser environments)\n * @returns Promise<VocaClient> - A new client instance for the created room\n */\n static async createRoom(config: VocaConfig = {}): Promise<VocaClient> {\n const httpUrl = VocaClient.getHttpUrl(config.serverUrl);\n\n if (!httpUrl) {\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n\n const headers: HeadersInit = {\n 'Content-Type': 'application/json',\n };\n\n if (config.apiKey) {\n headers['x-api-key'] = config.apiKey;\n }\n\n // Build URL with optional password query param\n let url = `${httpUrl}/api/room`;\n const params = new URLSearchParams();\n if (config.password) {\n params.append('password', config.password);\n }\n if (params.toString()) {\n url += `?${params.toString()}`;\n }\n\n const response = await fetch(url, {\n method: 'POST',\n headers,\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({ error: 'unknown', message: 'Failed to create room' }));\n throw new Error(error.message || 'Failed to create room');\n }\n\n const { room, password } = await response.json();\n // Use the password from response (in case server modified it) or from config\n const roomConfig = { ...config, password: password || config.password };\n return new VocaClient(room, roomConfig);\n }\n\n /**\n * Derive an HTTP/HTTPS URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: https:// or http:// URL\n */\n private static getHttpUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n return `${window.location.protocol}//${window.location.host}`;\n }\n return '';\n }\n // Normalize to HTTP(S)\n if (serverUrl.startsWith('wss://')) return serverUrl.replace('wss://', 'https://');\n if (serverUrl.startsWith('ws://')) return serverUrl.replace('ws://', 'http://');\n return serverUrl;\n }\n\n /**\n * Derive a WebSocket URL from any input format.\n * Accepts: https://, http://, wss://, ws://\n * Returns: wss:// or ws:// URL\n */\n private static getWsUrl(serverUrl?: string): string {\n if (!serverUrl) {\n if (typeof window !== 'undefined') {\n const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n return `${protocol}//${window.location.host}`;\n }\n throw new Error('VocaConfig.serverUrl is required in non-browser environments');\n }\n // Normalize to WS(S)\n if (serverUrl.startsWith('https://')) return serverUrl.replace('https://', 'wss://');\n if (serverUrl.startsWith('http://')) return serverUrl.replace('http://', 'ws://');\n return serverUrl;\n }\n\n constructor(roomId: string, config: VocaConfig = {}) {\n this.roomId = roomId;\n this.config = config;\n if (config.iceServers) this.iceServers = config.iceServers;\n }\n\n public on<E extends keyof VocaEvents>(event: E, callback: VocaEvents[E]) {\n return this.events.on(event, callback);\n }\n\n public async connect(): Promise<void> {\n this.status = 'connecting';\n this.events.emit('status', 'connecting');\n\n try {\n // Use default STUN servers (Google's public STUN servers)\n await this.setupMediaAndAudio();\n this.connectSocket();\n } catch (err) {\n this.handleError(VocaErrorCode.CONNECTION_FAILED, err instanceof Error ? err.message : 'Failed to connect');\n throw err;\n }\n }\n\n public disconnect() {\n // Prevent reconnection attempts\n this.shouldReconnect = false;\n if (this.reconnectTimeout) {\n clearTimeout(this.reconnectTimeout);\n this.reconnectTimeout = null;\n }\n\n if (this.animationFrame) cancelAnimationFrame(this.animationFrame);\n this.peers.forEach((p) => p.connection.close());\n this.peers.clear();\n this.ws?.close();\n this.localStream?.getTracks().forEach((t) => t.stop());\n if (this.audioContext && this.audioContext.state !== 'closed') {\n this.audioContext.close().catch(e => console.warn('Error closing AudioContext', e));\n }\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n\n public toggleMute() {\n const track = this.localStream?.getAudioTracks()[0];\n if (track) {\n track.enabled = !track.enabled;\n this.isMuted = !track.enabled;\n }\n return this.isMuted;\n }\n\n\n\n private async setupMediaAndAudio() {\n // Only verify secure context in browsers\n if (typeof window !== 'undefined' && !window.isSecureContext) {\n throw new Error(VocaErrorMessages[VocaErrorCode.INSECURE_CONTEXT]);\n }\n\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n latency: 0,\n channelCount: 1\n } as any,\n video: false,\n });\n } catch (err) {\n try {\n this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });\n } catch (retryErr: any) {\n if (retryErr.name === 'NotFoundError' || retryErr.message.includes('Requested device not found')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_NOT_FOUND]);\n }\n if (retryErr.name === 'NotAllowedError' || retryErr.message.includes('Permission denied')) {\n throw new Error(VocaErrorMessages[VocaErrorCode.MICROPHONE_PERMISSION_DENIED]);\n }\n throw retryErr;\n }\n }\n\n this.setupAudioAnalysis(this.localStream!);\n }\n\n private setupAudioAnalysis(stream: MediaStream) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n const source = this.audioContext.createMediaStreamSource(stream);\n this.analyser = this.audioContext.createAnalyser();\n this.analyser.fftSize = 256;\n source.connect(this.analyser);\n\n const dataArray = new Uint8Array(this.analyser.frequencyBinCount);\n const update = () => {\n if (!this.analyser) return;\n this.analyser.getByteFrequencyData(dataArray);\n const avg = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;\n const level = Math.min(avg / 128, 1);\n this.events.emit('local-audio-level', level);\n this.animationFrame = requestAnimationFrame(update);\n };\n update();\n }\n\n /**\n * Build the full WebSocket URL for connecting to a room.\n */\n private getSocketUrl(): string {\n const wsUrl = VocaClient.getWsUrl(this.config.serverUrl);\n\n // Build URL: base + path + query params\n let fullUrl = `${wsUrl}/ws/${this.roomId}`;\n\n const params = new URLSearchParams();\n\n // Append apiKey if present\n if (this.config.apiKey) {\n params.append('apiKey', this.config.apiKey);\n }\n\n // Append password if present\n if (this.config.password) {\n params.append('password', this.config.password);\n }\n\n if (params.toString()) {\n fullUrl += `?${params.toString()}`;\n }\n\n return fullUrl;\n }\n\n private connectSocket() {\n const socketUrl = this.getSocketUrl();\n\n this.ws = new WebSocket(socketUrl);\n\n this.ws.onopen = () => {\n // Send hello message with version info\n this.send({ type: 'hello', version: '0.3.0', client: '@treyorr/voca-client' });\n this.status = 'connected';\n this.events.emit('status', 'connected');\n // Reset reconnect attempts on successful connection\n this.reconnectAttempts = 0;\n };\n\n this.ws.onclose = () => {\n // Don't reconnect on terminal states\n if (this.status === 'full' || this.status === 'error') {\n return;\n }\n\n const reconnectEnabled = this.config.reconnect?.enabled !== false;\n const maxAttempts = this.config.reconnect?.maxAttempts ?? 5;\n const baseDelay = this.config.reconnect?.baseDelayMs ?? 1000;\n\n if (reconnectEnabled && this.shouldReconnect && this.reconnectAttempts < maxAttempts) {\n this.status = 'reconnecting';\n this.events.emit('status', 'reconnecting');\n\n // Exponential backoff: 1s, 2s, 4s, 8s, 16s (capped at 30s)\n const delay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), 30000);\n this.reconnectAttempts++;\n\n this.reconnectTimeout = setTimeout(() => {\n this.connectSocket();\n }, delay);\n } else {\n this.status = 'disconnected';\n this.events.emit('status', 'disconnected');\n }\n };\n\n this.ws.onerror = () => this.handleError(VocaErrorCode.WEBSOCKET_ERROR, 'WebSocket connection failed');\n\n this.ws.onmessage = (e) => {\n const msg: SignalMessage = JSON.parse(e.data);\n this.handleSignal(msg);\n };\n }\n\n private async handleSignal(msg: SignalMessage) {\n switch (msg.type) {\n case 'welcome':\n // Protocol handshake complete - peer_id is managed server-side\n console.debug('[Voca] Protocol version:', msg.version, 'Peer ID:', msg.peer_id);\n break;\n case 'join':\n await this.createPeer(msg.from, true);\n break;\n case 'offer':\n await this.createPeer(msg.from, false, msg.sdp);\n break;\n case 'answer':\n const peer = this.peers.get(msg.from);\n if (peer) {\n await peer.connection.setRemoteDescription({ type: 'answer', sdp: msg.sdp! });\n }\n break;\n case 'ice':\n const p = this.peers.get(msg.from);\n if (p) {\n try {\n await p.connection.addIceCandidate(JSON.parse(msg.candidate!));\n } catch (e) {\n console.warn('[Voca] Invalid ICE candidate:', e);\n }\n }\n break;\n case 'ping':\n this.send({ type: 'pong' });\n break;\n case 'leave':\n this.removePeer(msg.from);\n break;\n case 'error':\n this.handleError(msg.code ?? 'unknown', msg.message ?? 'Unknown error');\n break;\n }\n }\n\n private async createPeer(peerId: string, isInitiator: boolean, remoteSdp?: string) {\n const pc = new RTCPeerConnection({ iceServers: this.iceServers });\n\n pc.onicecandidate = (e) => {\n if (e.candidate) this.send({ type: 'ice', to: peerId, candidate: JSON.stringify(e.candidate) });\n };\n\n pc.ontrack = (e) => {\n // Emit track event so UI can handle the audio element/stream\n this.events.emit('track', peerId, e.track, e.streams[0]);\n this.setupRemoteAudio(peerId, e.streams[0]);\n const peer = this.peers.get(peerId);\n if (peer) peer.stream = e.streams[0];\n };\n\n // Add local tracks\n this.localStream?.getTracks().forEach((track) => pc.addTrack(track, this.localStream!));\n\n this.peers.set(peerId, { id: peerId, connection: pc, audioLevel: 0 });\n this.events.emit('peer-joined', peerId);\n\n if (isInitiator) {\n const offer = await pc.createOffer();\n await pc.setLocalDescription(offer);\n this.send({ type: 'offer', to: peerId, sdp: offer.sdp });\n } else if (remoteSdp) {\n await pc.setRemoteDescription({ type: 'offer', sdp: remoteSdp });\n const answer = await pc.createAnswer();\n await pc.setLocalDescription(answer);\n this.send({ type: 'answer', to: peerId, sdp: answer.sdp });\n }\n }\n\n private removePeer(peerId: string) {\n const peer = this.peers.get(peerId);\n peer?.connection.close();\n this.peers.delete(peerId);\n\n // Clean up audio analysis nodes\n const audio = this.peerAnalysers.get(peerId);\n if (audio) {\n audio.source.disconnect();\n audio.analyser.disconnect();\n this.peerAnalysers.delete(peerId);\n }\n\n this.events.emit('peer-left', peerId);\n }\n\n private setupRemoteAudio(peerId: string, stream: MediaStream) {\n if (!this.audioContext) {\n const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;\n this.audioContext = new AudioContextClass();\n }\n\n const source = this.audioContext.createMediaStreamSource(stream);\n const analyser = this.audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // CRITICAL: Connect to speakers so audio is actually played!\n source.connect(this.audioContext.destination);\n\n // Store for cleanup when peer leaves\n this.peerAnalysers.set(peerId, { source, analyser });\n\n const data = new Uint8Array(analyser.frequencyBinCount);\n const update = () => {\n const peer = this.peers.get(peerId);\n if (!peer) return;\n\n analyser.getByteFrequencyData(data);\n const avg = data.reduce((a, b) => a + b, 0) / data.length;\n const level = Math.min(avg / 128, 1);\n\n this.events.emit('peer-audio-level', peerId, level);\n\n // Store in peer object as well for convenience\n peer.audioLevel = level;\n\n requestAnimationFrame(update);\n };\n update();\n }\n\n private send(msg: Partial<SignalMessage>) {\n if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {\n // Queue or drop - for now we drop since this is a signaling race condition\n return;\n }\n this.ws.send(JSON.stringify({ from: '', ...msg }));\n }\n\n private handleError(code: VocaErrorCode | string, message: string) {\n this.status = code === VocaErrorCode.ROOM_FULL || code === 'room_full' ? 'full' : 'error';\n this.events.emit('status', this.status);\n this.events.emit('error', { code: code as VocaErrorCode, message });\n }\n}\n"
|
|
8
8
|
],
|
|
9
|
-
"mappings": "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": "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",
|
|
10
|
+
"debugId": "FA20E2EBA674C21264756E2164756E21",
|
|
11
11
|
"names": []
|
|
12
12
|
}
|
package/package.json
CHANGED
|
@@ -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
|
*/
|
|
@@ -54,6 +55,24 @@ interface VocaEvents {
|
|
|
54
55
|
'track': (peerId: string, track: MediaStreamTrack, stream: MediaStream) => void;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Validate password format for room creation.
|
|
60
|
+
* Returns null if valid, or an error message if invalid.
|
|
61
|
+
*
|
|
62
|
+
* @param password - The password to validate
|
|
63
|
+
* @returns Error message if invalid, null if valid
|
|
64
|
+
*/
|
|
65
|
+
export function validatePassword(password: string): string | null {
|
|
66
|
+
if (!password) return null; // Empty is valid (no password)
|
|
67
|
+
if (password.length < 4 || password.length > 12) {
|
|
68
|
+
return 'Password must be 4-12 characters';
|
|
69
|
+
}
|
|
70
|
+
if (!/^[a-zA-Z0-9]+$/.test(password)) {
|
|
71
|
+
return 'Password must contain only letters and numbers';
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
const DEFAULT_ICE_SERVERS: RTCIceServer[] = [
|
|
58
77
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
59
78
|
{ urls: 'stun:stun1.l.google.com:19302' },
|
|
@@ -104,10 +123,19 @@ export class VocaClient {
|
|
|
104
123
|
headers['x-api-key'] = config.apiKey;
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
|
|
126
|
+
// Build URL with optional password query param
|
|
127
|
+
let url = `${httpUrl}/api/room`;
|
|
128
|
+
const params = new URLSearchParams();
|
|
129
|
+
if (config.password) {
|
|
130
|
+
params.append('password', config.password);
|
|
131
|
+
}
|
|
132
|
+
if (params.toString()) {
|
|
133
|
+
url += `?${params.toString()}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const response = await fetch(url, {
|
|
108
137
|
method: 'POST',
|
|
109
138
|
headers,
|
|
110
|
-
body: JSON.stringify(config)
|
|
111
139
|
});
|
|
112
140
|
|
|
113
141
|
if (!response.ok) {
|
|
@@ -115,8 +143,10 @@ export class VocaClient {
|
|
|
115
143
|
throw new Error(error.message || 'Failed to create room');
|
|
116
144
|
}
|
|
117
145
|
|
|
118
|
-
const { room } = await response.json();
|
|
119
|
-
|
|
146
|
+
const { room, password } = await response.json();
|
|
147
|
+
// Use the password from response (in case server modified it) or from config
|
|
148
|
+
const roomConfig = { ...config, password: password || config.password };
|
|
149
|
+
return new VocaClient(room, roomConfig);
|
|
120
150
|
}
|
|
121
151
|
|
|
122
152
|
/**
|
|
@@ -274,9 +304,20 @@ export class VocaClient {
|
|
|
274
304
|
// Build URL: base + path + query params
|
|
275
305
|
let fullUrl = `${wsUrl}/ws/${this.roomId}`;
|
|
276
306
|
|
|
307
|
+
const params = new URLSearchParams();
|
|
308
|
+
|
|
277
309
|
// Append apiKey if present
|
|
278
310
|
if (this.config.apiKey) {
|
|
279
|
-
|
|
311
|
+
params.append('apiKey', this.config.apiKey);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Append password if present
|
|
315
|
+
if (this.config.password) {
|
|
316
|
+
params.append('password', this.config.password);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (params.toString()) {
|
|
320
|
+
fullUrl += `?${params.toString()}`;
|
|
280
321
|
}
|
|
281
322
|
|
|
282
323
|
return fullUrl;
|
|
@@ -289,7 +330,7 @@ export class VocaClient {
|
|
|
289
330
|
|
|
290
331
|
this.ws.onopen = () => {
|
|
291
332
|
// Send hello message with version info
|
|
292
|
-
this.send({ type: 'hello', version: '
|
|
333
|
+
this.send({ type: 'hello', version: '0.3.0', client: '@treyorr/voca-client' });
|
|
293
334
|
this.status = 'connected';
|
|
294
335
|
this.events.emit('status', 'connected');
|
|
295
336
|
// Reset reconnect attempts on successful connection
|
|
@@ -360,7 +401,7 @@ export class VocaClient {
|
|
|
360
401
|
}
|
|
361
402
|
break;
|
|
362
403
|
case 'ping':
|
|
363
|
-
this.
|
|
404
|
+
this.send({ type: 'pong' });
|
|
364
405
|
break;
|
|
365
406
|
case 'leave':
|
|
366
407
|
this.removePeer(msg.from);
|
|
@@ -457,9 +498,11 @@ export class VocaClient {
|
|
|
457
498
|
}
|
|
458
499
|
|
|
459
500
|
private send(msg: Partial<SignalMessage>) {
|
|
460
|
-
if (this.ws
|
|
461
|
-
this
|
|
501
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
502
|
+
// Queue or drop - for now we drop since this is a signaling race condition
|
|
503
|
+
return;
|
|
462
504
|
}
|
|
505
|
+
this.ws.send(JSON.stringify({ from: '', ...msg }));
|
|
463
506
|
}
|
|
464
507
|
|
|
465
508
|
private handleError(code: VocaErrorCode | string, message: string) {
|