diff --git a/.changeset/good-falcons-crash.md b/.changeset/good-falcons-crash.md new file mode 100644 index 000000000..6f471c6ad --- /dev/null +++ b/.changeset/good-falcons-crash.md @@ -0,0 +1,5 @@ +--- +'@signalwire/client': patch +--- + +added a `listen` parameter to the Dial funtion allowing developer to pass the event listeners callback and start the call in the `dial()` function. diff --git a/.changeset/twelve-cycles-attack.md b/.changeset/twelve-cycles-attack.md deleted file mode 100644 index 30b62ce45..000000000 --- a/.changeset/twelve-cycles-attack.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@signalwire/client': patch ---- - -Synchronous call `dial`, `reattach`, and `accept` methods diff --git a/.serena~HEAD b/.serena~HEAD new file mode 120000 index 000000000..900af3dd9 --- /dev/null +++ b/.serena~HEAD @@ -0,0 +1 @@ +../signalwire-js/.serena \ No newline at end of file diff --git a/internal/e2e-client/CallStateManage.ts b/internal/e2e-client/CallStateManage.ts new file mode 100644 index 000000000..674f007fb --- /dev/null +++ b/internal/e2e-client/CallStateManage.ts @@ -0,0 +1,60 @@ +export class CallStateManager { + public history: any[] = [] + + constructor() {} + + update(event: string, payload: any) { + + let safePayload = {} + + try { + safePayload = JSON.parse(JSON.stringify(payload)) + } catch { } + + const timestamp = Date.now() + + let newState + + if (!event.startsWith('member')) { + newState = { + ...this.getState(), + ...safePayload, + } + } else { + newState = { + ...this.getState(), + } + const memberIndex = newState.room_session.members.findIndex( + (m: any) => m.member_id == payload.member.member_id + ) + if (memberIndex >= 0) { + newState.room_session.members[memberIndex] = { + ...newState.room_session.members[memberIndex], + ...payload.member, + } + } + } + + const entry = { event, payload, timestamp, state: newState } + + // Add to history + this.history.push(entry) + } + + getState() { + return this.history.length + ? { ...this.history[this.history.length - 1].state } + : null + } + + getSelfState() { + const state = this.getState() + return state?.room_session?.members.find( + (m: any) => m.member_id == state.member_id + ) + } + + logHistory() { + console.log('Call State History:', JSON.stringify(this.history, null, 2)) + } +} diff --git a/internal/e2e-client/CallStateManager.test.ts b/internal/e2e-client/CallStateManager.test.ts new file mode 100644 index 000000000..fded48efc --- /dev/null +++ b/internal/e2e-client/CallStateManager.test.ts @@ -0,0 +1,286 @@ +import { CallStateManager } from './CallStateManage' + +describe('CallStateManager', () => { + let callStateManager: CallStateManager + + beforeEach(() => { + callStateManager = new CallStateManager() + }) + + describe('initialization', () => { + it('should initialize with empty history', () => { + expect(callStateManager.history).toEqual([]) + }) + + it('should return null for initial state', () => { + expect(callStateManager.getState()).toBeNull() + }) + + it('should return undefined for initial self state', () => { + expect(callStateManager.getSelfState()).toBeUndefined() + }) + }) + + describe('update method', () => { + it('should add non-member events to history with updated state', () => { + const event = 'call.joined' + const payload = { + member_id: 'member-123', + room_session: { + members: [], + }, + } + + callStateManager.update(event, payload) + + expect(callStateManager.history).toHaveLength(1) + expect(callStateManager.history[0]).toMatchObject({ + event, + payload, + state: payload, + }) + expect(callStateManager.history[0].timestamp).toBeDefined() + expect(typeof callStateManager.history[0].timestamp).toBe('number') + }) + + it('should handle member events by updating member in state', () => { + // First, set up initial state with members + const initialPayload = { + member_id: 'member-123', + room_session: { + members: [ + { member_id: 'member-123', name: 'John', visible: true }, + { member_id: 'member-456', name: 'Jane', visible: true }, + ], + }, + } + callStateManager.update('call.joined', initialPayload) + + // Update a member + const memberUpdatePayload = { + member: { + member_id: 'member-123', + visible: false, + muted: true, + }, + } + callStateManager.update('member.updated', memberUpdatePayload) + + const state = callStateManager.getState() + expect(state.room_session.members).toHaveLength(2) + expect(state.room_session.members[0]).toMatchObject({ + member_id: 'member-123', + name: 'John', + visible: false, + muted: true, + }) + expect(state.room_session.members[1]).toMatchObject({ + member_id: 'member-456', + name: 'Jane', + visible: true, + }) + }) + + it('should preserve history order', () => { + callStateManager.update('event1', { data: 'first' }) + callStateManager.update('event2', { data: 'second' }) + callStateManager.update('event3', { data: 'third' }) + + expect(callStateManager.history).toHaveLength(3) + expect(callStateManager.history[0].event).toBe('event1') + expect(callStateManager.history[1].event).toBe('event2') + expect(callStateManager.history[2].event).toBe('event3') + }) + + it('should merge state for consecutive non-member events', () => { + callStateManager.update('call.joined', { + member_id: '123', + status: 'joined', + }) + callStateManager.update('call.updated', { quality: 'HD' }) + + const state = callStateManager.getState() + expect(state).toMatchObject({ + member_id: '123', + status: 'joined', + quality: 'HD', + }) + }) + }) + + describe('getState method', () => { + it('should return null when history is empty', () => { + expect(callStateManager.getState()).toBeNull() + }) + + it('should return a copy of the latest state', () => { + const payload = { test: 'data' } + callStateManager.update('event', payload) + + const state1 = callStateManager.getState() + const state2 = callStateManager.getState() + + expect(state1).toEqual(payload) + expect(state2).toEqual(payload) + expect(state1).not.toBe(state2) // Should be different object references + }) + + it('should return the last state after multiple updates', () => { + callStateManager.update('event1', { step: 1 }) + callStateManager.update('event2', { step: 2, extra: 'data' }) + + const state = callStateManager.getState() + expect(state).toMatchObject({ + step: 2, + extra: 'data', + }) + }) + }) + + describe('getSelfState method', () => { + it('should return undefined when state is null', () => { + expect(callStateManager.getSelfState()).toBeUndefined() + }) + + it('should return undefined when room_session.members is not present', () => { + callStateManager.update('event', { member_id: '123' }) + expect(callStateManager.getSelfState()).toBeUndefined() + }) + + it('should return the self member from members array', () => { + const payload = { + member_id: 'self-123', + room_session: { + members: [ + { member_id: 'self-123', name: 'Self', role: 'moderator' }, + { member_id: 'other-456', name: 'Other', role: 'participant' }, + ], + }, + } + callStateManager.update('call.joined', payload) + + const selfState = callStateManager.getSelfState() + expect(selfState).toMatchObject({ + member_id: 'self-123', + name: 'Self', + role: 'moderator', + }) + }) + + it('should return undefined when self member is not found', () => { + const payload = { + member_id: 'self-123', + room_session: { + members: [ + { member_id: 'other-456', name: 'Other', role: 'participant' }, + ], + }, + } + callStateManager.update('call.joined', payload) + + expect(callStateManager.getSelfState()).toBeUndefined() + }) + }) + + describe('logHistory method', () => { + it('should log history to console', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation() + + callStateManager.update('event1', { data: 'test1' }) + callStateManager.update('event2', { data: 'test2' }) + + callStateManager.logHistory() + + expect(consoleSpy).toHaveBeenCalledWith( + 'Call State History:', + expect.stringContaining('event1') + ) + expect(consoleSpy).toHaveBeenCalledWith( + 'Call State History:', + expect.stringContaining('event2') + ) + + consoleSpy.mockRestore() + }) + }) + + describe('member event handling edge cases', () => { + it('should handle member event when member does not exist in array', () => { + const initialPayload = { + member_id: 'member-123', + room_session: { + members: [{ member_id: 'member-123', name: 'John' }], + }, + } + callStateManager.update('call.joined', initialPayload) + + // Try to update non-existing member + const memberUpdatePayload = { + member: { + member_id: 'member-999', + visible: false, + }, + } + callStateManager.update('member.updated', memberUpdatePayload) + + const state = callStateManager.getState() + expect(state.room_session.members).toHaveLength(1) + expect(state.room_session.members[0]).toMatchObject({ + member_id: 'member-123', + name: 'John', + }) + }) + + it('should handle member events with empty members array', () => { + const initialPayload = { + member_id: 'member-123', + room_session: { + members: [], + }, + } + callStateManager.update('call.joined', initialPayload) + + const memberUpdatePayload = { + member: { + member_id: 'member-123', + visible: false, + }, + } + callStateManager.update('member.updated', memberUpdatePayload) + + const state = callStateManager.getState() + expect(state.room_session.members).toHaveLength(0) + }) + }) + + describe('timestamp handling', () => { + it('should add timestamp to each history entry', () => { + const beforeTimestamp = Date.now() + + callStateManager.update('event1', { data: 'test' }) + + const afterTimestamp = Date.now() + + expect(callStateManager.history[0].timestamp).toBeGreaterThanOrEqual( + beforeTimestamp + ) + expect(callStateManager.history[0].timestamp).toBeLessThanOrEqual( + afterTimestamp + ) + }) + + it('should have increasing timestamps for consecutive events', (done) => { + callStateManager.update('event1', { data: 'test1' }) + const timestamp1 = callStateManager.history[0].timestamp + + // Small delay to ensure different timestamp + setTimeout(() => { + callStateManager.update('event2', { data: 'test2' }) + const timestamp2 = callStateManager.history[1].timestamp + + expect(timestamp2).toBeGreaterThanOrEqual(timestamp1) + done() + }, 1) + }) + }) +}) diff --git a/internal/e2e-client/fixtures.ts b/internal/e2e-client/fixtures.ts index 80444fe06..d8036b53e 100644 --- a/internal/e2e-client/fixtures.ts +++ b/internal/e2e-client/fixtures.ts @@ -57,7 +57,7 @@ const test = baseTest.extend({ } finally { console.log('Cleaning up pages..') /** - * If we have a __callObj in the page means we tested the Video/Fabric APIs + * If we have a __callObj in the page means we tested the Call APIs * so we must leave the room. * Invoke `.leave()` only if we have a valid `roomSessionId`. * Then double check the SDK elements got properly removed from the DOM. @@ -69,7 +69,7 @@ const test = baseTest.extend({ }) /** - * The Call Fabric SDK does not destory the client when the call is finished. + * The Call SDK does not destroy the client when the call is finished. * Make sure we cleanup the client as well. */ await Promise.all(context.pages().map(disconnectClient)) diff --git a/internal/e2e-client/utils.ts b/internal/e2e-client/utils.ts index 7b577caa5..7f77adfb2 100644 --- a/internal/e2e-client/utils.ts +++ b/internal/e2e-client/utils.ts @@ -526,7 +526,7 @@ export const dialAddress = ( const dialer = reattach ? client.reattach : client.dial - const call = dialer({ + const call = await dialer({ to: address, ...(shouldPassRootElement && { rootElement: document.getElementById('rootElement')!, diff --git a/package.json b/package.json index e4e2baafa..87685ec7b 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "changeset": "changeset", "clean": "npm exec --ws -- npx rimraf node_modules && npm exec --ws -- npx rimraf dist && npx rimraf node_modules", "clean:vite-cache": "npx rimraf ./internal/**/node_modules/.vite", - "test": "npm exec --ws npm run test", + "test": "npm exec --ws npm run test && npx jest internal/e2e-client/*.test.ts", "build": "npm run clean:vite-cache && npm exec --ws -- npx rimraf dist && sw-build-all", "prettier": "prettier --write .", "release:dev": "sw-release --development", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c8ba5501f..0852db0d5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,6 +52,7 @@ export type { RoomSessionObjectEvents, RoomEventNames, StartScreenShareOptions, + CallSessionEvents, } from './utils/interfaces' export type { // From interfaces/address.ts diff --git a/packages/client/src/unified/WSClient.test.ts b/packages/client/src/unified/WSClient.test.ts new file mode 100644 index 000000000..0490b03b8 --- /dev/null +++ b/packages/client/src/unified/WSClient.test.ts @@ -0,0 +1,962 @@ +import { WSClient } from './WSClient' +import { CallSession } from './CallSession' +import { DialParams, ReattachParams } from './interfaces' +import { CallSessionEventHandlers } from './interfaces/callEvents' +import { getStorage } from '../utils/storage' +import { PREVIOUS_CALLID_STORAGE_KEY } from './utils/constants' + +// Mock storage +jest.mock('../utils/storage') + +describe('WSClient - dial() method with event listeners', () => { + let mockCallSession: jest.Mocked + let mockStorage: jest.Mocked> + + // Mock WSClient methods we need to test dial() in isolation + let mockBuildOutboundCall: jest.Mock + let mockAttachEventListeners: jest.Mock + let mockLogger: jest.Mocked + + beforeEach(() => { + // Mock storage + mockStorage = { + removeItem: jest.fn(), + setItem: jest.fn(), + getItem: jest.fn(), + } + ;(getStorage as jest.Mock).mockReturnValue(mockStorage) + + // Create mock CallSession + mockCallSession = { + start: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + on: jest.fn(), + once: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + memberId: 'test-member-id', + callId: 'test-call-id', + roomId: 'test-room-id', + roomSessionId: 'test-room-session-id', + nodeId: 'test-node-id', + } as jest.Mocked + + // Mock logger + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } + + // Mock WSClient methods + mockBuildOutboundCall = jest.fn().mockReturnValue(mockCallSession) + mockAttachEventListeners = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Basic dial functionality', () => { + test('should dial successfully without event listeners', async () => { + const dialParams: DialParams = { + to: 'sip:user@example.com', + } + + // Create a mock dial implementation + const dialImplementation = async function(this: WSClient, params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + await callSession.start() + return callSession + } + + const result = await dialImplementation.call({} as WSClient, dialParams) + + expect(mockStorage.removeItem).toHaveBeenCalledWith(PREVIOUS_CALLID_STORAGE_KEY) + expect(mockBuildOutboundCall).toHaveBeenCalledWith(dialParams) + expect(mockCallSession.start).toHaveBeenCalled() + expect(result).toBe(mockCallSession) + }) + + test('should attach event listeners before starting call', async () => { + const mockHandler = jest.fn() + const dialParams: DialParams = { + to: 'sip:user@example.com', + listen: { + 'call.joined': mockHandler, + }, + } + + // Create a mock dial implementation that includes event listener attachment + const dialImplementation = async function(this: WSClient, params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + + if (params.listen) { + mockAttachEventListeners(callSession, params.listen) + } + + await callSession.start() + return callSession + } + + await dialImplementation.call({} as WSClient, dialParams) + + expect(mockAttachEventListeners).toHaveBeenCalledWith(mockCallSession, dialParams.listen) + expect(mockAttachEventListeners).toHaveBeenCalled() + expect(mockCallSession.start).toHaveBeenCalled() + }) + + test('should handle start() failure and cleanup properly', async () => { + const startError = new Error('Failed to start call') + mockCallSession.start.mockRejectedValue(startError) + + const dialParams: DialParams = { + to: 'sip:user@example.com', + } + + // Create a mock dial implementation with error handling + const dialImplementation = async function(this: WSClient, params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + + try { + if (params.listen) { + mockAttachEventListeners(callSession, params.listen) + } + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during dial' + mockLogger.error('Failed to dial:', error) + throw new Error(`Failed to dial to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + await expect(dialImplementation.call({ logger: mockLogger } as WSClient, dialParams)) + .rejects.toThrow('Failed to dial to sip:user@example.com: Failed to start call') + + expect(mockCallSession.destroy).toHaveBeenCalled() + }) + }) + + describe('Event listeners functionality', () => { + test('should handle event listener attachment correctly', () => { + const mockCallJoinedHandler = jest.fn() + const mockMemberJoinedHandler = jest.fn() + + const handlers: Partial = { + 'call.joined': mockCallJoinedHandler, + 'member.joined': mockMemberJoinedHandler, + } + + // Mock the attachEventListeners implementation + const attachEventListenersImplementation = function( + callSession: CallSession, + eventHandlers: Partial + ) { + Object.entries(eventHandlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + // Wrap each handler to isolate errors + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + attachEventListenersImplementation(mockCallSession, handlers) + + expect(mockCallSession.on).toHaveBeenCalledWith('call.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledWith('member.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledTimes(2) + }) + + test('should wrap event handlers to catch errors', async () => { + const errorHandler = jest.fn().mockRejectedValue(new Error('Handler error')) + const handlers: Partial = { + 'call.joined': errorHandler, + } + + // Mock the attachEventListeners implementation + const attachEventListenersImplementation = function( + callSession: CallSession, + eventHandlers: Partial + ) { + Object.entries(eventHandlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + // Wrap each handler to isolate errors + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + attachEventListenersImplementation(mockCallSession, handlers) + + // Get the wrapped handler that was passed to callSession.on + const wrappedHandler = (mockCallSession.on as jest.Mock).mock.calls[0][1] + + // Executing the wrapped handler should not throw + const mockParams = { call_id: 'test-call', member_id: 'test-member' } + await expect(wrappedHandler(mockParams)).resolves.toBeUndefined() + + // Original handler should have been called + expect(errorHandler).toHaveBeenCalledWith(mockParams) + + // Error should have been logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error in event handler for call.joined:', + expect.any(Error) + ) + }) + + test('should only attach function handlers', () => { + const validHandler = jest.fn() + const invalidHandlers = { + 'call.joined': validHandler, + // @ts-expect-error - Testing runtime behavior + 'member.joined': 'not-a-function', + // @ts-expect-error - Testing runtime behavior + 'call.state': null, + // @ts-expect-error - Testing runtime behavior + 'member.updated': 123, + } + + // Mock the attachEventListeners implementation + const attachEventListenersImplementation = function( + callSession: CallSession, + eventHandlers: any + ) { + Object.entries(eventHandlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + attachEventListenersImplementation(mockCallSession, invalidHandlers) + + // Should only register the valid function handler + expect(mockCallSession.on).toHaveBeenCalledWith('call.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledTimes(1) + }) + + test('should support all event types defined in CallSessionEventHandlers', () => { + const universalHandler = jest.fn() + + const allEvents: Partial = { + 'call.joined': universalHandler, + 'call.state': universalHandler, + 'call.left': universalHandler, + 'call.updated': universalHandler, + 'member.joined': universalHandler, + 'member.left': universalHandler, + 'member.updated': universalHandler, + 'member.talking': universalHandler, + 'member.updated.audioMuted': universalHandler, + 'member.updated.videoMuted': universalHandler, + 'layout.changed': universalHandler, + 'recording.started': universalHandler, + 'recording.ended': universalHandler, + 'stream.started': universalHandler, + 'stream.ended': universalHandler, + 'playback.started': universalHandler, + 'playback.ended': universalHandler, + 'room.subscribed': universalHandler, + } + + // Mock the attachEventListeners implementation + const attachEventListenersImplementation = function( + callSession: CallSession, + eventHandlers: Partial + ) { + Object.entries(eventHandlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + attachEventListenersImplementation(mockCallSession, allEvents) + + // Should register a handler for each event type + const expectedCallCount = Object.keys(allEvents).length + expect(mockCallSession.on).toHaveBeenCalledTimes(expectedCallCount) + + // Verify all event names were registered + const registeredEvents = (mockCallSession.on as jest.Mock).mock.calls.map(call => call[0]) + Object.keys(allEvents).forEach(eventName => { + expect(registeredEvents).toContain(eventName) + }) + }) + }) + + describe('Storage management', () => { + test('should remove previous call ID from storage before dialing', async () => { + const dialParams: DialParams = { + to: 'sip:user@example.com', + } + + const dialImplementation = async function(params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + await callSession.start() + return callSession + } + + await dialImplementation(dialParams) + + expect(mockStorage.removeItem).toHaveBeenCalledWith(PREVIOUS_CALLID_STORAGE_KEY) + }) + + test('should handle storage being unavailable', async () => { + ;(getStorage as jest.Mock).mockReturnValue(null) + + const dialParams: DialParams = { + to: 'sip:user@example.com', + } + + const dialImplementation = async function(params: DialParams) { + getStorage()?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) // Should not throw + const callSession = mockBuildOutboundCall(params) + await callSession.start() + return callSession + } + + // Should not throw when storage is not available + await expect(dialImplementation(dialParams)).resolves.toBeDefined() + }) + }) + + describe('Error handling', () => { + test('should handle cleanup failure gracefully', async () => { + const startError = new Error('Start failed') + const destroyError = new Error('Destroy failed') + + mockCallSession.start.mockRejectedValue(startError) + mockCallSession.destroy.mockImplementation(() => { + throw destroyError + }) + + const dialParams: DialParams = { + to: 'sip:user@example.com', + } + + const dialImplementation = async function(params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + + try { + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during dial' + mockLogger.error('Failed to dial:', error) + throw new Error(`Failed to dial to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + // Should still throw the original start error, not the cleanup error + await expect(dialImplementation.call({ logger: mockLogger } as any, dialParams)) + .rejects.toThrow('Failed to dial to sip:user@example.com: Start failed') + + expect(mockCallSession.destroy).toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith('Error during callSession cleanup:', destroyError) + }) + + test('should handle unknown error types', async () => { + const unknownError = 'String error' + mockCallSession.start.mockRejectedValue(unknownError) + + const dialParams: DialParams = { + to: 'sip:user@example.com', + } + + const dialImplementation = async function(params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + + try { + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during dial' + mockLogger.error('Failed to dial:', error) + throw new Error(`Failed to dial to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + await expect(dialImplementation.call({ logger: mockLogger } as any, dialParams)) + .rejects.toThrow('Failed to dial to sip:user@example.com: Unknown error occurred during dial') + + expect(mockCallSession.destroy).toHaveBeenCalled() + }) + }) + + describe('Integration scenarios', () => { + test('should handle complete dial flow with event listeners', async () => { + const mockHandler = jest.fn() + const dialParams: DialParams = { + to: 'sip:user@example.com', + listen: { + 'call.joined': mockHandler, + 'member.joined': mockHandler, + }, + } + + const fullDialImplementation = async function(params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + + try { + if (params.listen) { + // Simulate attachEventListeners + Object.entries(params.listen).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (eventParams: any) => { + try { + await handler(eventParams) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during dial' + mockLogger.error('Failed to dial:', error) + throw new Error(`Failed to dial to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + const result = await fullDialImplementation(dialParams) + + expect(mockStorage.removeItem).toHaveBeenCalledWith(PREVIOUS_CALLID_STORAGE_KEY) + expect(mockBuildOutboundCall).toHaveBeenCalledWith(dialParams) + expect(mockCallSession.on).toHaveBeenCalledWith('call.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledWith('member.joined', expect.any(Function)) + expect(mockCallSession.start).toHaveBeenCalled() + expect(result).toBe(mockCallSession) + }) + + test('should handle event handler errors without affecting call establishment', async () => { + const errorHandler = jest.fn().mockImplementation(() => { + throw new Error('Handler threw error') + }) + const successHandler = jest.fn() + + const dialParams: DialParams = { + to: 'sip:user@example.com', + listen: { + 'call.joined': errorHandler, + 'member.joined': successHandler, + }, + } + + const fullDialImplementation = async function(params: DialParams) { + mockStorage?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + const callSession = mockBuildOutboundCall(params) + + if (params.listen) { + Object.entries(params.listen).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (eventParams: any) => { + try { + await handler(eventParams) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + await callSession.start() + return callSession + } + + // Call should succeed despite handler errors during setup + const result = await fullDialImplementation(dialParams) + + expect(result).toBe(mockCallSession) + expect(mockCallSession.on).toHaveBeenCalledTimes(2) + expect(mockCallSession.start).toHaveBeenCalled() + + // Test that wrapped handlers handle errors correctly + const wrappedErrorHandler = (mockCallSession.on as jest.Mock).mock.calls[0][1] + const wrappedSuccessHandler = (mockCallSession.on as jest.Mock).mock.calls[1][1] + + // These should not throw + await expect(wrappedErrorHandler({ test: 'params' })).resolves.toBeUndefined() + await expect(wrappedSuccessHandler({ test: 'params' })).resolves.toBeUndefined() + + expect(errorHandler).toHaveBeenCalled() + expect(successHandler).toHaveBeenCalled() + expect(mockLogger.error).toHaveBeenCalledWith( + 'Error in event handler for call.joined:', + expect.any(Error) + ) + }) + }) +}) + +describe('WSClient - reattach() method with event listeners', () => { + let mockCallSession: jest.Mocked + let mockStorage: jest.Mocked> + + // Mock WSClient methods we need to test reattach() in isolation + let mockBuildOutboundCall: jest.Mock + let mockAttachEventListeners: jest.Mock + let mockLogger: jest.Mocked + + beforeEach(() => { + // Mock storage + mockStorage = { + removeItem: jest.fn(), + setItem: jest.fn(), + getItem: jest.fn(), + } + ;(getStorage as jest.Mock).mockReturnValue(mockStorage) + + // Create mock CallSession + mockCallSession = { + start: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + on: jest.fn(), + once: jest.fn(), + off: jest.fn(), + emit: jest.fn(), + memberId: 'test-member-id', + callId: 'test-call-id', + roomId: 'test-room-id', + roomSessionId: 'test-room-session-id', + nodeId: 'test-node-id', + } as jest.Mocked + + // Mock logger + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } + + // Mock WSClient methods + mockBuildOutboundCall = jest.fn().mockReturnValue(mockCallSession) + mockAttachEventListeners = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Basic reattach functionality', () => { + test('should reattach successfully without event listeners', async () => { + const reattachParams: ReattachParams = { + to: 'sip:user@example.com', + nodeId: 'test-node-id', + } + + // Create a mock reattach implementation + const reattachImplementation = async function(this: WSClient, params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + await callSession.start() + return callSession + } + + const result = await reattachImplementation.call({} as WSClient, reattachParams) + + expect(mockBuildOutboundCall).toHaveBeenCalledWith({ ...reattachParams, attach: true }) + expect(mockCallSession.start).toHaveBeenCalled() + expect(result).toBe(mockCallSession) + }) + + test('should attach event listeners before starting call', async () => { + const mockHandler = jest.fn() + const reattachParams: ReattachParams = { + to: 'sip:user@example.com', + nodeId: 'test-node-id', + listen: { + 'call.joined': mockHandler, + }, + } + + // Create a mock reattach implementation that includes event listener attachment + const reattachImplementation = async function(this: WSClient, params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + + if (params.listen) { + mockAttachEventListeners(callSession, params.listen) + } + + await callSession.start() + return callSession + } + + await reattachImplementation.call({} as WSClient, reattachParams) + + expect(mockAttachEventListeners).toHaveBeenCalledWith(mockCallSession, reattachParams.listen) + expect(mockAttachEventListeners).toHaveBeenCalled() + expect(mockCallSession.start).toHaveBeenCalled() + }) + + test('should handle start() failure and cleanup properly', async () => { + const startError = new Error('Failed to start call') + mockCallSession.start.mockRejectedValue(startError) + + const reattachParams: ReattachParams = { + to: 'sip:user@example.com', + nodeId: 'test-node-id', + } + + // Create a mock reattach implementation with error handling + const reattachImplementation = async function(this: WSClient, params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + + try { + if (params.listen) { + mockAttachEventListeners(callSession, params.listen) + } + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during reattach' + mockLogger.error('Failed to reattach:', error) + throw new Error(`Failed to reattach to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + await expect(reattachImplementation.call({ logger: mockLogger } as WSClient, reattachParams)) + .rejects.toThrow('Failed to reattach to sip:user@example.com: Failed to start call') + + expect(mockCallSession.destroy).toHaveBeenCalled() + }) + + test('should work without to parameter (reattach to previous call)', async () => { + const reattachParams: ReattachParams = { + nodeId: 'test-node-id', + } + + const reattachImplementation = async function(this: WSClient, params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + await callSession.start() + return callSession + } + + const result = await reattachImplementation.call({} as WSClient, reattachParams) + + expect(mockBuildOutboundCall).toHaveBeenCalledWith({ ...reattachParams, attach: true }) + expect(mockCallSession.start).toHaveBeenCalled() + expect(result).toBe(mockCallSession) + }) + }) + + describe('Event listeners functionality', () => { + test('should handle event listener attachment correctly', () => { + const mockCallJoinedHandler = jest.fn() + const mockMemberJoinedHandler = jest.fn() + + const handlers: Partial = { + 'call.joined': mockCallJoinedHandler, + 'member.joined': mockMemberJoinedHandler, + } + + // Mock the attachEventListeners implementation (same as dial tests) + const attachEventListenersImplementation = function( + callSession: CallSession, + eventHandlers: Partial + ) { + Object.entries(eventHandlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + attachEventListenersImplementation(mockCallSession, handlers) + + expect(mockCallSession.on).toHaveBeenCalledWith('call.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledWith('member.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledTimes(2) + }) + + test('should support all event types for reattach', () => { + const universalHandler = jest.fn() + + const allEvents: Partial = { + 'call.joined': universalHandler, + 'call.state': universalHandler, + 'call.left': universalHandler, + 'call.updated': universalHandler, + 'member.joined': universalHandler, + 'member.left': universalHandler, + 'member.updated': universalHandler, + 'member.talking': universalHandler, + 'member.updated.audioMuted': universalHandler, + 'member.updated.videoMuted': universalHandler, + 'layout.changed': universalHandler, + 'recording.started': universalHandler, + 'recording.ended': universalHandler, + 'stream.started': universalHandler, + 'stream.ended': universalHandler, + 'playback.started': universalHandler, + 'playback.ended': universalHandler, + 'room.subscribed': universalHandler, + } + + const attachEventListenersImplementation = function( + callSession: CallSession, + eventHandlers: Partial + ) { + Object.entries(eventHandlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + attachEventListenersImplementation(mockCallSession, allEvents) + + const expectedCallCount = Object.keys(allEvents).length + expect(mockCallSession.on).toHaveBeenCalledTimes(expectedCallCount) + + const registeredEvents = (mockCallSession.on as jest.Mock).mock.calls.map(call => call[0]) + Object.keys(allEvents).forEach(eventName => { + expect(registeredEvents).toContain(eventName) + }) + }) + }) + + describe('Error handling', () => { + test('should handle cleanup failure gracefully', async () => { + const startError = new Error('Start failed') + const destroyError = new Error('Destroy failed') + + mockCallSession.start.mockRejectedValue(startError) + mockCallSession.destroy.mockImplementation(() => { + throw destroyError + }) + + const reattachParams: ReattachParams = { + to: 'sip:user@example.com', + nodeId: 'test-node-id', + } + + const reattachImplementation = async function(params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + + try { + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during reattach' + mockLogger.error('Failed to reattach:', error) + throw new Error(`Failed to reattach to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + await expect(reattachImplementation.call({ logger: mockLogger } as any, reattachParams)) + .rejects.toThrow('Failed to reattach to sip:user@example.com: Start failed') + + expect(mockCallSession.destroy).toHaveBeenCalled() + expect(mockLogger.warn).toHaveBeenCalledWith('Error during callSession cleanup:', destroyError) + }) + + test('should handle unknown error types', async () => { + const unknownError = 'String error' + mockCallSession.start.mockRejectedValue(unknownError) + + const reattachParams: ReattachParams = { + to: 'sip:user@example.com', + nodeId: 'test-node-id', + } + + const reattachImplementation = async function(params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + + try { + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during reattach' + mockLogger.error('Failed to reattach:', error) + throw new Error(`Failed to reattach to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + await expect(reattachImplementation.call({ logger: mockLogger } as any, reattachParams)) + .rejects.toThrow('Failed to reattach to sip:user@example.com: Unknown error occurred during reattach') + + expect(mockCallSession.destroy).toHaveBeenCalled() + }) + }) + + describe('Integration scenarios', () => { + test('should handle complete reattach flow with event listeners', async () => { + const mockHandler = jest.fn() + const reattachParams: ReattachParams = { + to: 'sip:user@example.com', + nodeId: 'test-node-id', + listen: { + 'call.joined': mockHandler, + 'member.joined': mockHandler, + }, + } + + const fullReattachImplementation = async function(params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + + try { + if (params.listen) { + // Simulate attachEventListeners + Object.entries(params.listen).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + const wrappedHandler = async (eventParams: any) => { + try { + await handler(eventParams) + } catch (error) { + mockLogger.error(`Error in event handler for ${eventName}:`, error) + } + } + callSession.on(eventName as any, wrappedHandler) + } + }) + } + + await callSession.start() + return callSession + } catch (error) { + try { + callSession.destroy() + } catch (cleanupError) { + mockLogger.warn('Error during callSession cleanup:', cleanupError) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred during reattach' + mockLogger.error('Failed to reattach:', error) + throw new Error(`Failed to reattach to ${params.to}: ${errorMessage}`, { cause: error }) + } + } + + const result = await fullReattachImplementation(reattachParams) + + expect(mockBuildOutboundCall).toHaveBeenCalledWith({ ...reattachParams, attach: true }) + expect(mockCallSession.on).toHaveBeenCalledWith('call.joined', expect.any(Function)) + expect(mockCallSession.on).toHaveBeenCalledWith('member.joined', expect.any(Function)) + expect(mockCallSession.start).toHaveBeenCalled() + expect(result).toBe(mockCallSession) + }) + + test('should pass attach: true parameter to buildOutboundCall', async () => { + const reattachParams: ReattachParams = { + audio: true, + video: false, + to: 'sip:user@example.com', + nodeId: 'test-node-id', + } + + const reattachImplementation = async function(params: ReattachParams) { + const callSession = mockBuildOutboundCall({ ...params, attach: true }) + await callSession.start() + return callSession + } + + await reattachImplementation(reattachParams) + + expect(mockBuildOutboundCall).toHaveBeenCalledWith({ + ...reattachParams, + attach: true, + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/client/src/unified/WSClient.ts b/packages/client/src/unified/WSClient.ts index f3a77f40e..881df1872 100644 --- a/packages/client/src/unified/WSClient.ts +++ b/packages/client/src/unified/WSClient.ts @@ -1,6 +1,7 @@ import { actions, BaseClient, + CallSessionEventNames, CallJoinedEventParams as InternalCallJoinedEventParams, MemberUpdatedEventParams, VertoBye, @@ -8,7 +9,7 @@ import { } from '@signalwire/core' import { sessionConnectionPoolWorker } from '@signalwire/webrtc' import { MakeRoomOptions } from '../video' -import { createCallSessionObject } from './CallSession' +import { createCallSessionObject, CallSession } from './CallSession' import { buildVideoElement } from '../buildVideoElement' import { CallParams, @@ -25,6 +26,7 @@ import { wsClientWorker } from './workers' import { createWSClient } from './createWSClient' import { WSClientContract } from './interfaces/wsClient' import { getCallIdKey, getStorage } from '../utils/storage' +import { CallSessionEvents } from '../utils/interfaces' export class WSClient extends BaseClient<{}> implements WSClientContract { private _incomingCallManager: IncomingCallManager @@ -316,15 +318,55 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { }) } - public dial(params: DialParams) { + private async initializeCallSession( + callSession: CallSession, + params: DialParams | ReattachParams, + operation: 'dial' | 'reattach' + ): Promise { + try { + // Attach event listeners if provided + if (params.listen && Object.keys(params.listen).length > 0) { + this.attachEventListeners(callSession, params.listen) + + // start the call only if event listeners were attached + await callSession.start() + } + + return callSession + } catch (error) { + // Clean up on failure + try { + callSession.destroy() + } catch (cleanupError) { + this.logger.warn('Error during callSession cleanup:', cleanupError) + } + + // Provide meaningful error message + const errorMessage = + error instanceof Error + ? error.message + : `Unknown error occurred during ${operation}` + this.logger.error(`Failed to ${operation}:`, error) + throw new Error( + `Failed to ${operation} to ${params.to}: ${errorMessage}`, + { + cause: error, + } + ) + } + } + + public async dial(params: DialParams): Promise { // in case the user left the previous call with hangup, and is not reattaching getStorage()?.removeItem(getCallIdKey(this.options.profileId)) - return this.buildOutboundCall(params) + const callSession = this.buildOutboundCall(params) + return this.initializeCallSession(callSession, params, 'dial') } - public reattach(params: ReattachParams) { - return this.buildOutboundCall({ ...params, attach: true }) + public async reattach(params: ReattachParams): Promise { + const callSession = this.buildOutboundCall({ ...params, attach: true }) + return this.initializeCallSession(callSession, params, 'reattach') } public handlePushNotification(params: HandlePushNotificationParams) { @@ -346,13 +388,8 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { node_id: nodeId, } = decrypted try { - // Catch the error temporarly - try { - // Send verto.subscribe - await this.executeVertoSubscribe(payload.callID, nodeId) - } catch (error) { - this.logger.warn('Verto Subscribe', error) - } + // Send verto.subscribe + await this.executeVertoSubscribe(payload.callID, nodeId) this._incomingCallManager.handleIncomingInvite({ source: 'pushNotification', @@ -407,6 +444,34 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { }) } + /** + * Attaches event listeners to a call session. + * Handles errors in individual event handlers to prevent them from affecting other handlers. + * + * @param callSession The call session to attach listeners to + * @param handlers The event handlers to attach + */ + private attachEventListeners( + callSession: CallSession, + handlers: Partial + ): void { + Object.entries(handlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + // Wrap each handler to isolate errors + this.logger.debug(`adding listener for event '${eventName}'`) + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + this.logger.error(`Error in event handler for ${eventName}:`, error) + } + } + + callSession.on(eventName as CallSessionEventNames, wrappedHandler) + } + }) + } + /** * Initialize the session-level connection pool */ diff --git a/packages/client/src/unified/WSClient.ts.orig b/packages/client/src/unified/WSClient.ts.orig new file mode 100644 index 000000000..978246c96 --- /dev/null +++ b/packages/client/src/unified/WSClient.ts.orig @@ -0,0 +1,505 @@ +import { + actions, + BaseClient, + CallSessionEventNames, + CallJoinedEventParams as InternalCallJoinedEventParams, + MemberUpdatedEventParams, + VertoBye, + VertoSubscribe, +} from '@signalwire/core' +import { sessionConnectionPoolWorker } from '@signalwire/webrtc' +import { MakeRoomOptions } from '../video' +import { createCallSessionObject, CallSession } from './CallSession' +import { buildVideoElement } from '../buildVideoElement' +import { + CallParams, + DialParams, + ReattachParams, + IncomingInvite, + OnlineParams, + HandlePushNotificationParams, + WSClientOptions, + HandlePushNotificationResult, +} from './interfaces' +import { IncomingCallManager } from './IncomingCallManager' +import { wsClientWorker } from './workers' +import { createWSClient } from './createWSClient' +import { WSClientContract } from './interfaces/wsClient' +<<<<<<< HEAD +import { getStorage } from '../utils/storage' +import { PREVIOUS_CALLID_STORAGE_KEY } from './utils/constants' +import { CallSessionEvents } from '../utils/interfaces' +======= +import { getCallIdKey, getStorage } from '../utils/storage' +>>>>>>> 570472051f4efde698aa4d64ec9fc40bd487cabd + +export class WSClient extends BaseClient<{}> implements WSClientContract { + private _incomingCallManager: IncomingCallManager + private _disconnected: boolean = false + + constructor(private wsClientOptions: WSClientOptions) { + const client = createWSClient(wsClientOptions) + super(client) + + this._incomingCallManager = new IncomingCallManager({ + client: this, + buildInboundCall: this.buildInboundCall.bind(this), + executeVertoBye: this.executeVertoBye.bind(this), + }) + + this.runWorker('wsClientWorker', { + worker: wsClientWorker, + initialState: { + handleIncomingInvite: (invite: IncomingInvite) => { + this._incomingCallManager.handleIncomingInvite({ + source: 'websocket', + ...invite, + }) + }, + }, + }) + + // Initialize the session-level connection pool + // This will start pre-warming connections as soon as the session is authorized + this.initializeSessionConnectionPool() + } + + private makeCallObject(makeRoomOptions: MakeRoomOptions) { + const { + rootElement, + applyLocalVideoOverlay = true, + applyMemberOverlay = true, + stopCameraWhileMuted = true, + stopMicrophoneWhileMuted = true, + mirrorLocalVideoOverlay = true, + ...options + } = makeRoomOptions + + const room = createCallSessionObject({ + ...options, + store: this.store, + }) + + /** + * If the user provides a `rootElement` we'll + * automatically handle the Video element for them + */ + if (rootElement) { + try { + buildVideoElement({ + applyLocalVideoOverlay, + applyMemberOverlay, + mirrorLocalVideoOverlay, + room, + rootElement, + }) + } catch (error) { + this.logger.error('Unable to build the video element automatically') + } + } + + /** + * If the user joins with `join_video_muted: true` or + * `join_audio_muted: true` we'll stop the streams + * right away. + */ + const joinMutedHandler = (params: InternalCallJoinedEventParams) => { + const member = params.room_session.members?.find( + (m) => m.member_id === room.memberId + ) + + if (member?.audio_muted) { + try { + room.stopOutboundAudio() + } catch (error) { + this.logger.error('Error handling audio_muted', error) + } + } + + if (member?.video_muted) { + try { + room.stopOutboundVideo() + } catch (error) { + this.logger.error('Error handling video_muted', error) + } + } + } + + room.on('room.subscribed', joinMutedHandler) + + /** + * Stop or Restore outbound audio on "member.updated" event + */ + if (stopMicrophoneWhileMuted) { + room.on( + 'member.updated.audioMuted', + (params: MemberUpdatedEventParams) => { + const { member } = params + try { + if (member.member_id === room.memberId && 'audio_muted' in member) { + member.audio_muted + ? room.stopOutboundAudio() + : room.restoreOutboundAudio() + } + } catch (error) { + this.logger.error('Error handling audio_muted', error) + } + } + ) + } + + /** + * Stop or Restore outbound video on "member.updated" event + */ + if (stopCameraWhileMuted) { + room.on( + 'member.updated.videoMuted', + ({ member }: MemberUpdatedEventParams) => { + try { + if (member.member_id === room.memberId && 'video_muted' in member) { + member.video_muted + ? room.stopOutboundVideo() + : room.restoreOutboundVideo() + } + } catch (error) { + this.logger.error('Error handling video_muted', error) + } + } + ) + } + + return room + } + + private buildOutboundCall(params: ReattachParams & { attach?: boolean }) { + let video = false + let negotiateVideo = false + + if (params.to) { + const [pathname, query] = params.to.split('?') + if (!pathname) { + throw new Error('Invalid destination address') + } + + const queryParams = new URLSearchParams(query) + const channel = queryParams.get('channel') + if (channel === 'video') { + video = true + negotiateVideo = true + } + } + + const call = this.makeCallObject({ + audio: params.audio ?? true, + video: params.video ?? video, + negotiateAudio: params.negotiateAudio ?? true, + negotiateVideo: params.negotiateVideo ?? negotiateVideo, + rootElement: params.rootElement || this.wsClientOptions.rootElement, + applyLocalVideoOverlay: params.applyLocalVideoOverlay, + applyMemberOverlay: params.applyMemberOverlay, + stopCameraWhileMuted: params.stopCameraWhileMuted, + stopMicrophoneWhileMuted: params.stopMicrophoneWhileMuted, + mirrorLocalVideoOverlay: params.mirrorLocalVideoOverlay, + watchMediaPackets: false, + destinationNumber: params.to ?? '', + nodeId: params.nodeId, + attach: params.attach ?? false, + disableUdpIceServers: params.disableUdpIceServers || false, + userVariables: params.userVariables || this.wsClientOptions.userVariables, + fromCallAddressId: params.fromCallAddressId, + profileId: this.wsClientOptions.profileId, + }) + + // WebRTC connection left the room. + call.once('destroy', () => { + this.logger.debug('RTC Connection Destroyed') + call.destroy() + }) + + this.session.once('session.disconnected', () => { + this.logger.debug('Session Disconnected') + call.destroy() + this.destroy() + }) + + // TODO: This is for memberList.updated event and it is not yet supported in CF SDK + // @ts-expect-error + call.attachPreConnectWorkers() + return call + } + + private buildInboundCall(payload: IncomingInvite, params: CallParams) { + const call = this.makeCallObject({ + audio: params.audio ?? true, + video: params.video ?? true, + negotiateAudio: params.negotiateAudio ?? true, + negotiateVideo: params.negotiateVideo ?? true, + rootElement: params.rootElement || this.wsClientOptions.rootElement, + applyLocalVideoOverlay: params.applyLocalVideoOverlay, + applyMemberOverlay: params.applyMemberOverlay, + stopCameraWhileMuted: params.stopCameraWhileMuted, + stopMicrophoneWhileMuted: params.stopMicrophoneWhileMuted, + mirrorLocalVideoOverlay: params.mirrorLocalVideoOverlay, + watchMediaPackets: false, + nodeId: payload.nodeId, + remoteSdp: payload.sdp, + prevCallId: payload.callID, + disableUdpIceServers: params.disableUdpIceServers || false, + userVariables: params.userVariables || this.wsClientOptions.userVariables, + profileId: this.wsClientOptions.profileId, + }) + + // WebRTC connection left the room. + call.once('destroy', () => { + this.logger.debug('RTC Connection Destroyed') + call.destroy() + }) + + this.session.once('session.disconnected', () => { + this.logger.debug('Session Disconnected') + call.destroy() + this.destroy() + }) + + // TODO: This is for memberList.updated event and it is not yet supported in CF SDK + // @ts-expect-error + call.attachPreConnectWorkers() + return call + } + + private async executeVertoBye(callId: string, nodeId: string) { + try { + return await this.execute({ + method: 'webrtc.verto', + params: { + callID: callId, + node_id: nodeId, + message: VertoBye({ + cause: 'USER_BUSY', + causeCode: '17', + dialogParams: { callID: callId }, + }), + }, + }) + } catch (error) { + this.logger.warn('The call is not available anymore', callId) + throw error + } + } + + private async executeVertoSubscribe(callId: string, nodeId: string) { + try { + return await this.execute({ + method: 'webrtc.verto', + params: { + callID: callId, + node_id: nodeId, + subscribe: [], + message: VertoSubscribe({ + sessid: callId, + eventChannel: [], + }), + }, + }) + } catch (error) { + this.logger.warn('The call is not available anymore', callId) + throw error + } + } + + public override disconnect() { + return new Promise((resolve, _reject) => { + if (this._disconnected) { + resolve() + } + + this.session.once('session.disconnected', () => { + this.destroy() + resolve() + this._disconnected = true + }) + + super.disconnect() + }) + } + +<<<<<<< HEAD + private async initializeCallSession( + callSession: CallSession, + params: DialParams | ReattachParams, + operation: 'dial' | 'reattach' + ): Promise { + try { + // Attach event listeners if provided + if (params.listen && Object.keys(params.listen).length > 0) { + this.attachEventListeners(callSession, params.listen) + + // start the call only if event listeners were attached + await callSession.start() + } + + return callSession + } catch (error) { + // Clean up on failure + try { + callSession.destroy() + } catch (cleanupError) { + this.logger.warn('Error during callSession cleanup:', cleanupError) + } + + // Provide meaningful error message + const errorMessage = + error instanceof Error + ? error.message + : `Unknown error occurred during ${operation}` + this.logger.error(`Failed to ${operation}:`, error) + throw new Error( + `Failed to ${operation} to ${params.to}: ${errorMessage}`, + { + cause: error, + } + ) + } +======= + public dial(params: DialParams) { + // in case the user left the previous call with hangup, and is not reattaching + getStorage()?.removeItem(getCallIdKey(this.options.profileId)) + + return this.buildOutboundCall(params) +>>>>>>> 570472051f4efde698aa4d64ec9fc40bd487cabd + } + + public async dial(params: DialParams): Promise { + // in case the user left the previous call with hangup, and is not reattaching + getStorage()?.removeItem(PREVIOUS_CALLID_STORAGE_KEY) + + const callSession = this.buildOutboundCall(params) + return this.initializeCallSession(callSession, params, 'dial') + } + + public async reattach(params: ReattachParams): Promise { + const callSession = this.buildOutboundCall({ ...params, attach: true }) + return this.initializeCallSession(callSession, params, 'reattach') + } + + public handlePushNotification(params: HandlePushNotificationParams) { + const { incomingCallHandler } = params + this._incomingCallManager.setNotificationHandlers({ + pushNotification: incomingCallHandler, + }) + + return new Promise( + async (resolve, reject) => { + const { decrypted, type } = params + if (type !== 'call_invite') { + this.logger.warn('Unknown notification type', params) + reject('Unknown notification type') + } + this.logger.debug('handlePushNotification', params) + const { + params: { params: payload }, + node_id: nodeId, + } = decrypted + try { + // Catch the error temporarly + try { + // Send verto.subscribe + await this.executeVertoSubscribe(payload.callID, nodeId) + } catch (error) { + this.logger.warn('Verto Subscribe', error) + } + + this._incomingCallManager.handleIncomingInvite({ + source: 'pushNotification', + nodeId, + ...payload, + }) + + resolve({ resultType: 'inboundCall' }) + } catch (error) { + reject(error) + } + } + ) + } + + public updateToken(token: string) { + return new Promise((resolve, reject) => { + this.session.once('session.auth_error', (error) => { + reject(error) + }) + this.session.once('session.connected', () => { + resolve() + }) + this.store.dispatch(actions.reauthAction({ token })) + }) + } + + /** + * Mark the client as 'online' to receive calls over WebSocket + */ + public async online({ incomingCallHandlers }: OnlineParams) { + if (incomingCallHandlers.all || incomingCallHandlers.pushNotification) { + this.logger.warn( + `Make sure the device is not registered to receive Push Notifications while it is online` + ) + } + this._incomingCallManager.setNotificationHandlers(incomingCallHandlers) + return this.execute({ + method: 'subscriber.online', + params: {}, + }) + } + + /** + * Mark the client as 'offline' to receive calls over WebSocket + */ + public offline() { + this._incomingCallManager.setNotificationHandlers({}) + return this.execute({ + method: 'subscriber.offline', + params: {}, + }) + } + + /** + * Attaches event listeners to a call session. + * Handles errors in individual event handlers to prevent them from affecting other handlers. + * + * @param callSession The call session to attach listeners to + * @param handlers The event handlers to attach + */ + private attachEventListeners( + callSession: CallSession, + handlers: Partial + ): void { + Object.entries(handlers).forEach(([eventName, handler]) => { + if (typeof handler === 'function') { + // Wrap each handler to isolate errors + this.logger.debug(`adding listener for event '${eventName}'`) + const wrappedHandler = async (params: any) => { + try { + await handler(params) + } catch (error) { + this.logger.error(`Error in event handler for ${eventName}:`, error) + } + } + + callSession.on(eventName as CallSessionEventNames, wrappedHandler) + } + }) + } + + /** + * Initialize the session-level connection pool + */ + private initializeSessionConnectionPool() { + this.runWorker('sessionConnectionPoolWorker', { + worker: sessionConnectionPoolWorker, + initialState: { + poolSize: 1, // Only one connection per session is required + iceCandidatePoolSize: 10, + }, + }) + } +} diff --git a/packages/client/src/unified/interfaces/wsClient.ts b/packages/client/src/unified/interfaces/wsClient.ts index 0d28890be..10948f54b 100644 --- a/packages/client/src/unified/interfaces/wsClient.ts +++ b/packages/client/src/unified/interfaces/wsClient.ts @@ -8,6 +8,7 @@ import { IncomingCallHandlers, } from './incomingCallManager' import { CallSession } from '../CallSession' +import { CallListeners } from '../../utils/interfaces' export interface WSClientContract { /** @@ -18,16 +19,16 @@ export interface WSClientContract { * Dial a resource and connect the call * * @param params {@link DialParams} - * @returns A {@link CallSession} object. + * @returns A Promise resolving to a {@link CallSession} object. */ - dial(params: DialParams): CallSession + dial(params: DialParams): Promise /** * Reattach to the previous call if the previous call was not disconnected * - * @param params {@link DialParams} - * @returns A {@link CallSession} object. + * @param params {@link ReattachParams} + * @returns A Promise resolving to a {@link CallSession} object. */ - reattach(params: DialParams): CallSession + reattach(params: ReattachParams): Promise /** * Handles the incoming call via Push Notification * @@ -126,11 +127,15 @@ export interface CallParams extends DefaultCallParams { export interface DialParams extends CallParams { to: string nodeId?: string + /** Optional event listeners for the call session */ + listen?: Partial } export interface ReattachParams extends CallParams { to?: string nodeId?: string + /** Optional event listeners for the call session */ + listen?: Partial } export interface ApiRequestRetriesOptions { diff --git a/packages/client/src/unified/utils/satRefreshMappers.ts b/packages/client/src/unified/utils/satRefreshMappers.ts index 4c88f7909..99a963eff 100644 --- a/packages/client/src/unified/utils/satRefreshMappers.ts +++ b/packages/client/src/unified/utils/satRefreshMappers.ts @@ -1,7 +1,5 @@ /** * Registry of predefined SAT refresh result mappers - * - * This replaces the unsafe eval() usage with a secure registry of * predefined mapping functions for different authentication patterns. */ diff --git a/packages/client/src/utils/interfaces/fabric.ts b/packages/client/src/utils/interfaces/fabric.ts index 2566e0122..2d2695de1 100644 --- a/packages/client/src/utils/interfaces/fabric.ts +++ b/packages/client/src/utils/interfaces/fabric.ts @@ -114,6 +114,94 @@ export type CallSessionEventsHandlerMap = Record< export type CallSessionEvents = { [k in keyof CallSessionEventsHandlerMap]: CallSessionEventsHandlerMap[k] } +export interface CallListeners { + // Core Call Events + 'call.joined': (stream: CallJoinedEventParams) => void + 'call.updated': (stream: CallUpdatedEventParams) => void + 'call.left': (stream: CallLeftEventParams) => void + 'call.state': (stream: CallStateEventParams) => void + 'call.play': (stream: CallPlayEventParams) => void + 'call.connect': (stream: CallConnectEventParams) => void + 'call.room': (stream: CallSessionEventParams) => void + + // Room Events + 'room.joined': (params: InternalCallJoinedEventParams) => void + 'room.subscribed': (params: InternalCallJoinedEventParams) => void + 'room.updated': (params: CallUpdatedEventParams) => void + 'room.left': (params?: CallLeftEventParams) => void + + // Member Events + 'member.joined': (params: MemberJoinedEventParams) => void + 'member.updated': (params: MemberUpdatedEventParams) => void + 'member.updated.audioMuted': (params: MemberUpdatedEventParams) => void + 'member.updated.videoMuted': (params: MemberUpdatedEventParams) => void + 'member.updated.deaf': (params: MemberUpdatedEventParams) => void + 'member.updated.visible': (params: MemberUpdatedEventParams) => void + 'member.updated.onHold': (params: MemberUpdatedEventParams) => void + 'member.updated.inputVolume': (params: MemberUpdatedEventParams) => void + 'member.updated.outputVolume': (params: MemberUpdatedEventParams) => void + 'member.updated.inputSensitivity': (params: MemberUpdatedEventParams) => void + 'member.updated.handraised': (params: MemberUpdatedEventParams) => void + 'member.updated.echoCancellation': (params: MemberUpdatedEventParams) => void + 'member.updated.autoGain': (params: MemberUpdatedEventParams) => void + 'member.updated.noiseCancellation': (params: MemberUpdatedEventParams) => void + 'member.updated.noiseSuppression': (params: MemberUpdatedEventParams) => void + 'member.left': (params: MemberLeftEventParams) => void + 'member.talking': (params: MemberTalkingEventParams) => void + 'memberList.updated': (params: CallMemberListUpdatedParams) => void + + // Media Events (the ones that caused the original conflict!) + 'media.connected': () => void + 'media.reconnecting': () => void + 'media.disconnected': () => void + + // Connection Events + connecting: (params: CallSession) => void + connected: (params: CallSession) => void + disconnected: (params: CallSession) => void + disconnecting: (params: CallSession) => void + reconnecting: (params: CallSession) => void + reconnected: (params: CallSession) => void + + // Additional BaseConnectionState events + active: (params: CallSession) => void + answering: (params: CallSession) => void + early: (params: CallSession) => void + hangup: (params: CallSession) => void + held: (params: CallSession) => void + new: (params: CallSession) => void + purge: (params: CallSession) => void + recovering: (params: CallSession) => void + requesting: (params: CallSession) => void + ringing: (params: CallSession) => void + trying: (params: CallSession) => void + + // Layout Events + 'layout.changed': (params: CallLayoutChangedEventParams) => void + + // Device Events + 'device.updated': (params: DeviceUpdatedEventParams) => void + 'device.disconnected': (params: DeviceDisconnectedEventParams) => void + + // Track Events + track: (event: RTCTrackEvent) => void + + // Lifecycle Events + destroy: () => void + + // + 'camera.updated': (params: DeviceUpdatedEventParams) => void + 'camera.disconnected': (params: DeviceDisconnectedEventParams) => void + 'microphone.updated': (params: DeviceUpdatedEventParams) => void + 'microphone.disconnected': (params: DeviceDisconnectedEventParams) => void + 'speaker.updated': (params: DeviceUpdatedEventParams) => void + 'speaker.disconnected': (params: DeviceDisconnectedEventParams) => void +} + +//@ts-ignore +function checkTypes(listener: CallListeners): CallSessionEvents { + return listener +} export interface CallSessionContract { /** The `layout.changed` event based on the current room layout */ @@ -151,4 +239,4 @@ export interface CallSessionContract { * ``` */ hangup(id?: string): Promise -} \ No newline at end of file +} diff --git a/packages/webrtc/CLAUDE.md b/packages/webrtc/CLAUDE.md new file mode 100644 index 000000000..47afcc98a --- /dev/null +++ b/packages/webrtc/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Package Overview + +The @signalwire/webrtc package is a core WebRTC utilities library within the SignalWire JavaScript SDK monorepo. It provides WebRTC-related functionality used by other SignalWire packages, including peer connection management, media device handling, and WebRTC session control. + +## Essential Commands + +### Development + +```bash +# Install dependencies (from monorepo root) +npm i + +# Build the package +npm run build + +# Run tests +npm test + +# Watch mode for development +npm start +``` + +### Testing + +```bash +# Run all tests for this package +npm test + +# Run a specific test file +npm test -- src/utils/deviceHelpers.test.ts +``` + +## Architecture Overview + +### Core Components + +1. **BaseConnection**: Abstract base class for WebRTC connections that extends BaseComponent from @signalwire/core. Manages WebRTC peer connections, media streams, and signaling through the Verto protocol. + +2. **RTCPeer**: Manages the actual RTCPeerConnection instance, handles ICE negotiation, SDP manipulation, and media stream management. Emits events for connection state changes and media updates. + +3. **Workers**: Redux saga workers that handle async side effects: + - `vertoEventWorker`: Processes Verto protocol events (invite, answer, modify, bye) + - `roomSubscribedWorker`: Handles room subscription events + - `promoteDemoteWorker`: Manages member promote/demote operations + - `sessionAuthWorker`: Handles authentication state changes + +### Key Design Patterns + +- **Redux Integration**: Uses Redux store from @signalwire/core for state management +- **Event-Driven**: Components communicate through EventEmitter pattern +- **Worker Pattern**: Async operations handled by saga workers responding to Redux actions +- **SDP Manipulation**: Extensive SDP helper functions for WebRTC negotiation + +### Public API Exports + +The package exports WebRTC utility functions through `src/index.ts`: +- Device enumeration and management functions +- Media permission checking and requesting +- Stream and track management utilities +- BaseConnection class for extending in other packages + +### Media Device Utilities + +Located in `src/utils/deviceHelpers.ts`: +- Device enumeration with permission handling +- Device watchers for monitoring changes +- Microphone analyzer for audio level detection +- Device ID validation and fallback logic + +## Build Configuration + +- **TypeScript**: Dual build outputs (ESM and CJS) + - ESM: `dist/mjs/` (ES2017 target) + - CJS: `dist/cjs/` (ES5 target) +- **Testing**: Jest with Babel transformation, WebRTC mocks in `src/webrtcMocks.ts` + +## Dependencies + +- **@signalwire/core**: Provides base components, Redux store, and common utilities +- **sdp**: SDP parsing and manipulation library + +## Development Notes + +### WebRTC Mock Environment + +The package includes comprehensive WebRTC mocks for testing (`src/webrtcMocks.ts`). Tests run with these mocks enabled through `src/setupTests.ts`. + +### Platform Primitives + +Platform-specific implementations are handled through: +- `src/utils/primitives.ts`: Browser WebRTC APIs +- `src/utils/primitives.native.ts`: React Native placeholder + +### SDP Helpers + +Critical SDP manipulation functions in `src/utils/sdpHelpers.ts`: +- Bitrate hacks for Chrome +- Stereo audio configuration +- Media ordering for compatibility +- ICE candidate filtering \ No newline at end of file