diff --git a/.github/workflows/browser-js-production.yml b/.github/workflows/browser-js-production.yml index 08e52a9c3..1f2c7ad7c 100644 --- a/.github/workflows/browser-js-production.yml +++ b/.github/workflows/browser-js-production.yml @@ -27,9 +27,11 @@ jobs: demote, audience, reattach, - callfabric, - renegotiation, videoElement, + fabricV3, + fabricV4, + renegotiationV3, + renegotiationV4, v2WebRTC, ] steps: diff --git a/.github/workflows/browser-js-staging.yml b/.github/workflows/browser-js-staging.yml index 2db733302..2752b9735 100644 --- a/.github/workflows/browser-js-staging.yml +++ b/.github/workflows/browser-js-staging.yml @@ -26,9 +26,11 @@ jobs: # demote, # audience, # reattach, - callfabric, - renegotiation, videoElement, + fabricV3, + fabricV4, + renegotiationV3, + renegotiationV4, v2WebRTC, ] steps: diff --git a/internal/e2e-js/fixtures.ts b/internal/e2e-js/fixtures.ts index 05d72a8e6..136958ed6 100644 --- a/internal/e2e-js/fixtures.ts +++ b/internal/e2e-js/fixtures.ts @@ -28,6 +28,7 @@ type CustomFixture = { createRelayAppResource: typeof createRelayAppResource resources: Resource[] } + useV4Client: boolean } const test = baseTest.extend({ @@ -125,6 +126,11 @@ const test = baseTest.extend({ await Promise.allSettled(deleteResources) } }, + useV4Client: async ({}, use, testInfo) => { + const val = + (testInfo.project.use as { useV4Client: boolean }).useV4Client ?? false + await use(val) + }, }) export { test, expect, Page } diff --git a/internal/e2e-js/playwright.config.ts b/internal/e2e-js/playwright.config.ts index 4d0c377d5..7cfd0bc81 100644 --- a/internal/e2e-js/playwright.config.ts +++ b/internal/e2e-js/playwright.config.ts @@ -35,13 +35,16 @@ const reattachTests = [ 'roomSessionReattachWrongCallId.spec.ts', 'roomSessionReattachWrongProtocol.spec.ts', ] -const callfabricTests = [ +const videoElementTests = ['buildVideoWithVideoSDK.spec.ts'] +const fabricV3Tests = ['reattach.spec.ts'] +const fabricV4Tests = ['reattachV4.spec.ts'] +const fabricBaseTests = [ 'address.spec.ts', + 'buildVideoWithFabricSDK.spec.ts', 'cleanup.spec.ts', 'conversation.spec.ts', 'holdunhold.spec.ts', 'raiseHand.spec.ts', - 'reattach.spec.ts', 'relayApp.spec.ts', 'swml.spec.ts', 'videoRoom.spec.ts', @@ -52,10 +55,6 @@ const renegotiationTests = [ 'renegotiateAudio.spec.ts', 'renegotiateVideo.spec.ts', ] -const videoElementTests = [ - 'buildVideoWithVideoSDK.spec.ts', - 'buildVideoWithFabricSDK.spec.ts', -] const v2WebRTC = ['v2WebrtcFromRest.spec.ts', 'webrtcCalling.spec.ts'] const useDesktopChrome = { @@ -70,6 +69,16 @@ const useDesktopChrome = { }, } +const useFabricV3Client = { + ...useDesktopChrome, + useV4Client: false, +} + +const useFabricV4Client = { + ...useDesktopChrome, + useV4Client: true, +} + const config: PlaywrightTestConfig = { testDir: 'tests', reporter: process.env.CI ? 'github' : 'list', @@ -95,9 +104,11 @@ const config: PlaywrightTestConfig = { ...demoteTests, ...audienceTests, ...reattachTests, - ...callfabricTests, - ...renegotiationTests, ...videoElementTests, + ...fabricBaseTests, + ...fabricV3Tests, + ...fabricV4Tests, + ...renegotiationTests, ...v2WebRTC, ], }, @@ -132,19 +143,29 @@ const config: PlaywrightTestConfig = { testMatch: reattachTests, }, { - name: 'callfabric', + name: 'videoElement', use: useDesktopChrome, - testMatch: callfabricTests, + testMatch: videoElementTests, }, { - name: 'renegotiation', - use: useDesktopChrome, + name: 'fabricV3', + use: useFabricV3Client, + testMatch: [...fabricBaseTests, ...fabricV3Tests], + }, + { + name: 'fabricV4', + use: useFabricV4Client, + testMatch: [...fabricBaseTests, ...fabricV4Tests], + }, + { + name: 'renegotiationV3', + use: useFabricV3Client, testMatch: renegotiationTests, }, { - name: 'videoElement', - use: useDesktopChrome, - testMatch: videoElementTests, + name: 'renegotiationV4', + use: useFabricV4Client, + testMatch: renegotiationTests, }, { name: 'v2WebRTC', @@ -153,4 +174,5 @@ const config: PlaywrightTestConfig = { }, ], } + export default config diff --git a/internal/e2e-js/tests/buildVideoWithFabricSDK.spec.ts b/internal/e2e-js/tests/buildVideoWithFabricSDK.spec.ts index db07f5f2a..282f25429 100644 --- a/internal/e2e-js/tests/buildVideoWithFabricSDK.spec.ts +++ b/internal/e2e-js/tests/buildVideoWithFabricSDK.spec.ts @@ -33,6 +33,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { test('should not render any video if rootElement is not passed', async ({ createCustomPage, + useV4Client, resource, }) => { const page = await createCustomPage({ name: '[page]' }) @@ -40,7 +41,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { const roomName = randomizeRoomName('build-video-element') await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room without passing the rootElement await dialAddress(page, { @@ -56,6 +57,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { test('should return the rootElement', async ({ createCustomPage, + useV4Client, resource, }) => { const page = await createCustomPage({ name: '[page]' }) @@ -63,7 +65,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { const roomName = randomizeRoomName('build-video-element') await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room without passing the rootElement await dialAddress(page, { @@ -116,6 +118,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { test('should render multiple video elements', async ({ createCustomPage, + useV4Client, resource, }) => { const page = await createCustomPage({ name: '[page]' }) @@ -123,7 +126,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { const roomName = randomizeRoomName('build-video-element') await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial and expect both video and member overlays await dialAddress(page, { @@ -255,6 +258,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { test('should render the video even if the function is called before call.start', async ({ createCustomPage, + useV4Client, resource, }) => { const page = await createCustomPage({ name: '[page]' }) @@ -262,7 +266,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { const roomName = randomizeRoomName('build-video-element') await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Create and expect 1 video elements await page.evaluate( @@ -307,6 +311,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { test('should not create a new element if the elements are same', async ({ createCustomPage, + useV4Client, resource, }) => { const page = await createCustomPage({ name: '[page]' }) @@ -314,7 +319,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { const roomName = randomizeRoomName('build-video-element') await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room with rootElement await dialAddress(page, { @@ -345,6 +350,7 @@ test.describe('buildVideoElement with CallFabric SDK', () => { test('should handle the element for multiple users', async ({ createCustomPage, + useV4Client, resource, }) => { const pageOne = await createCustomPage({ name: '[pageOne]' }) @@ -355,7 +361,10 @@ test.describe('buildVideoElement with CallFabric SDK', () => { const roomName = randomizeRoomName('build-video-element') await resource.createVideoRoomResource(roomName) - await Promise.all([createCFClient(pageOne), createCFClient(pageTwo)]) + await Promise.all([ + createCFClient(pageOne, { useV4Client }), + createCFClient(pageTwo, { useV4Client }), + ]) // Dial an address and join a video room from pageOne await dialAddress(pageOne, { diff --git a/internal/e2e-js/tests/callfabric/address.spec.ts b/internal/e2e-js/tests/callfabric/address.spec.ts index 0f16a1988..6a0c5fbcf 100644 --- a/internal/e2e-js/tests/callfabric/address.spec.ts +++ b/internal/e2e-js/tests/callfabric/address.spec.ts @@ -5,11 +5,12 @@ import { SERVER_URL, createCFClient } from '../../utils' test.describe('Addresses', () => { test('query multiple addresses and single address', async ({ createCustomPage, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) const { addressById, addressByName, addressToCompare } = await page.evaluate(async () => { @@ -35,11 +36,12 @@ test.describe('Addresses', () => { test('Should return only type rooms in ASC order by name', async ({ createCustomPage, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) const isCorrectlySorted = await page.evaluate(async () => { // @ts-expect-error @@ -95,11 +97,12 @@ test.describe('Addresses', () => { */ test.skip('Should return only type rooms in DESC order by name', async ({ createCustomPage, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) const isCorrectlySorted = await page.evaluate(async () => { // @ts-expect-error diff --git a/internal/e2e-js/tests/callfabric/cleanup.spec.ts b/internal/e2e-js/tests/callfabric/cleanup.spec.ts index 680b3ee40..042f9f368 100644 --- a/internal/e2e-js/tests/callfabric/cleanup.spec.ts +++ b/internal/e2e-js/tests/callfabric/cleanup.spec.ts @@ -9,7 +9,10 @@ import { } from '../../utils' test.describe('Clean up', () => { - test('it should create a webscoket client', async ({ createCustomPage }) => { + test('it should create a webscoket client', async ({ + createCustomPage, + useV4Client, + }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -30,7 +33,7 @@ test.describe('Clean up', () => { expect(websocketUrl).toBe(null) - await createCFClient(page) + await createCFClient(page, { useV4Client }) await disconnectClient(page) @@ -42,11 +45,12 @@ test.describe('Clean up', () => { test('it should cleanup session emitter and workers', async ({ createCustomPage, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page, { attachSagaMonitor: true }) + await createCFClient(page, { attachSagaMonitor: true, useV4Client }) await test.step('the client should have workers and listeners attached', async () => { const watchers: Record = await page.evaluate(() => { @@ -91,6 +95,7 @@ test.describe('Clean up', () => { test('it should cleanup call emitter and workers without affecting the client', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -98,7 +103,7 @@ test.describe('Clean up', () => { const roomName = `e2e-cleanup_${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page, { attachSagaMonitor: true }) + await createCFClient(page, { attachSagaMonitor: true, useV4Client }) // Dial an address and join a video room await dialAddress(page, { diff --git a/internal/e2e-js/tests/callfabric/conversation.spec.ts b/internal/e2e-js/tests/callfabric/conversation.spec.ts index 0c5b09a91..78a1cf8b2 100644 --- a/internal/e2e-js/tests/callfabric/conversation.spec.ts +++ b/internal/e2e-js/tests/callfabric/conversation.spec.ts @@ -10,6 +10,7 @@ test.describe('Conversation Room', () => { test('send message in a room conversation', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) const page2 = await createCustomPage({ @@ -18,8 +19,8 @@ test.describe('Conversation Room', () => { await page.goto(SERVER_URL) await page2.goto(SERVER_URL) - await createCFClient(page) - await createCFClient(page2) + await createCFClient(page, { useV4Client }) + await createCFClient(page2, { useV4Client }) const roomName = `e2e-js-convo-room_${uuid()}` await resource.createVideoRoomResource(roomName) diff --git a/internal/e2e-js/tests/callfabric/holdunhold.spec.ts b/internal/e2e-js/tests/callfabric/holdunhold.spec.ts index fa5b4dcbf..e3aa582b3 100644 --- a/internal/e2e-js/tests/callfabric/holdunhold.spec.ts +++ b/internal/e2e-js/tests/callfabric/holdunhold.spec.ts @@ -15,6 +15,7 @@ test.describe('CallFabric Hold/Unhold Call', () => { test('should dial a call and be able to hold/unhold the call', async ({ createCustomPage, resource, + useV4Client, }) => { const pageOne = await createCustomPage({ name: '[page-one]' }) const pageTwo = await createCustomPage({ name: '[page-two]' }) @@ -25,7 +26,7 @@ test.describe('CallFabric Hold/Unhold Call', () => { await resource.createVideoRoomResource(roomName) await test.step('[page-one] should create a client and dial a call', async () => { - await createCFClient(pageOne) + await createCFClient(pageOne, { useV4Client }) await dialAddress(pageOne, { address: `/public/${roomName}?channel=video`, }) @@ -33,7 +34,7 @@ test.describe('CallFabric Hold/Unhold Call', () => { }) await test.step('[page-two] should create a client and dial a call', async () => { - await createCFClient(pageTwo) + await createCFClient(pageTwo, { useV4Client }) await dialAddress(pageTwo, { address: `/public/${roomName}?channel=video`, }) diff --git a/internal/e2e-js/tests/callfabric/raiseHand.spec.ts b/internal/e2e-js/tests/callfabric/raiseHand.spec.ts index 856138d3f..790731c79 100644 --- a/internal/e2e-js/tests/callfabric/raiseHand.spec.ts +++ b/internal/e2e-js/tests/callfabric/raiseHand.spec.ts @@ -12,6 +12,7 @@ test.describe('CallFabric Raise/Lower Hand', () => { test("should join a room and be able to raise/lower self member's hand", async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -19,7 +20,7 @@ test.describe('CallFabric Raise/Lower Hand', () => { const roomName = `e2e-hand-raise_${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room const roomSession = await dialAddress(page, { @@ -87,6 +88,7 @@ test.describe('CallFabric Raise/Lower Hand', () => { test("should join a room and be able to raise/lower other member's hand", async ({ createCustomPage, resource, + useV4Client, }) => { const pageOne = await createCustomPage({ name: '[page-one]' }) const pageTwo = await createCustomPage({ name: '[page-two]' }) @@ -97,7 +99,7 @@ test.describe('CallFabric Raise/Lower Hand', () => { await resource.createVideoRoomResource(roomName) // Create client, dial an address and join a video room from page-one - await createCFClient(pageOne) + await createCFClient(pageOne, { useV4Client }) const roomSessionOne = await dialAddress(pageOne, { address: `/public/${roomName}?channel=video`, }) @@ -112,7 +114,7 @@ test.describe('CallFabric Raise/Lower Hand', () => { await expectMCUVisible(pageOne) // Create client, dial an address and join a video room from page-two - await createCFClient(pageTwo) + await createCFClient(pageTwo, { useV4Client }) const roomSessionTwo = await dialAddress(pageTwo, { address: `/public/${roomName}?channel=video`, }) diff --git a/internal/e2e-js/tests/callfabric/reattachV4.spec.ts b/internal/e2e-js/tests/callfabric/reattachV4.spec.ts new file mode 100644 index 000000000..00bb20872 --- /dev/null +++ b/internal/e2e-js/tests/callfabric/reattachV4.spec.ts @@ -0,0 +1,319 @@ +import { uuid } from '@signalwire/core' +import { test, expect } from '../../fixtures' +import { + SERVER_URL, + createCFClient, + createTestSATToken, + dialAddress, + expectMCUVisible, +} from '../../utils' +import { + FabricRoomSession, + SignalWireClient, + SignalWireContract, +} from '@signalwire/js' + +test.describe('Reattach with v4 Client', () => { + test('it should reattach the call', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-reattach-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page, { useV4Client: true }) + + // Dial an address and join a video room + const roomSessionBeforeReattach = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + + expect(roomSessionBeforeReattach.room_session).toBeDefined() + + await expectMCUVisible(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await createCFClient(page, { useV4Client: true }) + + // Reattach to the previous call session + const roomSessionAfterReattach = await page.evaluate( + async ({ roomName }) => { + return new Promise(async (resolve, _reject) => { + // @ts-expect-error + const client: SignalWireClient = window._client + + const call = await client.reattach({ + to: `/public/${roomName}?channel=video`, + rootElement: document.getElementById('rootElement'), + }) + + call.on('call.joined', resolve) + + // @ts-expect-error + window._roomObj = call + await call.start() + }) + }, + { roomName } + ) + + expect(roomSessionAfterReattach.call_id).toEqual( + roomSessionBeforeReattach.call_id + ) + }) + + test('it should not reattach if the authState is not provided', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-reattach-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page, { useV4Client: true }) + + // Dial an address and join a video room + const roomSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + + expect(roomSession.room_session).toBeDefined() + + await expectMCUVisible(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await createCFClient(page, { useV4Client: true, useAuthState: false }) + + // Reattach to the previous call session + const reattachError = await page.evaluate( + async ({ roomName }) => { + return new Promise(async (resolve, reject) => { + // @ts-expect-error + const client: SignalWireClient = window._client + + try { + const call = await client.reattach({ + to: `/public/${roomName}?channel=video`, + rootElement: document.getElementById('rootElement'), + }) + call.on('call.joined', reject) + + // @ts-expect-error + window._roomObj = call + await call.start() + } catch (e) { + resolve(e) + } + }) + }, + { roomName } + ) + + expect(reattachError).toBeDefined() + expect(reattachError).toBeInstanceOf(Error) + }) + + test('it should reattach with previous room states', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-reattach-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page, { useV4Client: true }) + + // Dial an address and join a video room + const roomSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + + expect(roomSession.room_session).toBeDefined() + + await expectMCUVisible(page) + + // --------------- Muting Video (self) --------------- + await page.evaluate( + async ({ roomSession }) => { + // @ts-expect-error + const roomObj: FabricRoomSession = window._roomObj + + const memberVideoMutedEvent = new Promise((res) => { + roomObj.on('member.updated.videoMuted', (params) => { + if ( + params.member.member_id === roomSession.member_id && + params.member.video_muted === true + ) { + res(true) + } + }) + }) + + await roomObj.videoMute() + await memberVideoMutedEvent + }, + { roomSession } + ) + + // --------------- Muting Audio (self) --------------- + await page.evaluate( + async ({ roomSession }) => { + // @ts-expect-error + const roomObj: FabricRoomSession = window._roomObj + + const memberAudioMutedEvent = new Promise((res) => { + roomObj.on('member.updated.audioMuted', (params) => { + if ( + params.member.member_id === roomSession.member_id && + params.member.audio_muted === true + ) { + res(true) + } + }) + }) + + await roomObj.audioMute() + await memberAudioMutedEvent + }, + { roomSession } + ) + + await page.reload({ waitUntil: 'domcontentloaded' }) + await createCFClient(page, { useV4Client: true }) + + // Reattach to the previous call session + const roomSessionAfterReattach = await page.evaluate( + async ({ roomName }) => { + return new Promise(async (resolve, _reject) => { + // @ts-expect-error + const client: SignalWireClient = window._client + + const call = await client.reattach({ + to: `/public/${roomName}?channel=video`, + rootElement: document.getElementById('rootElement'), + }) + + call.on('call.joined', resolve) + + // @ts-expect-error + window._roomObj = call + await call.start() + }) + }, + { roomName } + ) + + expect(roomSessionAfterReattach.call_id).toEqual(roomSession.call_id) + + const localVideoTrack = await page.evaluate( + // @ts-expect-error + () => window._roomObj.peer.localVideoTrack + ) + expect(localVideoTrack).toEqual({}) + + const localAudioTrack = await page.evaluate( + // @ts-expect-error + () => window._roomObj.peer.localAudioTrack + ) + expect(localAudioTrack).toEqual({}) + }) + + // TODO: Unskip this test once the following issue is resolved: + // https://github.com/signalwire/cloud-product/issues/14179 + test.skip('it should not reattach with invalid authState', async ({ + createCustomPage, + resource, + }) => { + const page = await createCustomPage({ name: '[page]' }) + await page.goto(SERVER_URL) + + const roomName = `e2e-reattach-${uuid()}` + await resource.createVideoRoomResource(roomName) + + await createCFClient(page, { useV4Client: true }) + + // Dial an address and join a video room + const roomSession = await dialAddress(page, { + address: `/public/${roomName}?channel=video`, + }) + + expect(roomSession.room_session).toBeDefined() + + await expectMCUVisible(page) + + await page.reload({ waitUntil: 'domcontentloaded' }) + + // Create a client with invalid auth state + const sat = await createTestSATToken() + await page.evaluate( + async (options) => { + // Get, decode, and use the previous call protocol and ID + const authStateFromStorage = sessionStorage.getItem( + 'authState' + ) as string + const decodedAuthState = window.atob(authStateFromStorage) + const prevAuthState = JSON.parse(decodedAuthState) + + const json = JSON.stringify({ + authState: 'wrong auth state', + protocol: prevAuthState.protocol, + callId: prevAuthState.callId, + }) + const authState = window.btoa(json) + + const SignalWire = window._SWJS.SignalWireV4 + const client: SignalWireContract = await SignalWire({ + host: options.RELAY_HOST, + token: options.TOKEN, + debug: { logWsTraffic: true }, + maxApiRequestRetries: 0, + onAuthStateChange: (state) => { + sessionStorage.setItem('authState', state) + }, + authState, + }) + + window._client = client + }, + { + RELAY_HOST: process.env.RELAY_HOST, + TOKEN: sat, + } + ) + + // Reattach to the previous call session + const reattachError = await page.evaluate( + async ({ roomName }) => { + return new Promise(async (resolve, reject) => { + // @ts-expect-error + const client: SignalWireClient = window._client + + try { + const call = await client.reattach({ + to: `/public/${roomName}?channel=video`, + rootElement: document.getElementById('rootElement'), + }) + call.on('call.joined', reject) + + // @ts-expect-error + window._roomObj = call + await call.start() + } catch (e) { + resolve(e) + } + }) + }, + { roomName } + ) + + expect(reattachError).toBeDefined() + expect(reattachError).toBeInstanceOf(Error) + }) +}) diff --git a/internal/e2e-js/tests/callfabric/relayApp.spec.ts b/internal/e2e-js/tests/callfabric/relayApp.spec.ts index 958cd3ba7..345dba4ec 100644 --- a/internal/e2e-js/tests/callfabric/relayApp.spec.ts +++ b/internal/e2e-js/tests/callfabric/relayApp.spec.ts @@ -15,6 +15,7 @@ test.describe('CallFabric Relay Application', () => { test('should connect to the relay app and expect an audio playback', async ({ createCustomPage, resource, + useV4Client, }) => { let playback const client = await SignalWire({ @@ -58,7 +59,7 @@ test.describe('CallFabric Relay Application', () => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) await dialAddress(page, { address: `/private/${topic}`, @@ -124,6 +125,7 @@ test.describe('CallFabric Relay Application', () => { test('should connect to the relay app and expect a silence', async ({ createCustomPage, resource, + useV4Client, }) => { let playback const client = await SignalWire({ @@ -163,7 +165,7 @@ test.describe('CallFabric Relay Application', () => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) await dialAddress(page, { address: `/private/${topic}?channel=video`, @@ -239,6 +241,7 @@ test.describe('CallFabric Relay Application', () => { test('should connect to the relay app and expect a hangup', async ({ createCustomPage, resource, + useV4Client, }) => { const client = await SignalWire({ host: process.env.RELAY_HOST, @@ -275,7 +278,7 @@ test.describe('CallFabric Relay Application', () => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) await dialAddress(page, { address: `/private/${topic}?channel=video`, diff --git a/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts b/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts index 3e1c5a950..aab0e6ea3 100644 --- a/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts +++ b/internal/e2e-js/tests/callfabric/renegotiateAudio.spec.ts @@ -15,6 +15,7 @@ test.describe('CallFabric Audio Renegotiation', () => { test('it should enable audio with "sendrecv" and then disable with "inactive"', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -22,7 +23,7 @@ test.describe('CallFabric Audio Renegotiation', () => { const roomName = `e2e-room-audio-reneg-${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with video only channel await dialAddress(page, { @@ -100,6 +101,7 @@ test.describe('CallFabric Audio Renegotiation', () => { test('it should enable audio with "sendonly" and then disable with "recvonly"', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -107,7 +109,7 @@ test.describe('CallFabric Audio Renegotiation', () => { const roomName = `e2e-room-audio-reneg-${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with video only channel await dialAddress(page, { @@ -169,6 +171,7 @@ test.describe('CallFabric Audio Renegotiation', () => { test('it should enable audio with "recvonly" and then disable with "inactive"', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -176,7 +179,7 @@ test.describe('CallFabric Audio Renegotiation', () => { const roomName = `e2e-room-audio-reneg-${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with video only channel await dialAddress(page, { diff --git a/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts b/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts index efc9c5818..98130f27e 100644 --- a/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts +++ b/internal/e2e-js/tests/callfabric/renegotiateVideo.spec.ts @@ -16,6 +16,7 @@ test.describe('CallFabric Video Renegotiation', () => { test('it should enable video with "sendrecv" and then disable with "inactive"', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -23,7 +24,7 @@ test.describe('CallFabric Video Renegotiation', () => { const roomName = `e2e-room-video-reneg-${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with audio only channel await dialAddress(page, { @@ -79,6 +80,7 @@ test.describe('CallFabric Video Renegotiation', () => { test('it should enable video with "sendonly" and then disable with "recvonly"', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -86,7 +88,7 @@ test.describe('CallFabric Video Renegotiation', () => { const roomName = `e2e-room-video-reneg-${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with audio only channel await dialAddress(page, { @@ -150,6 +152,7 @@ test.describe('CallFabric Video Renegotiation', () => { test('it should enable video with "recvonly" and then disable with "inactive"', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -157,7 +160,7 @@ test.describe('CallFabric Video Renegotiation', () => { const roomName = `e2e-room-video-reneg-${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with audio only channel await dialAddress(page, { diff --git a/internal/e2e-js/tests/callfabric/swml.spec.ts b/internal/e2e-js/tests/callfabric/swml.spec.ts index 2d7b0eac9..678d29031 100644 --- a/internal/e2e-js/tests/callfabric/swml.spec.ts +++ b/internal/e2e-js/tests/callfabric/swml.spec.ts @@ -45,6 +45,7 @@ test.describe('CallFabric SWML', () => { test('should dial an address and expect a TTS audio', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -55,7 +56,7 @@ test.describe('CallFabric SWML', () => { contents: swmlTTS, }) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and listen a TTS await dialAddress(page, { @@ -93,6 +94,7 @@ test.describe('CallFabric SWML', () => { test('should dial an address and expect a hangup', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -103,7 +105,7 @@ test.describe('CallFabric SWML', () => { contents: swmlHangup, }) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and listen a TTS await dialAddress(page, { diff --git a/internal/e2e-js/tests/callfabric/videoRoom.spec.ts b/internal/e2e-js/tests/callfabric/videoRoom.spec.ts index f371ed9d5..0087f24d1 100644 --- a/internal/e2e-js/tests/callfabric/videoRoom.spec.ts +++ b/internal/e2e-js/tests/callfabric/videoRoom.spec.ts @@ -15,6 +15,7 @@ test.describe('CallFabric VideoRoom', () => { test('should handle joining a room, perform actions and then leave the room', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -22,7 +23,7 @@ test.describe('CallFabric VideoRoom', () => { const roomName = `e2e-video-room_${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room const roomSession: CallJoinedEventParams = await dialAddress(page, { @@ -367,11 +368,14 @@ test.describe('CallFabric VideoRoom', () => { // ).toStrictEqual(meta) }) - test('should fail on invalid address', async ({ createCustomPage }) => { + test('should fail on invalid address', async ({ + createCustomPage, + useV4Client, + }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room const roomSession = await page.evaluate(async () => { @@ -400,6 +404,7 @@ test.describe('CallFabric VideoRoom', () => { test('should handle joining a room with audio channel only', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -407,7 +412,7 @@ test.describe('CallFabric VideoRoom', () => { const roomName = `e2e-video-room_${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address with audio only channel const roomSession = await dialAddress(page, { diff --git a/internal/e2e-js/tests/callfabric/videoRoomLayout.spec.ts b/internal/e2e-js/tests/callfabric/videoRoomLayout.spec.ts index e150edd13..34281e4af 100644 --- a/internal/e2e-js/tests/callfabric/videoRoomLayout.spec.ts +++ b/internal/e2e-js/tests/callfabric/videoRoomLayout.spec.ts @@ -16,6 +16,7 @@ test.describe('CallFabric Video Room Layout', () => { test('should join a room and be able to change the layout', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -23,7 +24,7 @@ test.describe('CallFabric Video Room Layout', () => { const roomName = `e2e-video-layout_${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room const roomSession = await dialAddress(page, { @@ -63,6 +64,7 @@ test.describe('CallFabric Video Room Layout', () => { test('should join a room and be able to change the member position in the layout', async ({ createCustomPage, resource, + useV4Client, }) => { const page = await createCustomPage({ name: '[page]' }) await page.goto(SERVER_URL) @@ -70,7 +72,7 @@ test.describe('CallFabric Video Room Layout', () => { const roomName = `e2e-video-layout-position_${uuid()}` await resource.createVideoRoomResource(roomName) - await createCFClient(page) + await createCFClient(page, { useV4Client }) // Dial an address and join a video room const roomSession = await dialAddress(page, { @@ -133,6 +135,7 @@ test.describe('CallFabric Video Room Layout', () => { test.skip('should join a room and be able to change other member position in the layout', async ({ createCustomPage, resource, + useV4Client, }) => { const pageOne = await createCustomPage({ name: '[page-one]' }) const pageTwo = await createCustomPage({ name: '[page-two]' }) @@ -143,7 +146,7 @@ test.describe('CallFabric Video Room Layout', () => { await resource.createVideoRoomResource(roomName) // Create client for pageOne and Dial an address to join a video room - await createCFClient(pageOne) + await createCFClient(pageOne, { useV4Client }) const roomSessionOne = (await dialAddress(pageOne, { address: `/public/${roomName}?channel=video`, })) as VideoRoomSubscribedEventParams @@ -151,7 +154,7 @@ test.describe('CallFabric Video Room Layout', () => { await expectMCUVisible(pageOne) // Create client for pageTwo and Dial an address to join a video room - await createCFClient(pageTwo) + await createCFClient(pageTwo, { useV4Client }) const roomSessionTwo = (await dialAddress(pageTwo, { address: `/public/${roomName}?channel=video`, })) as VideoRoomSubscribedEventParams diff --git a/internal/e2e-js/utils.ts b/internal/e2e-js/utils.ts index 229c480e3..c78864c11 100644 --- a/internal/e2e-js/utils.ts +++ b/internal/e2e-js/utils.ts @@ -1,6 +1,7 @@ import type { FabricRoomSession, SignalWire, + SignalWireV4, SignalWireClient, SignalWireContract, Video, @@ -17,7 +18,9 @@ declare global { interface Window { _SWJS: { SignalWire: typeof SignalWire + SignalWireV4: typeof SignalWireV4 } + _authState?: string _client?: SignalWireClient } } @@ -464,6 +467,8 @@ export const leaveRoom = async (page: Page) => { interface CreateCFClientParams { attachSagaMonitor?: boolean + useV4Client?: boolean + useAuthState?: boolean } export const createCFClient = async ( @@ -476,7 +481,11 @@ export const createCFClient = async ( process.exit(4) } - const { attachSagaMonitor = false } = params || {} + const { + attachSagaMonitor = false, + useV4Client = false, + useAuthState = true, + } = params || {} const swClient = await page.evaluate( async (options) => { @@ -507,11 +516,21 @@ export const createCFClient = async ( }, } - const SignalWire = window._SWJS.SignalWire + const SignalWire = options.useV4Client + ? window._SWJS.SignalWireV4 + : window._SWJS.SignalWire + const client: SignalWireContract = await SignalWire({ host: options.RELAY_HOST, token: options.API_TOKEN, debug: { logWsTraffic: true }, + maxApiRequestRetries: 5, + onAuthStateChange: (state) => { + sessionStorage.setItem('authState', state) + }, + ...(options.useAuthState && { + authState: sessionStorage.getItem('authState')!, + }), ...(options.attachSagaMonitor && { sagaMonitor }), }) @@ -522,6 +541,8 @@ export const createCFClient = async ( RELAY_HOST: process.env.RELAY_HOST, API_TOKEN: sat, attachSagaMonitor, + useV4Client, + useAuthState, } ) diff --git a/internal/playground-js/src/fabric/index.js b/internal/playground-js/src/fabric/index.js index 9f2d2c6af..d160fe383 100644 --- a/internal/playground-js/src/fabric/index.js +++ b/internal/playground-js/src/fabric/index.js @@ -1,4 +1,4 @@ -import { SignalWire, buildVideoElement } from '@signalwire/js' +import { SignalWire, SignalWireV4, buildVideoElement } from '@signalwire/js' import { enumerateDevices, checkPermissions, @@ -251,11 +251,15 @@ function restoreUI() { window.connect = async () => { connectStatus.innerHTML = 'Connecting...' - client = await SignalWire({ + client = await SignalWireV4({ host: document.getElementById('host').value, token: document.getElementById('token').value, logLevel: 'debug', debug: { logWsTraffic: true }, + authState: sessionStorage.getItem('fabric.ws.authState'), + onAuthStateChange: (authState) => { + sessionStorage.setItem('fabric.ws.authState', authState) + }, }) window.__client = client diff --git a/packages/core/src/BaseComponent.ts b/packages/core/src/BaseComponent.ts index b108a2255..0f0c3feb6 100644 --- a/packages/core/src/BaseComponent.ts +++ b/packages/core/src/BaseComponent.ts @@ -164,6 +164,7 @@ export class BaseComponent< return this.emitter.listenerCount(event) } + /** @internal */ destroy() { this._destroyer?.() this.removeAllListeners() @@ -257,7 +258,7 @@ export class BaseComponent< /** @internal */ protected _waitUntilSessionAuthorized(): Promise { - const authStatus = this._sessionAuthStatus + const authStatus = this.select(getAuthStatus) switch (authStatus) { case 'authorized': diff --git a/packages/core/src/redux/interfaces.ts b/packages/core/src/redux/interfaces.ts index d3bb5f12e..d2a31bccc 100644 --- a/packages/core/src/redux/interfaces.ts +++ b/packages/core/src/redux/interfaces.ts @@ -40,6 +40,7 @@ export interface WebRTCCall extends SWComponent { nodeId?: string roomId?: string roomSessionId?: string + originCallId?: string memberId?: string previewUrl?: string byeCause?: string diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 8a94a3415..27bcaa18c 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -312,7 +312,6 @@ export interface SwAuthorizationStateEvent { params: SwAuthorizationStateEventParams } -// prettier-ignore export type SwEventParams = | VideoAPIEvent | WebRTCMessageParams diff --git a/packages/core/src/utils/interfaces.ts b/packages/core/src/utils/interfaces.ts index b262974d8..fe8fbb5f0 100644 --- a/packages/core/src/utils/interfaces.ts +++ b/packages/core/src/utils/interfaces.ts @@ -96,6 +96,8 @@ export interface BaseRPCResult extends Record { message: string } +export type OnAuthStateChange = (authState: string) => unknown + export interface SessionOptions { /** @internal */ host?: string @@ -110,8 +112,21 @@ export interface SessionOptions { // From `LogLevelDesc` of loglevel to simplify our docs /** logging level */ logLevel?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent' + /** + * Authorization state used when reconnecting to the WebSocket + * and/or reattaching to the call via the {@link client.reattach} API. + * + * Applicable only with the {@link SignalWireV4} client. + */ + authState?: string + /** + * Callback triggered whenever the authorization state changes + * + * Applicable only with the {@link SignalWireV4} client. + */ + onAuthStateChange?: OnAuthStateChange /** Callback invoked by the SDK to fetch a new token for re-authentication */ - onRefreshToken?(): Promise + onRefreshToken?: () => Promise sessionChannel?: SessionChannel instanceMap?: InstanceMap } @@ -212,7 +227,6 @@ export interface SATAuthorization { jti: string project_id: string fabric_subscriber: { - // TODO: public ? version: number expires_at: number subscriber_id: string diff --git a/packages/js/src/Client.ts b/packages/js/src/Client.ts index 073906940..a4d05b52c 100644 --- a/packages/js/src/Client.ts +++ b/packages/js/src/Client.ts @@ -6,7 +6,7 @@ import { Chat as ChatNamespace, PubSub as PubSubNamespace, } from '@signalwire/core' -import type { CustomSaga } from '@signalwire/core' +import type { CustomSaga, OnAuthStateChange } from '@signalwire/core' import { ConnectionOptions } from '@signalwire/webrtc' import { makeAudioElementSaga } from './features/mediaElements/mediaElementsSagas' import { VideoManager, createVideoManagerObject } from './cantina' @@ -41,6 +41,12 @@ export interface MakeRoomOptions extends ConnectionOptions { stopMicrophoneWhileMuted?: boolean /** Local media stream to override the local video and audio stream tracks */ localStream?: MediaStream + /** + * Callback triggered whenever the authorization state changes + * + * Applicable only with the {@link FabricRoomSessionV4} class. + */ + onAuthStateChange?: OnAuthStateChange } export class ClientAPI extends BaseClient { diff --git a/packages/js/src/JWTSession.ts b/packages/js/src/JWTSession.ts index 72e8d8e90..c3433b456 100644 --- a/packages/js/src/JWTSession.ts +++ b/packages/js/src/JWTSession.ts @@ -9,7 +9,7 @@ import { import { getStorage, sessionStorageManager } from './utils/storage' import { SwCloseEvent } from './utils/CloseEvent' -type JWTHeader = { ch?: string; typ?: string } +export type JWTHeader = { ch?: string; typ?: string } export class JWTSession extends BaseJWTSession { public WebSocketConstructor = WebSocket @@ -21,9 +21,7 @@ export class JWTSession extends BaseJWTSession { constructor(public options: SessionOptions) { let decodedJwt: JWTHeader = {} try { - decodedJwt = jwtDecode(options.token, { - header: true, - }) + decodedJwt = jwtDecode(options.token, { header: true }) } catch (e) { if (process.env.NODE_ENV !== 'production') { getLogger().debug('[JWTSession] error decoding the JWT') diff --git a/packages/js/src/fabric/HTTPClient.ts b/packages/js/src/fabric/HTTPClient.ts index 596956efc..e008b5c55 100644 --- a/packages/js/src/fabric/HTTPClient.ts +++ b/packages/js/src/fabric/HTTPClient.ts @@ -13,7 +13,6 @@ import type { RegisterDeviceResult, GetSubscriberInfoResponse, GetSubscriberInfoResult, - FabricUserOptions, } from './interfaces' import { CreateHttpClient, createHttpClient } from './createHttpClient' import { buildPaginatedResult } from '../utils/paginatedResult' @@ -23,7 +22,7 @@ import { isGetAddressByNameParams, isGetAddressesResponse, } from './utils/typeGuard' -import { HTTPClientContract } from './interfaces/httpClient' +import { HTTPClientContract, HTTPClientOptions } from './interfaces/httpClient' type JWTHeader = { ch?: string; typ?: string } @@ -31,7 +30,7 @@ type JWTHeader = { ch?: string; typ?: string } export class HTTPClient implements HTTPClientContract { private httpClient: CreateHttpClient - constructor(public options: FabricUserOptions) { + constructor(public options: HTTPClientOptions) { this.httpClient = createHttpClient({ baseUrl: `https://${this.httpHost}`, headers: { @@ -39,7 +38,8 @@ export class HTTPClient implements HTTPClientContract { }, maxApiRequestRetries: this.options.maxApiRequestRetries, apiRequestRetriesDelay: this.options.apiRequestRetriesDelay, - apiRequestRetriesDelayIncrement: this.options.apiRequestRetriesDelayIncrement + apiRequestRetriesDelayIncrement: + this.options.apiRequestRetriesDelayIncrement, }) } diff --git a/packages/js/src/fabric/SATSession.ts b/packages/js/src/fabric/SATSession.ts index bb527c7e7..10bb25573 100644 --- a/packages/js/src/fabric/SATSession.ts +++ b/packages/js/src/fabric/SATSession.ts @@ -9,7 +9,7 @@ import { UNIFIED_CONNECT_VERSION, } from '@signalwire/core' import { JWTSession } from '../JWTSession' -import { SATSessionOptions } from './interfaces' +import { SATSessionOptions } from './interfaces/wsClient' /** * SAT Session is for the Call Fabric SDK diff --git a/packages/js/src/fabric/SignalWire.ts b/packages/js/src/fabric/SignalWire.ts index 2b5aeb124..48a1cb31d 100644 --- a/packages/js/src/fabric/SignalWire.ts +++ b/packages/js/src/fabric/SignalWire.ts @@ -2,7 +2,11 @@ import { HTTPClient } from './HTTPClient' import { Conversation } from './Conversation' import { SignalWireClient, SignalWireClientParams } from './interfaces' import { WSClient } from './WSClient' -import { DEFAULT_API_REQUEST_RETRIES, DEFAULT_API_REQUEST_RETRIES_DELAY, DEFAULT_API_REQUEST_RETRIES_DELAY_INCREMENT } from './utils/constants' +import { + DEFAULT_API_REQUEST_RETRIES, + DEFAULT_API_REQUEST_RETRIES_DELAY, + DEFAULT_API_REQUEST_RETRIES_DELAY_INCREMENT, +} from './utils/constants' export const SignalWire = (() => { let instance: Promise | null = null @@ -14,8 +18,9 @@ export const SignalWire = (() => { const options = { maxApiRequestRetries: DEFAULT_API_REQUEST_RETRIES, apiRequestRetriesDelay: DEFAULT_API_REQUEST_RETRIES_DELAY, - apiRequestRetriesDelayIncrement: DEFAULT_API_REQUEST_RETRIES_DELAY_INCREMENT, - ...params + apiRequestRetriesDelayIncrement: + DEFAULT_API_REQUEST_RETRIES_DELAY_INCREMENT, + ...params, } const wsClient = new WSClient(options) diff --git a/packages/js/src/fabric/WSClient.ts b/packages/js/src/fabric/WSClient.ts index 9e9bd4788..892aa5eff 100644 --- a/packages/js/src/fabric/WSClient.ts +++ b/packages/js/src/fabric/WSClient.ts @@ -2,6 +2,7 @@ import { actions, BaseClient, CallJoinedEventParams as InternalCallJoinedEventParams, + selectors, VertoBye, VertoSubscribe, VideoRoomSubscribedEventParams, @@ -24,14 +25,22 @@ import { import { IncomingCallManager } from './IncomingCallManager' import { wsClientWorker } from './workers' import { createWSClient } from './createWSClient' -import { WSClientContract } from './interfaces/wsClient' +import { + BuildOutboundCallParams, + WSClientContract, +} from './interfaces/wsClient' +import { encodeAuthState } from './utils/authStateCodec' export class WSClient extends BaseClient<{}> implements WSClientContract { private _incomingCallManager: IncomingCallManager private _disconnected: boolean = false + protected fabricRoomSessionCreator = createFabricRoomSessionObject - constructor(private wsClientOptions: WSClientOptions) { - const client = createWSClient(wsClientOptions) + constructor( + protected wsClientOptions: WSClientOptions, + clientCreator: typeof createWSClient = createWSClient + ) { + const client = clientCreator(wsClientOptions) super(client) this._incomingCallManager = new IncomingCallManager({ @@ -64,7 +73,7 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { ...options } = makeRoomOptions - const room = createFabricRoomSessionObject({ + const room = this.fabricRoomSessionCreator({ ...options, store: this.store, }) @@ -156,7 +165,7 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { return room } - private buildOutboundCall(params: DialParams & { attach?: boolean }) { + protected buildOutboundCall(params: BuildOutboundCallParams) { const [pathname, query] = params.to.split('?') if (!pathname) { throw new Error('Invalid destination address') @@ -188,12 +197,22 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { attach: params.attach ?? false, disableUdpIceServers: params.disableUdpIceServers || false, userVariables: params.userVariables || this.wsClientOptions.userVariables, + onAuthStateChange: this.wsClientOptions.onAuthStateChange, + prevCallId: params.prevCallId, }) // WebRTC connection left the room. call.once('destroy', () => { this.logger.debug('RTC Connection Destroyed') call.destroy() + + if (this.wsClientOptions.onAuthStateChange) { + const protocol = selectors.getProtocol(this.store.getState()) + const authState = selectors.getAuthorizationState(this.store.getState()) + + const encode = encodeAuthState({ authState, protocol }) + this.wsClientOptions.onAuthStateChange(encode) + } }) this.session.once('session.disconnected', () => { @@ -307,7 +326,7 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { const call = this.buildOutboundCall(params) resolve(call) } catch (error) { - this.logger.error('Unable to connect and dial a call', error) + this.logger.error('Unable to dial a call', error) reject(error) } }) @@ -319,7 +338,7 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { const call = this.buildOutboundCall({ ...params, attach: true }) resolve(call) } catch (error) { - this.logger.error('Unable to connect and reattach a call', error) + this.logger.error('Unable to reattach a call', error) reject(error) } }) @@ -373,9 +392,6 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { }) } - /** - * Mark the client as 'online' to receive calls over WebSocket - */ public async online({ incomingCallHandlers }: OnlineParams) { this._incomingCallManager.setNotificationHandlers(incomingCallHandlers) return this.execute({ @@ -384,9 +400,6 @@ export class WSClient extends BaseClient<{}> implements WSClientContract { }) } - /** - * Mark the client as 'offline' to receive calls over WebSocket - */ public offline() { this._incomingCallManager.setNotificationHandlers({}) return this.execute({ diff --git a/packages/js/src/fabric/index.ts b/packages/js/src/fabric/index.ts index 5f920f250..4dbd7bcc5 100644 --- a/packages/js/src/fabric/index.ts +++ b/packages/js/src/fabric/index.ts @@ -10,6 +10,7 @@ export * from './interfaces/capabilities' export * from './interfaces/conversation' export * from './interfaces/device' export * from './interfaces/incomingCallManager' +export * from './v4/SignalWireV4' export { OnlineParams, diff --git a/packages/js/src/fabric/interfaces/httpClient.ts b/packages/js/src/fabric/interfaces/httpClient.ts index 6381b7336..d4a2f4879 100644 --- a/packages/js/src/fabric/interfaces/httpClient.ts +++ b/packages/js/src/fabric/interfaces/httpClient.ts @@ -10,6 +10,7 @@ import { RegisterDeviceResult, UnregisterDeviceParams, } from './device' +import { WSClientOptions } from './wsClient' export interface HTTPClientContract { /** @@ -48,3 +49,5 @@ export interface HTTPClientContract { */ getSubscriberInfo(): Promise } + +export type HTTPClientOptions = WSClientOptions diff --git a/packages/js/src/fabric/interfaces/wsClient.ts b/packages/js/src/fabric/interfaces/wsClient.ts index 256b7d314..3b7711b9c 100644 --- a/packages/js/src/fabric/interfaces/wsClient.ts +++ b/packages/js/src/fabric/interfaces/wsClient.ts @@ -100,6 +100,11 @@ export interface DialParams extends CallParams { nodeId?: string } +export interface BuildOutboundCallParams extends DialParams { + prevCallId?: string + attach?: boolean +} + export interface ApiRequestRetriesOptions { /** Increment step for each retry delay */ apiRequestRetriesDelayIncrement?: number diff --git a/packages/js/src/fabric/utils/authStateCodec.ts b/packages/js/src/fabric/utils/authStateCodec.ts new file mode 100644 index 000000000..66632feb2 --- /dev/null +++ b/packages/js/src/fabric/utils/authStateCodec.ts @@ -0,0 +1,69 @@ +interface EncodeAuthStateParams { + authState?: string + protocol?: string + callId?: string +} + +export const encodeAuthState = (params: EncodeAuthStateParams): string => { + try { + const json = JSON.stringify(params) + let encoded: string + + // For Node JS environment + if (typeof Buffer !== 'undefined' && Buffer.from) { + encoded = Buffer.from(json, 'utf-8').toString('base64') + } + // For Browser environment + else if ( + typeof window !== 'undefined' && + window.btoa && + window.TextEncoder + ) { + const utf8Bytes = new TextEncoder().encode(json) + let binary = '' + utf8Bytes.forEach((byte) => { + binary += String.fromCharCode(byte) + }) + encoded = window.btoa(binary) + } else { + throw new Error('No suitable Base64 encoding method available.') + } + return encoded + } catch (error) { + throw new Error( + `Encoding failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} + +export const decodeAuthState = (authState: string): EncodeAuthStateParams => { + try { + let json: string + + // For Node JS environment + if (typeof Buffer !== 'undefined' && Buffer.from) { + json = Buffer.from(authState, 'base64').toString('utf-8') + } + // For Browser environment + else if ( + typeof window !== 'undefined' && + window.atob && + window.TextDecoder + ) { + const binary = window.atob(authState) + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)) + json = new TextDecoder().decode(bytes) + } else { + throw new Error('No suitable Base64 decoding method available.') + } + return JSON.parse(json) + } catch (error) { + throw new Error( + `Decoding failed: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } +} diff --git a/packages/js/src/fabric/v4/FabricRoomSessionV4.ts b/packages/js/src/fabric/v4/FabricRoomSessionV4.ts new file mode 100644 index 000000000..f571eb98c --- /dev/null +++ b/packages/js/src/fabric/v4/FabricRoomSessionV4.ts @@ -0,0 +1,60 @@ +import { connect, OnAuthStateChange, selectors } from '@signalwire/core' +import { FabricRoomSessionEvents } from '../../utils/interfaces' +import { + FabricRoomSession, + FabricRoomSessionConnection, + FabricRoomSessionOptions, +} from '../FabricRoomSession' +import { encodeAuthState } from '../utils/authStateCodec' + +export interface FabricRoomSessionOptionsV4 extends FabricRoomSessionOptions { + onAuthStateChange?: OnAuthStateChange +} + +export class FabricRoomSessionConnectionV4 extends FabricRoomSessionConnection { + constructor(private fabricOptions: FabricRoomSessionOptionsV4) { + super(fabricOptions) + } + + public override async start() { + return new Promise(async (resolve, reject) => { + try { + this.once('room.subscribed', () => { + resolve() + + if (this.fabricOptions.onAuthStateChange) { + const protocol = selectors.getProtocol(this.store.getState()) + const authState = selectors.getAuthorizationState( + this.store.getState() + ) + const callId = this.callId + + const encode = encodeAuthState({ authState, protocol, callId }) + this.fabricOptions.onAuthStateChange(encode) + } + }) + + await super.invite() + } catch (error) { + this.logger.error('WSClient call start', error) + reject(error) + } + }) + } +} + +/** @internal */ +export const createFabricRoomSessionObjectV4 = ( + params: FabricRoomSessionOptionsV4 +): FabricRoomSession => { + const room = connect< + FabricRoomSessionEvents, + FabricRoomSessionConnectionV4, + FabricRoomSession + >({ + store: params.store, + Component: FabricRoomSessionConnectionV4, + })(params) + + return room +} diff --git a/packages/js/src/fabric/v4/SATSessionV4.ts b/packages/js/src/fabric/v4/SATSessionV4.ts new file mode 100644 index 000000000..f35ad4d1c --- /dev/null +++ b/packages/js/src/fabric/v4/SATSessionV4.ts @@ -0,0 +1,132 @@ +import jwtDecode from 'jwt-decode' +import { + asyncRetry, + BaseJWTSession, + getLogger, + increasingDelay, + JSONRPCRequest, + JSONRPCResponse, + RPCConnect, + RPCConnectParams, + RPCReauthenticate, + RPCReauthenticateParams, + SATAuthorization, + UNIFIED_CONNECT_VERSION, +} from '@signalwire/core' +import { JWTHeader } from '../../JWTSession' +import { SwCloseEvent } from '../../utils/CloseEvent' +import { decodeAuthState } from '../utils/authStateCodec' +import { SATSessionOptions } from '../interfaces/wsClient' + +/** + * SAT Session is for the Call Fabric SDK + */ +export class SATSessionV4 extends BaseJWTSession { + public connectVersion = UNIFIED_CONNECT_VERSION + public WebSocketConstructor = WebSocket + public CloseEventConstructor = SwCloseEvent + public agent = process.env.SDK_PKG_AGENT! + + constructor(public options: SATSessionOptions) { + let decodedJwt: JWTHeader = {} + try { + decodedJwt = jwtDecode(options.token, { header: true }) + } catch (e) { + getLogger().debug('[SATSession] error decoding the JWT') + } + + super({ + ...options, + host: decodedJwt?.ch || options.host, + }) + } + + override get signature() { + if (this._rpcConnectResult) { + const { authorization } = this._rpcConnectResult + return (authorization as SATAuthorization).jti + } + return undefined + } + + /** + * Authenticate with the SignalWire Network using SAT + * @return Promise + */ + override async authenticate() { + let authState: string | undefined + let protocol: string | undefined + + if (this.options.authState?.length) { + const decoded = decodeAuthState(this.options.authState) + authState = decoded.authState + protocol = decoded.protocol + } + + const params: RPCConnectParams = { + ...this._connectParams, + authentication: { + jwt_token: this.options.token, + }, + ...(authState && { authorization_state: authState }), + ...(protocol && { protocol }), + } + + try { + this._rpcConnectResult = await this.execute(RPCConnect(params)) + } catch (error) { + this.logger.debug('SATSession authenticate error', error) + throw error + } + } + + /** + * Reauthenticate with the SignalWire Network using a newer SAT. + * If the session has expired, this will reconnect it. + * @return Promise + */ + override async reauthenticate() { + this.logger.debug('Session Reauthenticate', { + ready: this.ready, + expired: this.expired, + }) + if (!this.ready || this.expired) { + return this.connect() + } + + const params: RPCReauthenticateParams = { + project: this._rpcConnectResult.authorization.project_id, + jwt_token: this.options.token, + } + + try { + const reauthResponse = await this.execute(RPCReauthenticate(params)) + + this._rpcConnectResult = { + ...this._rpcConnectResult, + ...reauthResponse, + } + } catch (error) { + this.logger.debug('Session Reauthenticate Failed', error) + throw error + } + } + + override async execute(msg: JSONRPCRequest | JSONRPCResponse): Promise { + return asyncRetry({ + asyncCallable: () => super.execute(msg), + maxRetries: this.options.maxApiRequestRetries, + delayFn: increasingDelay({ + initialDelay: this.options.apiRequestRetriesDelay, + variation: this.options.apiRequestRetriesDelayIncrement, + }), + expectedErrorHandler: (error) => { + if (error?.message?.startsWith('Authentication failed')) { + // is expected to be handle by the app developer, skipping retries + return true + } + return false + }, + }) + } +} diff --git a/packages/js/src/fabric/v4/SignalWireV4.ts b/packages/js/src/fabric/v4/SignalWireV4.ts new file mode 100644 index 000000000..b5037eccd --- /dev/null +++ b/packages/js/src/fabric/v4/SignalWireV4.ts @@ -0,0 +1,75 @@ +import { HTTPClient } from '../HTTPClient' +import { Conversation } from '../Conversation' +import { SignalWireClient, SignalWireClientParams } from '../interfaces' +import { + DEFAULT_API_REQUEST_RETRIES, + DEFAULT_API_REQUEST_RETRIES_DELAY, + DEFAULT_API_REQUEST_RETRIES_DELAY_INCREMENT, +} from '../utils/constants' +import { WSClientV4 } from './WSClientV4' + +export const SignalWireV4 = ( + params: SignalWireClientParams +): Promise => { + return new Promise(async (resolve, reject) => { + if (!params.token) { + throw new Error('Token is required') + } + + try { + const options = { + maxApiRequestRetries: DEFAULT_API_REQUEST_RETRIES, + apiRequestRetriesDelay: DEFAULT_API_REQUEST_RETRIES_DELAY, + apiRequestRetriesDelayIncrement: + DEFAULT_API_REQUEST_RETRIES_DELAY_INCREMENT, + ...params, + } + + const wsClient = new WSClientV4(options) + const httpClient = new HTTPClient(options) + const conversation = new Conversation({ httpClient, wsClient }) + + // Connect the WebSocket and authenticate the user + await wsClient.connect() + + resolve({ + registerDevice: httpClient.registerDevice.bind(httpClient), + unregisterDevice: httpClient.unregisterDevice.bind(httpClient), + getSubscriberInfo: httpClient.getSubscriberInfo.bind(httpClient), + disconnect: wsClient.disconnect.bind(wsClient), + online: wsClient.online.bind(wsClient), + offline: wsClient.offline.bind(wsClient), + dial: wsClient.dial.bind(wsClient), + reattach: wsClient.reattach.bind(wsClient), + handlePushNotification: wsClient.handlePushNotification.bind(wsClient), + updateToken: wsClient.updateToken.bind(wsClient), + address: { + getAddresses: httpClient.getAddresses.bind(httpClient), + getAddress: httpClient.getAddress.bind(httpClient), + }, + conversation: { + getConversations: conversation.getConversations.bind(conversation), + getMessages: conversation.getMessages.bind(conversation), + getConversationMessages: + conversation.getConversationMessages.bind(conversation), + subscribe: conversation.subscribe.bind(conversation), + sendMessage: conversation.sendMessage.bind(conversation), + join: conversation.joinConversation.bind(conversation), + }, + chat: { + getMessages: conversation.getChatMessages.bind(conversation), + subscribe: conversation.subscribeChatMessages.bind(conversation), + sendMessage: conversation.sendMessage.bind(conversation), + join: conversation.joinConversation.bind(conversation), + }, + // @ts-expect-error For debugging purposes + on: wsClient.on.bind(wsClient), + off: wsClient.off.bind(wsClient), + __httpClient: httpClient, + __wsClient: wsClient, + }) + } catch (error) { + reject(error) + } + }) +} diff --git a/packages/js/src/fabric/v4/WSClientV4.ts b/packages/js/src/fabric/v4/WSClientV4.ts new file mode 100644 index 000000000..c03a3f96f --- /dev/null +++ b/packages/js/src/fabric/v4/WSClientV4.ts @@ -0,0 +1,46 @@ +import { WSClient } from '../WSClient' +import { DialParams, WSClientOptions } from '../interfaces' +import { FabricRoomSession } from '../FabricRoomSession' +import { decodeAuthState } from '../utils/authStateCodec' +import { createWSClientV4 } from './createWSClientV4' +import { createFabricRoomSessionObjectV4 } from './FabricRoomSessionV4' +import { wsClientWorkerV4 } from './wsClientWorkerV4' + +export class WSClientV4 extends WSClient { + protected fabricRoomSessionCreator = createFabricRoomSessionObjectV4 + + constructor(public wsClientOptions: WSClientOptions) { + super(wsClientOptions, createWSClientV4) + + this.runWorker('wsClientWorkerV4', { + worker: wsClientWorkerV4, + }) + } + + public override async reattach(params: DialParams) { + return new Promise(async (resolve, reject) => { + try { + if (!this.wsClientOptions.authState) { + throw new Error( + 'No "authState" provided during the client initialization' + ) + } + + const decoded = decodeAuthState(this.wsClientOptions.authState) + if (!decoded.callId) { + throw new Error('Invalid Call ID in authState') + } + + const call = this.buildOutboundCall({ + ...params, + attach: true, + prevCallId: decoded.callId, + }) + resolve(call) + } catch (error) { + this.logger.error('Unable to reattach a call', error) + reject(error) + } + }) + } +} diff --git a/packages/js/src/fabric/v4/createWSClientV4.ts b/packages/js/src/fabric/v4/createWSClientV4.ts new file mode 100644 index 000000000..cafd9a89b --- /dev/null +++ b/packages/js/src/fabric/v4/createWSClientV4.ts @@ -0,0 +1,12 @@ +import { configureStore } from '@signalwire/core' +import { SATSessionV4 } from './SATSessionV4' +import { WSClientOptions } from '../interfaces' + +export const createWSClientV4 = (options: WSClientOptions) => { + const store = configureStore({ + userOptions: options, + SessionConstructor: SATSessionV4, + }) + + return { ...options, store } +} diff --git a/packages/js/src/fabric/v4/wsClientWorkerV4.ts b/packages/js/src/fabric/v4/wsClientWorkerV4.ts new file mode 100644 index 000000000..31705d936 --- /dev/null +++ b/packages/js/src/fabric/v4/wsClientWorkerV4.ts @@ -0,0 +1,69 @@ +import { + getLogger, + sagaEffects, + SagaIterator, + SDKWorker, + SDKActions, + MapToPubSubShape, + SwAuthorizationStateEvent, + selectors, + componentSelectors, + ReduxComponent, +} from '@signalwire/core' +import { WSClientV4 } from './WSClientV4' +import { encodeAuthState } from '../utils/authStateCodec' + +export const wsClientWorkerV4: SDKWorker = function* ( + options +): SagaIterator { + getLogger().debug('wsClientWorkerV4 started') + const { channels, instance: client } = options + const { swEventChannel } = channels + + function* authStateWorker( + action: MapToPubSubShape + ) { + if (client.wsClientOptions.onAuthStateChange) { + const authState = action.payload.authorization_state + const protocol = selectors.getProtocol(client.store.getState()) + + /** + * The reattach is only possible to first call leg; originCallId + * We store this origin call ID in the Redux Store via {@link roomSubscribedWorker} + */ + const components = componentSelectors.getComponentsById( + client.store.getState() + ) + const callId = Object.values(components).find( + (comp): comp is ReduxComponent & { originCallId: string } => + 'originCallId' in comp + )?.originCallId + + const encode = encodeAuthState({ authState, protocol, callId }) + + client.wsClientOptions.onAuthStateChange(encode) + } + } + + const isAuthState = (action: SDKActions) => { + return action.type === 'signalwire.authorization.state' + } + + try { + while (true) { + // Take all actions from the channel + const action: SDKActions = yield sagaEffects.take( + swEventChannel, + () => true + ) + + // If the event is signalwire.authorization.state, handle that with authStateWorker + if (isAuthState(action)) { + getLogger().debug('An authorization state is received', action) + yield sagaEffects.fork(authStateWorker, action) + } + } + } finally { + getLogger().trace('wsClientWorkerV4 ended') + } +} diff --git a/packages/js/src/fabric/workers/callJoinWorker.ts b/packages/js/src/fabric/workers/callJoinWorker.ts index 6c675bc98..a4485583f 100644 --- a/packages/js/src/fabric/workers/callJoinWorker.ts +++ b/packages/js/src/fabric/workers/callJoinWorker.ts @@ -12,8 +12,8 @@ import { } from '../FabricRoomSessionMember' import { FabricWorkerParams } from './fabricWorker' import { fabricMemberWorker } from './fabricMemberWorker' -import { mapCallJoinedToRoomSubscribedEventParams } from '../utils/eventMappers' import { mapCapabilityPayload } from '../utils/capabilitiesHelpers' +import { mapCallJoinedToRoomSubscribedEventParams } from '../utils/eventMappers' export const callJoinWorker = function* ( options: FabricWorkerParams diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 55a3dc5ca..69049a6d6 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -61,7 +61,7 @@ export * as PubSub from './pubSub' * with Chat/Messaging capabilties. */ export * as Fabric from './fabric' -export { SignalWire } from './fabric' +export { SignalWire, SignalWireV4 } from './fabric' export * from './fabric' /** diff --git a/packages/webrtc/src/workers/roomSubscribedWorker.ts b/packages/webrtc/src/workers/roomSubscribedWorker.ts index 42f563ff7..d514a89c6 100644 --- a/packages/webrtc/src/workers/roomSubscribedWorker.ts +++ b/packages/webrtc/src/workers/roomSubscribedWorker.ts @@ -67,6 +67,9 @@ export const roomSubscribedWorker: SDKWorker< yield sagaEffects.put( componentActions.upsert({ id: action.payload.call_id, + ...('origin_call_id' in action.payload && { + originCallId: action.payload.origin_call_id, + }), roomId: action.payload.room_session.room_id, roomSessionId: roomSessionId, memberId: action.payload.member_id,