diff --git a/.eslintrc.js b/.eslintrc.js index 2986d8be..c174971b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,6 +6,7 @@ module.exports = { 'no-undef': 'off', '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 'array-callback-return': 'off', + 'max-nested-callbacks': 'off', }, extends: [ 'alloy', diff --git a/package.json b/package.json index 4720575f..bfa867fc 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "react-qr-code": "^2.0.8", "react-use": "^17.4.0", "rxjs": "^7.5.7", + "tslog": "^4.7.1", "zod": "^3.19.1" }, "devDependencies": { diff --git a/src/chrome/background-with-dev-tools.ts b/src/chrome/background-with-dev-tools.ts deleted file mode 100644 index 2ffa4365..00000000 --- a/src/chrome/background-with-dev-tools.ts +++ /dev/null @@ -1,29 +0,0 @@ -import './background' -import { getExtensionTabsByUrl } from 'chrome/helpers/get-extension-tabs-by-url' -import { config } from 'config' - -chrome.contextMenus.create({ - id: 'radix-dev-tools', - title: 'Radix dev tools', - contexts: ['all'], -}) - -const openRadixDevToolsPage = async () => { - const devToolsUrl = chrome.runtime.getURL(config.popup.pages.devTools) - - const result = await getExtensionTabsByUrl(config.popup.pages.devTools) - - if (result.isErr()) return - - const [devToolsTab] = result.value - - if (devToolsTab?.id) { - await chrome.tabs.update(devToolsTab.id, { active: true }) - } else { - await chrome.tabs.create({ - url: devToolsUrl, - }) - } -} - -chrome.contextMenus.onClicked.addListener(async () => openRadixDevToolsPage()) diff --git a/src/chrome/background.ts b/src/chrome/background.ts index 815182dd..a001f411 100644 --- a/src/chrome/background.ts +++ b/src/chrome/background.ts @@ -4,15 +4,10 @@ import { getExtensionTabsByUrl } from 'chrome/helpers/get-extension-tabs-by-url' import { getPopupId } from 'chrome/helpers/get-popup-id' import { setPopupId } from 'chrome/helpers/set-popup-id' import { config } from 'config' -import { getLogger } from 'loglevel' import { ok } from 'neverthrow' -import { createChromeApi } from './chrome-api' import { closePopup } from './helpers/close-popup' import { getActiveWindow } from './helpers/get-active-window' - -const logger = getLogger('background') - -const chromeAPI = createChromeApi(config.storage.key, logger) +import { chromeLocalStore } from './helpers/chrome-local-store' const createOrFocusPopupWindow = () => getExtensionTabsByUrl(config.popup.pages.pairing) @@ -31,19 +26,16 @@ const createOrFocusPopupWindow = () => ) const handleIncomingMessage = () => - chromeAPI - .getConnectionPassword() - .andThen((connectionPassword) => + chromeLocalStore + .getItem('connectionPassword') + .andThen(({ connectionPassword }) => connectionPassword ? closePopup() : createOrFocusPopupWindow() ) const handleConnectionPasswordChange = async (changes: { [key: string]: chrome.storage.StorageChange }) => { - const connectionPasswordKey = Object.keys(changes).find((key) => - key.includes(':connectionPassword') - ) - if (connectionPasswordKey && changes[connectionPasswordKey].newValue) { + if (changes['connectionPassword']?.newValue) { setTimeout(() => { closePopup() }, config.popup.closeDelayTime) diff --git a/src/chrome/chrome-connector-client.ts b/src/chrome/chrome-connector-client.ts index 669eb797..fd832535 100644 --- a/src/chrome/chrome-connector-client.ts +++ b/src/chrome/chrome-connector-client.ts @@ -1,42 +1,66 @@ -import { Connector, ConnectorType } from 'connector/connector' -import { StorageClient } from 'connector/storage/storage-client' +import { ConnectorClient } from 'connector/connector-client' import { config } from 'config' -import log, { LogLevelDesc } from 'loglevel' import { map, Subscription } from 'rxjs' import { ChromeDAppClient } from './chrome-dapp-client' +import { Logger } from 'tslog' +import { chromeLocalStore } from './helpers/chrome-local-store' const chromeDAppClient = ChromeDAppClient() -export const ChromeConnectorClient = (logLevel: LogLevelDesc) => { - let connector: ConnectorType | undefined +export const ChromeConnectorClient = () => { + let connector: ConnectorClient | undefined let subscriptions: Subscription | undefined - const logger = log - - chrome.storage.local.get('loglevel').then((value) => { - const storedLoglevel = value['loglevel'] - - if (storedLoglevel === 'DEBUG') - console.log(`Radix Connector loglevel: 'debug'`) - - logger.setLevel(storedLoglevel || logLevel) - }) const createConnector = () => { - connector = Connector({ - logger, - storageClient: StorageClient({ id: config.storage.key }), - generateConnectionPassword: false, + connector = ConnectorClient({ + source: 'extension', + target: 'wallet', + signalingServerBaseUrl: config.signalingServer.baseUrl, + isInitiator: false, + logger: new Logger({ + prettyLogTemplate: '{{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}\t', + minLevel: 2, + }), }) + connector.connect() - subscriptions = connector.message$ - .pipe(map((result) => result.map(chromeDAppClient.sendMessage))) + + subscriptions = connector.onMessage$ + .pipe(map((message) => chromeDAppClient.sendMessage(message))) .subscribe() + + chromeLocalStore + .getItem('connectionPassword') + .map(({ connectionPassword }) => { + if (connectionPassword) + connector?.setConnectionPassword( + Buffer.from(connectionPassword, 'hex') + ) + }) + } + + const onChange = ({ + connectionPassword, + }: { + [key: string]: chrome.storage.StorageChange + }) => { + if (!connectionPassword.newValue) { + connector?.disconnect() + } else if (connectionPassword.newValue) { + connector?.setConnectionPassword( + Buffer.from(connectionPassword.newValue, 'hex') + ) + connector?.connect() + } } + chrome.storage.onChanged.addListener(onChange) + const destroy = () => { subscriptions?.unsubscribe() subscriptions = undefined connector?.destroy() + chrome.storage.onChanged.removeListener(onChange) connector = undefined } diff --git a/src/chrome/content.ts b/src/chrome/content.ts index 0e829c5c..35311525 100644 --- a/src/chrome/content.ts +++ b/src/chrome/content.ts @@ -1,9 +1,8 @@ -import { config } from 'config' import { ChromeConnectorClient } from './chrome-connector-client' import { ChromeDAppClient, messageLifeCycleEvent } from './chrome-dapp-client' import { decorateMessage } from './helpers/decorate-message' -const connectorClient = ChromeConnectorClient(config.logLevel) +const connectorClient = ChromeConnectorClient() const chromeDAppClient = ChromeDAppClient() chromeDAppClient.messageListener((message) => { diff --git a/src/chrome/dev-tools/components/connection-secret.tsx b/src/chrome/dev-tools/components/connection-secret.tsx deleted file mode 100644 index b34ea731..00000000 --- a/src/chrome/dev-tools/components/connection-secret.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { ConnectorContext } from 'contexts/connector-context' -import { useContext, useState } from 'react' -import { Box, Button } from 'components' - -export const ConnectionSecret = () => { - const connector = useContext(ConnectorContext) - const [text, setText] = useState('') - - const onSubmit = () => { - connector?.setConnectionPassword(text) - setText('') - } - - return ( - - - setText(ev.target.value)} - value={text} - /> - - - - - ) -} diff --git a/src/chrome/dev-tools/components/connection-status.tsx b/src/chrome/dev-tools/components/connection-status.tsx deleted file mode 100644 index 5c20a079..00000000 --- a/src/chrome/dev-tools/components/connection-status.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Text } from 'components' -import { Status } from 'connector' -import { useConnectionStatus } from 'hooks/use-connection-status' - -const renderStatusIcon = (connectionStatus: Status | undefined) => { - if (connectionStatus === 'connected') { - return `🟒` - } else if (connectionStatus === 'disconnected') { - return 'πŸ”΄' - } else { - return `βšͺ️` - } -} - -export const ConnectionStatus = () => { - const connectionStatus = useConnectionStatus() - - return πŸ•Έ Data channel: {renderStatusIcon(connectionStatus)} -} diff --git a/src/chrome/dev-tools/components/dev-tools.tsx b/src/chrome/dev-tools/components/dev-tools.tsx deleted file mode 100644 index a0ccdf84..00000000 --- a/src/chrome/dev-tools/components/dev-tools.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Box } from 'components' -import { SignalingServer } from './signaling-server' -import { ConnectionStatus } from './connection-status' -import { Message } from './message' -import { ConnectionSecret } from './connection-secret' - -export const DevTools = () => ( - - - - - - -) diff --git a/src/chrome/dev-tools/components/message-actions.tsx b/src/chrome/dev-tools/components/message-actions.tsx deleted file mode 100644 index 9c198d21..00000000 --- a/src/chrome/dev-tools/components/message-actions.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Box, Button } from 'components' -import { useConnector } from 'hooks/use-connector' -import { createMockResponse } from '../helpers/create-mock-response' - -export const MessageActions = ({ - message, - clearMessage, -}: { - message: any - clearMessage: () => void -}) => { - const connector = useConnector() - - const onReject = () => { - connector?.sendMessage({ - error: 'rejectedByUser', - message: 'user rejected request', - requestId: message.requestId, - }) - clearMessage() - } - const onApprove = async () => { - const response = await createMockResponse(message) - connector?.sendMessage(response) - clearMessage() - } - - return ( - - - - - - - ) -} diff --git a/src/chrome/dev-tools/components/message.tsx b/src/chrome/dev-tools/components/message.tsx deleted file mode 100644 index ccfc0362..00000000 --- a/src/chrome/dev-tools/components/message.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable max-nested-callbacks */ -import { Box } from 'components' -import { useConnector } from 'hooks/use-connector' -import { useEffect, useState } from 'react' -import { PreviewMessage } from './preview-message' -import { MessageActions } from './message-actions' - -export const Message = () => { - const connector = useConnector() - - const [message, setMessage] = useState< - { message: any; formatted: string } | undefined - >() - - useEffect(() => { - if (!connector) return - - const subscription = connector.message$.subscribe((result) => { - result.map((message) => - setMessage({ message, formatted: JSON.stringify(message, null, 2) }) - ) - }) - - return () => { - subscription.unsubscribe() - } - }, [connector]) - - if (!message) return null - - return ( - - - setMessage(undefined)} - /> - - ) -} diff --git a/src/chrome/dev-tools/components/preview-message.tsx b/src/chrome/dev-tools/components/preview-message.tsx deleted file mode 100644 index 97ab5f56..00000000 --- a/src/chrome/dev-tools/components/preview-message.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable max-nested-callbacks */ -import { Box, Text } from 'components' - -export const PreviewMessage = ({ text }: { text: string }) => ( - - Incoming message: -
-      {text}
-    
-
-) diff --git a/src/chrome/dev-tools/components/signaling-server.tsx b/src/chrome/dev-tools/components/signaling-server.tsx deleted file mode 100644 index 697766ed..00000000 --- a/src/chrome/dev-tools/components/signaling-server.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { Text } from 'components' -import { useConnector } from 'hooks/use-connector' -import { useEffect, useState } from 'react' -import { Subscription } from 'rxjs' -import { Status } from '../../../connector' - -export const SignalingServer = () => { - const connector = useConnector() - const [status, setStatus] = useState() - - useEffect(() => { - if (!connector) return - - connector?.signalingServerClient.subjects.wsConnectSubject.next(true) - - const subscription = new Subscription() - - subscription.add( - connector.signalingServerClient.subjects.wsStatusSubject.subscribe( - (status) => { - setStatus(status) - } - ) - ) - - subscription.add( - connector?.signalingServerClient.subjects.wsIncomingRawMessageSubject.subscribe( - (raw) => { - const message = JSON.parse(raw.data) - if ( - [ - 'remoteClientJustConnected', - 'remoteClientIsAlreadyConnected', - ].includes(message.info) - ) { - connector?.webRtcClient.subjects.rtcCreateOfferSubject.next() - } - } - ) - ) - - return () => { - subscription.unsubscribe() - } - }, [connector]) - - return ( - - πŸ“‘ Signaling server:{' '} - {status === 'connected' ? `🟒` : status === 'disconnected' ? 'πŸ”΄' : 'βšͺ️'} - - ) -} diff --git a/src/chrome/dev-tools/dev-tools.html b/src/chrome/dev-tools/dev-tools.html deleted file mode 100644 index 909aab77..00000000 --- a/src/chrome/dev-tools/dev-tools.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Radix Dev Tools - - -
- - - diff --git a/src/chrome/dev-tools/helpers/create-mock-response.ts b/src/chrome/dev-tools/helpers/create-mock-response.ts deleted file mode 100644 index 3703fbf0..00000000 --- a/src/chrome/dev-tools/helpers/create-mock-response.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Buffer } from 'buffer' -import { KeyPair } from './keypair' - -const mockRequestPayloads = { - accountAddresses: { - addresses: [ - { - label: 'Main account', - address: - 'account_tdx_a_1qv3jfqugkm70ely0trae20wcwealxmj5zsacnhkllhgqlccnrp', - }, - { - label: "NFT's", - address: - 'account_tdx_a_1qd5svul20u30qnq408zhj2tw5evqrunq48eg0jsjf9qsx5t8qu', - }, - { - label: 'Savings', - address: - 'account_tdx_a_1qwz8dwm79jpq8fagt9vx0mug22ckznh3g45mfv4lmq2sjlwzqj', - }, - ], - }, - personaData: { - fields: [ - { field: 'firstName', value: 'alex' }, - { - field: 'email', - value: 'alex@rdx.works', - }, - ], - }, -} as any - -const getMockRequestTypePayload = async ( - item: any, - message: Record -) => { - if (item.requestType === 'login') { - const loginPayload = await handleLogin(item, message) - return { ...item, ...loginPayload } - } - - const mockData = mockRequestPayloads[item.requestType] - - if (!mockData) throw new Error('unsupported request type') - - return { ...item, ...mockData } -} - -const getWellKnown = async (origin: string) => { - const result = await fetch(`${origin}/.well-known/radix.json`, { - headers: { 'Content-type': 'application/json' }, - }) - return result.json() -} - -const handleLogin = async ( - item: Record, - message: Record -) => { - const dAppId = item.dAppId - const wellKnown = await getWellKnown(message.metadata.origin) - const expectedDApp = wellKnown.dApps.find((dApp: any) => dApp.id === dAppId) - - if (!expectedDApp) throw new Error('missing expectedDApp') - - const challenge: string = item.challenge - const dAppDefinitionAddress = expectedDApp.definitionAddress - const origin = message.metadata.origin - - const messageToSignBuffer = Buffer.concat([ - Buffer.from(challenge, 'hex'), - Buffer.from(dAppDefinitionAddress), - Buffer.from(origin), - ]) - - const keyPair = await KeyPair() - - const signatureBuffer = await keyPair.sign(messageToSignBuffer) - - console.log(messageToSignBuffer.toString('hex')) - - return { - challenge, - signature: signatureBuffer.toString('hex'), - publicKey: keyPair.publicKeyHex, - identityComponentAddress: - 'account_tdx_a_1qv3jfqugkm70ely0trae20wcwealxmj5zsacnhkllhgqlccnrp', - origin, - } -} - -export const createMockResponse = async (message: any) => { - let response: any = { ...message } - - switch (message.method) { - case 'request': - response.payload = [] - - for (const item of message.payload) { - response.payload.push(await getMockRequestTypePayload(item, message)) - } - break - - case 'sendTransaction': - response.payload = { transactionIntentHash: crypto.randomUUID() } - break - - default: - throw new Error('unsupported method') - } - - return response -} diff --git a/src/chrome/dev-tools/helpers/keypair.ts b/src/chrome/dev-tools/helpers/keypair.ts deleted file mode 100644 index cf4f47cf..00000000 --- a/src/chrome/dev-tools/helpers/keypair.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Buffer } from 'buffer' - -export const KeyPair = async () => { - const keyPair = await crypto.subtle.generateKey( - { - name: 'ECDSA', - namedCurve: 'P-256', - }, - true, - ['sign', 'verify'] - ) - - const publicKeyHex = Buffer.from( - await crypto.subtle.exportKey('raw', keyPair.publicKey) - ).toString('hex') - - const sign = async (messageBuffer: Buffer) => - Buffer.from( - await crypto.subtle.sign( - { - name: 'ECDSA', - hash: { name: 'SHA-256' }, - }, - keyPair.privateKey, - messageBuffer - ) - ) - - return { sign, publicKeyHex } -} diff --git a/src/chrome/dev-tools/main.tsx b/src/chrome/dev-tools/main.tsx deleted file mode 100644 index 1392d1ca..00000000 --- a/src/chrome/dev-tools/main.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import ReactDOM from 'react-dom/client' -import '../../../fonts.css' -import { DevTools } from './components/dev-tools' -import { ConnectorContext } from 'contexts/connector-context' -import { Connector } from 'connector/connector' -import { SignalingServerClient } from 'connector/signaling/signaling-server-client' - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -) diff --git a/src/chrome/helpers/chrome-local-store.ts b/src/chrome/helpers/chrome-local-store.ts new file mode 100644 index 00000000..62d015d2 --- /dev/null +++ b/src/chrome/helpers/chrome-local-store.ts @@ -0,0 +1,19 @@ +import { ResultAsync } from 'neverthrow' + +export const chromeLocalStore = { + setItem: (value: Record) => + ResultAsync.fromPromise( + chrome.storage.local.set(value), + (error) => error as Error + ), + removeItem: (key: string) => + ResultAsync.fromPromise( + chrome.storage.local.remove(key), + (error) => error as Error + ), + getItem: (key: string | null) => + ResultAsync.fromPromise( + chrome.storage.local.get(key), + (error) => error as Error + ), +} as const diff --git a/src/config.ts b/src/config.ts index 16c1aea3..d9a2fbf6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,9 @@ import { LogLevelDesc } from 'loglevel' import packageJson from '../package.json' const { version } = packageJson +import { Buffer } from 'buffer' + +globalThis.Buffer = Buffer const turnServers = { test: [ diff --git a/src/connector/__tests__/webrtc-flow.test.ts b/src/connector/__tests__/webrtc-flow.test.ts deleted file mode 100644 index 2ac5a1ab..00000000 --- a/src/connector/__tests__/webrtc-flow.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import log from 'loglevel' -import { filter, firstValueFrom, Observable } from 'rxjs' -import { subscribeSpyTo } from '@hirez_io/observer-spy' -import { delayAsync } from 'test-utils/delay-async' -import { Connector, ConnectorType } from '../connector' -import { Status } from 'connector/_types' -import { SignalingServerClient } from 'connector/signaling/signaling-server-client' - -const oneMB = new Array(1000) - .fill(null) - .map( - () => - `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean eu odio consectetur, varius lorem quis, finibus enim. Aliquam erat volutpat. Vivamus posuere sit amet justo ut vulputate. Nam ultrices nec tortor at pulvinar. Nunc et nibh purus. Donec vehicula venenatis risus eu sollicitudin. Sed posuere eu odio ac semper. Sed vitae est id dui blandit aliquet. Sed dapibus mi dui, ut rhoncus dolor aliquet tempus. Nam fermentum justo a arcu egestas, id laoreet urna condimentum. Nunc auctor elit sed arcu lobortis, a tincidunt libero mollis. Etiam hendrerit eu risus eget porttitor. Donec vitae neque vehicula, cursus magna eget, mollis metus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum vel facilisis diam. Sed non tortor ultricies, viverra mauris tempor, cursus justo. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Vestibulum vel facilisis diam. Sed non tortor ultricies, viverra mauris tempor, cursu. ` - ) - .join('') - -const waitUntilStatus = async (status: Status, obs: Observable) => - firstValueFrom(obs.pipe(filter((s) => s === status))) - -const waitUntilOpen = async (status: Status, obs: Observable) => - firstValueFrom(obs.pipe(filter((s) => s === status))) - -const WebRtcTestHelper = { - bootstrap: async (client: ConnectorType) => { - client.signalingServerClient.subjects.wsConnectionPasswordSubject.next( - Buffer.from([ - 101, 11, 188, 67, 254, 113, 165, 152, 53, 19, 118, 227, 195, 21, 110, - 83, 145, 197, 78, 134, 31, 238, 50, 160, 207, 34, 245, 16, 26, 135, 105, - 96, - ]) - ) - client.signalingServerClient.subjects.wsConnectSubject.next(true) - - await waitUntilStatus( - 'connected', - client.signalingServerClient.subjects.wsStatusSubject - ) - }, - - cleanup: async (client: ConnectorType) => { - client.webRtcClient.subjects.rtcConnectSubject.next(false) - await waitUntilOpen( - 'disconnected', - client.webRtcClient.subjects.rtcStatusSubject - ) - client.destroy() - await waitUntilStatus( - 'disconnected', - client.signalingServerClient.subjects.wsStatusSubject - ) - await waitUntilOpen( - 'disconnected', - client.webRtcClient.subjects.rtcStatusSubject - ) - }, -} - -let wallet: ConnectorType -let extension: ConnectorType - -describe('webRTC flow', () => { - beforeEach(async () => { - extension = Connector({ logLevel: 'silent' }) - - wallet = Connector({ - logLevel: 'silent', - signalingServerClient: SignalingServerClient({ - source: 'wallet', - target: 'extension', - }), - }) - - await WebRtcTestHelper.bootstrap(wallet) - await WebRtcTestHelper.bootstrap(extension) - }, 30_000) - - afterEach(async () => { - wallet.destroy() - extension.destroy() - }) - - it('should send message over data channel between two clients', async () => { - wallet.webRtcClient.subjects.rtcConnectSubject.next(true) - extension.webRtcClient.subjects.rtcConnectSubject.next(true) - - wallet.webRtcClient.subjects.rtcCreateOfferSubject.next() - - await waitUntilOpen( - 'connected', - wallet.webRtcClient.subjects.rtcStatusSubject - ) - await waitUntilOpen( - 'connected', - extension.webRtcClient.subjects.rtcStatusSubject - ) - - const walletIncomingMessage = subscribeSpyTo( - wallet.webRtcClient.subjects.rtcIncomingMessageSubject - ) - const extensionIncomingMessage = subscribeSpyTo( - extension.webRtcClient.subjects.rtcIncomingMessageSubject - ) - - const message = oneMB.slice(0, oneMB.length / 1000) - - await waitUntilStatus( - 'disconnected', - wallet.signalingServerClient.subjects.wsStatusSubject - ) - await waitUntilStatus( - 'disconnected', - extension.signalingServerClient.subjects.wsStatusSubject - ) - - wallet.webRtcClient.subjects.rtcAddMessageToQueueSubject.next(message) - extension.webRtcClient.subjects.rtcAddMessageToQueueSubject.next( - 'hello from extension' - ) - - await delayAsync() - - expect(extensionIncomingMessage.getValues()).toEqual([message]) - expect(walletIncomingMessage.getValues()).toEqual(['hello from extension']) - }, 30_000) - - it('should reconnect if a client disconnects', async () => { - log.setLevel('silent') - wallet.webRtcClient.subjects.rtcConnectSubject.next(true) - extension.webRtcClient.subjects.rtcConnectSubject.next(true) - - wallet.webRtcClient.subjects.rtcCreateOfferSubject.next() - await waitUntilOpen( - 'connected', - wallet.webRtcClient.subjects.rtcStatusSubject - ) - await waitUntilOpen( - 'connected', - extension.webRtcClient.subjects.rtcStatusSubject - ) - - await waitUntilStatus( - 'disconnected', - wallet.signalingServerClient.subjects.wsStatusSubject - ) - await waitUntilStatus( - 'disconnected', - extension.signalingServerClient.subjects.wsStatusSubject - ) - wallet.webRtcClient.subjects.rtcIceConnectionStateSubject.next('failed') - - await waitUntilOpen( - 'connected', - wallet.signalingServerClient.subjects.wsStatusSubject - ) - await waitUntilOpen( - 'connected', - extension.signalingServerClient.subjects.wsStatusSubject - ) - - wallet.webRtcClient.subjects.rtcCreateOfferSubject.next() - - await waitUntilOpen( - 'connected', - wallet.webRtcClient.subjects.rtcStatusSubject - ) - await waitUntilOpen( - 'connected', - extension.webRtcClient.subjects.rtcStatusSubject - ) - - await waitUntilStatus( - 'disconnected', - wallet.signalingServerClient.subjects.wsStatusSubject - ) - await waitUntilStatus( - 'disconnected', - extension.signalingServerClient.subjects.wsStatusSubject - ) - - const walletIncomingMessage = subscribeSpyTo( - wallet.webRtcClient.subjects.rtcIncomingMessageSubject - ) - - extension.webRtcClient.subjects.rtcAddMessageToQueueSubject.next( - 'hello from extension' - ) - - await delayAsync() - - expect(walletIncomingMessage.getValues()).toEqual(['hello from extension']) - }, 30_000) -}) diff --git a/src/connector/__tests__/webrtc.test.ts b/src/connector/__tests__/webrtc.test.ts new file mode 100644 index 00000000..9cb2dc59 --- /dev/null +++ b/src/connector/__tests__/webrtc.test.ts @@ -0,0 +1,184 @@ +import { config } from 'config' +import { ConnectorClient } from 'connector/connector-client' +import { + SignalingSubjects, + SignalingSubjectsType, +} from 'connector/signaling/subjects' +import { WebRtcSubjects, WebRtcSubjectsType } from 'connector/webrtc/subjects' +import { Status } from 'connector/_types' +import { filter, firstValueFrom } from 'rxjs' +import { delayAsync } from 'test-utils/delay-async' +import { Logger } from 'tslog' + +describe('connector client', () => { + let extensionLogger = new Logger({ name: 'extensionConnector', minLevel: 2 }) + let walletLogger = new Logger({ name: 'walletConnector', minLevel: 2 }) + let extensionConnector: ConnectorClient + let walletConnector: ConnectorClient + let extensionWebRtcSubjects: WebRtcSubjectsType + let walletWebRtcSubjects: WebRtcSubjectsType + let extensionSignalingSubjects: SignalingSubjectsType + const password = Buffer.from( + '9e47e1afc8a02b626cb4db4c4c7d92a0f3a58949eded93f87b0a966bf9075b3f', + 'hex' + ) + + const waitForDataChannelStatus = ( + subjects: WebRtcSubjectsType, + value: 'open' | 'closed' + ) => + firstValueFrom( + subjects.dataChannelStatusSubject.pipe( + filter((status) => status === value) + ) + ) + + const waitForSignalingServerStatus = ( + subjects: SignalingSubjectsType, + value: Status + ) => + firstValueFrom( + subjects.statusSubject.pipe(filter((status) => status === value)) + ) + + const createExtensionConnector = () => { + extensionConnector = ConnectorClient({ + source: 'extension', + target: 'wallet', + signalingServerBaseUrl: config.signalingServer.baseUrl, + logger: extensionLogger, + isInitiator: false, + createSignalingSubjects: () => extensionSignalingSubjects, + createWebRtcSubjects: () => extensionWebRtcSubjects, + }) + } + + const createWalletConnector = () => { + walletConnector = ConnectorClient({ + source: 'wallet', + target: 'extension', + signalingServerBaseUrl: config.signalingServer.baseUrl, + // logger: walletLogger, + isInitiator: true, + createWebRtcSubjects: () => walletWebRtcSubjects, + }) + } + + beforeEach(() => { + extensionLogger.settings.minLevel = 2 + walletLogger.settings.minLevel = 2 + + extensionWebRtcSubjects = WebRtcSubjects() + extensionSignalingSubjects = SignalingSubjects() + walletWebRtcSubjects = WebRtcSubjects() + }) + + afterEach(async () => { + walletLogger.settings.minLevel = 3 + extensionLogger.settings.minLevel = 3 + extensionConnector?.destroy() + walletConnector?.destroy() + // wait for cleanup to finish + await delayAsync(100) + }) + + it('should open data channel between two peers', async () => { + createExtensionConnector() + createWalletConnector() + extensionConnector.setConnectionPassword(password) + extensionConnector.connect() + + walletConnector.setConnectionPassword(password) + walletConnector.connect() + + expect( + await waitForDataChannelStatus(extensionWebRtcSubjects, 'open') + ).toBe('open') + }) + + it('should reconnect to SS if connection password is changed', async () => { + createExtensionConnector() + + extensionConnector.setConnectionPassword(password) + extensionConnector.connect() + + await waitForSignalingServerStatus(extensionSignalingSubjects, 'connected') + + await extensionConnector.generateConnectionPassword() + + await waitForSignalingServerStatus( + extensionSignalingSubjects, + 'disconnected' + ) + + expect( + await waitForSignalingServerStatus( + extensionSignalingSubjects, + 'connected' + ) + ).toBe('connected') + }) + + it('should wait for connection password before connecting', async () => { + createExtensionConnector() + extensionConnector.connect() + + expect( + await waitForSignalingServerStatus( + extensionSignalingSubjects, + 'disconnected' + ) + ).toBe('disconnected') + + extensionConnector.setConnectionPassword(password) + + expect( + await waitForSignalingServerStatus( + extensionSignalingSubjects, + 'connected' + ) + ).toBe('connected') + }) + + it('should queue a message and send it after data channel has opened', async () => { + createExtensionConnector() + createWalletConnector() + + extensionConnector.sendMessage({ foo: 'bar' }) + + extensionConnector.setConnectionPassword(password) + extensionConnector.connect() + + walletConnector.setConnectionPassword(password) + walletConnector.connect() + + await waitForDataChannelStatus(extensionWebRtcSubjects, 'open') + + expect(await firstValueFrom(walletConnector.onMessage$)).toEqual({ + foo: 'bar', + }) + }) + + it('should open data channel and send message', async () => { + createExtensionConnector() + createWalletConnector() + + extensionConnector.setConnectionPassword(password) + extensionConnector.connect() + + walletConnector.setConnectionPassword(password) + walletConnector.connect() + + await waitForDataChannelStatus(extensionWebRtcSubjects, 'open') + + extensionConnector.sendMessage({ foo: 'bar' }) + + await firstValueFrom( + extensionWebRtcSubjects.onDataChannelMessageSubject.pipe( + filter( + (message) => message.packageType === 'receiveMessageConfirmation' + ) + ) + ) + }) +}) diff --git a/src/connector/_types.ts b/src/connector/_types.ts index 6d5d7982..1c158342 100644 --- a/src/connector/_types.ts +++ b/src/connector/_types.ts @@ -1,26 +1,43 @@ -import { Logger } from 'loglevel' -import { SignalingServerClientType } from './signaling/signaling-server-client' -import { StorageClientType } from './storage/storage-client' -import { ConnectorSubjectsType } from './subjects' -import { WebRtcClientType } from './webrtc/webrtc-client' +import { IceCandidate, MessageSources } from 'io-types/types' +import { SignalingClientType } from './signaling/signaling-client' +import { WebRtcClient } from './webrtc/webrtc-client' -export type Status = - | 'connecting' - | 'connected' - | 'disconnected' - | 'disconnecting' +export const remoteClientState = { + remoteClientIsAlreadyConnected: 'remoteClientIsAlreadyConnected', + remoteClientDisconnected: 'remoteClientDisconnected', + remoteClientJustConnected: 'remoteClientJustConnected', +} as const + +export const remoteClientConnected = new Set([ + remoteClientState.remoteClientIsAlreadyConnected, + remoteClientState.remoteClientJustConnected, +]) + +export const remoteClientDisconnected = new Set([ + remoteClientState.remoteClientDisconnected, +]) + +export type Dependencies = { + secrets: Secrets + signalingClient: SignalingClientType + webRtcClient: WebRtcClient + source: MessageSources +} + +export type IceCandidateMessage = Pick< + IceCandidate, + 'method' | 'payload' | 'source' +> + +export type Message = Record export type Secrets = { encryptionKey: Buffer connectionId: Buffer } -export type PairingState = 'paired' | 'notPaired' | 'loading' - -export type ConnectorSubscriptionsInput = { - webRtcClient: WebRtcClientType - storageClient: StorageClientType - signalingServerClient: SignalingServerClientType - connectorSubjects: ConnectorSubjectsType - logger: Logger -} +export type Status = + | 'connecting' + | 'connected' + | 'disconnected' + | 'disconnecting' diff --git a/src/connector/connector-client.ts b/src/connector/connector-client.ts new file mode 100644 index 00000000..346b8b38 --- /dev/null +++ b/src/connector/connector-client.ts @@ -0,0 +1,145 @@ +import { config } from 'config' +import { SecretsClient } from 'connector/secrets-client' +import { SignalingClient } from 'connector/signaling/signaling-client' +import { Message } from 'connector/_types' +import { + BehaviorSubject, + filter, + finalize, + first, + firstValueFrom, + iif, + map, + merge, + Subject, + Subscription, + switchMap, + tap, + withLatestFrom, +} from 'rxjs' +import { WebRtcClient } from './webrtc/webrtc-client' +import { MessageSources } from 'io-types/types' +import { Logger } from 'tslog' +import { WebRtcSubjects, WebRtcSubjectsType } from './webrtc/subjects' +import { SignalingSubjects, SignalingSubjectsType } from './signaling/subjects' +import { MessageClient } from './messages/message-client' +import { waitForDataChannelStatus } from './webrtc/helpers/wait-for-data-channel-status' +import { sendMessage } from './messages/helpers/send-message' +import { ResultAsync } from 'neverthrow' +import { errorIdentity } from 'utils/error-identity' + +export type ConnectorClient = ReturnType + +export const ConnectorClient = (input: { + target: MessageSources + source: MessageSources + signalingServerBaseUrl: string + logger?: Logger + isInitiator: boolean + createWebRtcSubjects?: () => WebRtcSubjectsType + createSignalingSubjects?: () => SignalingSubjectsType +}) => { + const logger = input.logger + logger?.debug(`πŸ”Œβœ¨ connector client initiated`) + + const shouldConnectSubject = new BehaviorSubject(false) + const connected = new BehaviorSubject(false) + const triggerRestartSubject = new Subject() + const onMessageSubject = new Subject() + + const createWebRtcSubjects = + input.createWebRtcSubjects || (() => WebRtcSubjects()) + + const createSignalingSubjects = + input.createSignalingSubjects || (() => SignalingSubjects()) + + const messageClient = MessageClient({ logger }) + const secretsClient = SecretsClient({ logger }) + + const triggerRestart$ = triggerRestartSubject.pipe( + withLatestFrom(shouldConnectSubject), + map(([, shouldConnect]) => shouldConnect), + tap(() => logger?.debug(`πŸ”ŒπŸ”„ restarting connector client`)) + ) + const triggerConnection$ = merge(shouldConnectSubject, triggerRestart$) + + const subscriptions = new Subscription() + + const connection$ = secretsClient.secrets$.pipe( + switchMap((secrets) => { + const signalingClient = SignalingClient({ + baseUrl: input.signalingServerBaseUrl, + target: input.target, + source: input.source, + logger, + subjects: createSignalingSubjects(), + secrets, + }) + + const webRtcClient = WebRtcClient({ + ...config.webRTC, + logger, + shouldCreateOffer: input.isInitiator, + subjects: createWebRtcSubjects(), + onMessageSubject, + signalingClient, + source: input.source, + messageClient, + restart: () => triggerRestartSubject.next(), + }) + + const sendMessages$ = waitForDataChannelStatus(webRtcClient, 'open').pipe( + first(), + switchMap(() => sendMessage({ messageClient, webRtcClient })) + ) + + const observables$ = merge( + sendMessages$, + webRtcClient.dataChannelClient.subjects.dataChannelStatusSubject.pipe( + tap((status) => connected.next(status === 'open')) + ) + ) + + const destroy = () => { + signalingClient.destroy() + webRtcClient.destroy() + } + + return observables$.pipe(finalize(() => destroy())) + }) + ) + + subscriptions.add( + triggerConnection$ + .pipe( + switchMap((shouldConnect) => + iif(() => !!shouldConnect, connection$, []) + ) + ) + .subscribe() + ) + + return { + connected$: connected.asObservable(), + connected: () => + ResultAsync.fromPromise( + firstValueFrom(connected.pipe(filter((value) => value))), + errorIdentity + ), + setConnectionPassword: (password: Buffer) => + secretsClient.deriveSecretsFromPassword(password), + connectionPassword$: secretsClient.secrets$.pipe( + map((secrets) => secrets.encryptionKey) + ), + generateConnectionPassword: () => secretsClient.generateConnectionSecrets(), + connect: () => shouldConnectSubject.next(true), + disconnect: () => shouldConnectSubject.next(false), + sendMessage: (message: Record) => + messageClient.addToQueue(message), + onMessage$: onMessageSubject.asObservable(), + destroy: () => { + logger?.debug('πŸ”ŒπŸ§Ή destroying connector client') + subscriptions.unsubscribe() + }, + } +} diff --git a/src/connector/connector.ts b/src/connector/connector.ts deleted file mode 100644 index 979927b1..00000000 --- a/src/connector/connector.ts +++ /dev/null @@ -1,114 +0,0 @@ -import log, { LogLevelDesc } from 'loglevel' -import { - SignalingServerClient, - SignalingServerClientType, -} from 'connector/signaling/signaling-server-client' -import { StorageClient, StorageClientType } from './storage/storage-client' -import { ConnectorSubscriptions } from './subscriptions' -import { WebRtcClient, WebRtcClientType } from 'connector/webrtc/webrtc-client' -import { map } from 'rxjs/operators' -import { parseJSON } from 'utils' -import { ConnectorSubjects, ConnectorSubjectsType } from './subjects' -import { Buffer } from 'buffer' -import { config } from 'config' - -export type ConnectorType = ReturnType - -export type ConnectorInput = { - id?: string - logger?: log.Logger - connectorSubjects?: ConnectorSubjectsType - signalingServerClient?: SignalingServerClientType - webRtcClient?: WebRtcClientType - storageClient?: StorageClientType - logLevel?: LogLevelDesc - generateConnectionPassword?: boolean -} - -export const Connector = ({ - id = crypto.randomUUID(), - logLevel = config.logLevel, - logger = log, - connectorSubjects = ConnectorSubjects(), - signalingServerClient = SignalingServerClient({ - logger: log.getLogger(`${id}-signalingServerClient`), - }), - webRtcClient = WebRtcClient({ logger: log.getLogger(`${id}-webRtcClient`) }), - storageClient = StorageClient({ - logger: log.getLogger(`${id}-storageClient`), - }), - generateConnectionPassword = true, -}: ConnectorInput) => { - logger.setLevel(logLevel) - logger.debug( - `πŸƒβ€β™‚οΈ connector extension running in: '${process.env.NODE_ENV}' mode` - ) - - const connectorSubscriptions = ConnectorSubscriptions({ - webRtcClient, - storageClient, - signalingServerClient, - connectorSubjects, - logger, - }) - - const connect = (value: boolean) => { - signalingServerClient.connect(value) - webRtcClient.connect(value) - } - - const init = () => { - storageClient.getConnectionPassword().map((connectionPassword) => { - if (connectionPassword) { - logger.debug(`πŸ” setting connectionPassword`) - signalingServerClient.subjects.wsConnectionPasswordSubject.next( - Buffer.from(connectionPassword, 'hex') - ) - signalingServerClient.subjects.wsAutoConnect.next(true) - connectorSubjects.pairingStateSubject.next('paired') - } else { - if (generateConnectionPassword) { - signalingServerClient.subjects.wsGenerateConnectionSecretsSubject.next() - } - connectorSubjects.pairingStateSubject.next('notPaired') - } - }) - } - - init() - - const destroy = () => { - webRtcClient.destroy() - signalingServerClient.destroy() - storageClient.destroy() - connectorSubscriptions.unsubscribe() - } - - return { - webRtcClient, - signalingServerClient, - storageClient, - destroy, - logger, - sendMessage: (message: Record) => { - webRtcClient.subjects.rtcAddMessageToQueueSubject.next(message) - }, - generateConnectionPassword: () => - signalingServerClient.subjects.wsRegenerateConnectionPassword.next(), - setConnectionPassword: (password: string) => { - signalingServerClient.subjects.wsConnectionPasswordSubject.next( - Buffer.from(password, 'hex') - ) - }, - connect: () => connect(true), - disconnect: () => connect(false), - getConnectionPassword: storageClient.getConnectionPassword, - message$: webRtcClient.subjects.rtcIncomingMessageSubject - .asObservable() - .pipe(map(parseJSON)), - connectionSecrets$: - signalingServerClient.subjects.wsConnectionSecretsSubject.asObservable(), - connectionStatus$: webRtcClient.subjects.rtcStatusSubject.asObservable(), - pairingState$: connectorSubjects.pairingStateSubject.asObservable(), - } -} diff --git a/src/connector/webrtc/data-chunking.ts b/src/connector/helpers/data-chunking.ts similarity index 92% rename from src/connector/webrtc/data-chunking.ts rename to src/connector/helpers/data-chunking.ts index 4803d6a4..14d4cc74 100644 --- a/src/connector/webrtc/data-chunking.ts +++ b/src/connector/helpers/data-chunking.ts @@ -1,11 +1,10 @@ import { config } from 'config' import { sha256 } from 'crypto/sha256' -import { Logger } from 'loglevel' import { err, ok, Result } from 'neverthrow' import { bufferToChunks } from 'utils' import { Buffer } from 'buffer' -type MetaData = { +export type MetaData = { packageType: 'metaData' chunkCount: number hashOfMessage: string @@ -13,7 +12,7 @@ type MetaData = { messageByteCount: number } -type MessageChunk = { +export type MessageChunk = { packageType: 'chunk' chunkIndex: number chunkData: string @@ -72,7 +71,7 @@ export const messageToChunked = ( ) } -export const Chunked = (metaData: MetaData, logger: Logger) => { +export const Chunked = (metaData: MetaData) => { const chunks: MessageChunk[] = [] const addChunk = (chunk: MessageChunk) => { @@ -83,7 +82,6 @@ export const Chunked = (metaData: MetaData, logger: Logger) => { return err(Error('expected chunks received')) chunks.push(chunk) - logger.debug(`🍞 incoming chunk: ${chunks.length}/${metaData.chunkCount}`) return ok(undefined) } @@ -97,7 +95,6 @@ export const Chunked = (metaData: MetaData, logger: Logger) => { .join('') ) } catch (error) { - logger.error(error) return err(Error('failed to decode chunked messages')) } } diff --git a/src/connector/helpers/decrypt-message-payload.ts b/src/connector/helpers/decrypt-message-payload.ts new file mode 100644 index 00000000..db6ff4e7 --- /dev/null +++ b/src/connector/helpers/decrypt-message-payload.ts @@ -0,0 +1,15 @@ +import { decrypt } from 'crypto/encryption' +import { transformBufferToSealbox } from 'crypto/sealbox' +import { DataTypes } from 'io-types/types' +import { ResultAsync } from 'neverthrow' +import { parseJSON } from 'utils' + +export const decryptMessagePayload = ( + message: DataTypes, + encryptionKey: Buffer +): ResultAsync => + transformBufferToSealbox(Buffer.from(message.encryptedPayload, 'hex')) + .asyncAndThen(({ ciphertextAndAuthTag, iv }) => + decrypt(ciphertextAndAuthTag, encryptionKey, iv) + ) + .andThen((decrypted) => parseJSON(decrypted.toString('utf8'))) diff --git a/src/connector/helpers/derive-secrets-from-connection-password.ts b/src/connector/helpers/derive-secrets-from-connection-password.ts new file mode 100644 index 00000000..dd23c346 --- /dev/null +++ b/src/connector/helpers/derive-secrets-from-connection-password.ts @@ -0,0 +1,7 @@ +import { sha256 } from 'crypto/sha256' + +export const deriveSecretsFromPassword = (password: Buffer) => + sha256(password).map((connectionId) => ({ + connectionId, + encryptionKey: password, + })) diff --git a/src/connector/helpers/generate-connection-password.ts b/src/connector/helpers/generate-connection-password.ts new file mode 100644 index 00000000..57d82977 --- /dev/null +++ b/src/connector/helpers/generate-connection-password.ts @@ -0,0 +1,6 @@ +import { config } from 'config' +import { secureRandom } from 'crypto/secure-random' + +export const generateConnectionPassword = ( + byteLength = config.secrets.connectionPasswordByteLength +) => secureRandom(byteLength) diff --git a/src/connector/helpers/index.ts b/src/connector/helpers/index.ts new file mode 100644 index 00000000..b2c2cd08 --- /dev/null +++ b/src/connector/helpers/index.ts @@ -0,0 +1,6 @@ +export * from './data-chunking' +export * from './decrypt-message-payload' +export * from './derive-secrets-from-connection-password' +export * from './generate-connection-password' +export * from './parse-raw-message' +export * from './prepare-message' diff --git a/src/connector/helpers/parse-raw-message.ts b/src/connector/helpers/parse-raw-message.ts new file mode 100644 index 00000000..572a80b8 --- /dev/null +++ b/src/connector/helpers/parse-raw-message.ts @@ -0,0 +1,5 @@ +import { SignalingServerResponse } from 'io-types/types' +import { parseJSON } from 'utils' + +export const parseRawMessage = (data: string) => + parseJSON(data) diff --git a/src/connector/helpers/prepare-message.ts b/src/connector/helpers/prepare-message.ts new file mode 100644 index 00000000..66ffd89e --- /dev/null +++ b/src/connector/helpers/prepare-message.ts @@ -0,0 +1,20 @@ +import { Secrets } from 'connector/_types' +import { createIV, encrypt } from 'crypto/encryption' +import { DataTypes } from 'io-types/types' +import { ResultAsync } from 'neverthrow' + +export const prepareMessage = ( + { payload, method, source }: Pick, + { encryptionKey, connectionId }: Secrets +): ResultAsync, Error> => + createIV() + .asyncAndThen((iv) => + encrypt(Buffer.from(JSON.stringify(payload)), encryptionKey, iv) + ) + .map((encrypted) => ({ + requestId: crypto.randomUUID(), + connectionId: connectionId.toString('hex'), + encryptedPayload: encrypted.combined.toString('hex'), + method, + source, + })) diff --git a/src/connector/index.ts b/src/connector/index.ts deleted file mode 100644 index 5bd84569..00000000 --- a/src/connector/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './connector' -export * from './_types' diff --git a/src/connector/messages/helpers/send-message.ts b/src/connector/messages/helpers/send-message.ts new file mode 100644 index 00000000..91acd1df --- /dev/null +++ b/src/connector/messages/helpers/send-message.ts @@ -0,0 +1,48 @@ +import { config } from 'config' +import { sendMessageOverDataChannelAndWaitForConfirmation } from 'connector/webrtc/helpers/send-message-over-data-channel-and-wait-for-confirmation' +import { WebRtcClient } from 'connector/webrtc/webrtc-client' +import { + concatMap, + filter, + first, + merge, + mergeMap, + of, + Subject, + tap, + timer, +} from 'rxjs' +import { MessageClientType } from '../message-client' + +export const sendMessage = (input: { + messageClient: MessageClientType + webRtcClient: WebRtcClient +}) => + input.messageClient.messageQueue$.pipe( + concatMap((message) => { + const retryTrigger = new Subject() + return merge(of(0), retryTrigger).pipe( + mergeMap((retryCount) => + merge( + timer(config.webRTC.confirmationTimeout).pipe( + tap(() => { + retryTrigger.next(retryCount + 1) + }), + filter(() => false) + ), + sendMessageOverDataChannelAndWaitForConfirmation( + input.webRtcClient.subjects, + message + ).pipe( + tap((result) => { + if (result.isErr()) retryTrigger.next(retryCount + 1) + else input.messageClient.nextMessage() + }), + filter((result) => !result.isErr()) + ) + ) + ), + first() + ) + }) + ) diff --git a/src/connector/messages/message-client.ts b/src/connector/messages/message-client.ts new file mode 100644 index 00000000..8b874526 --- /dev/null +++ b/src/connector/messages/message-client.ts @@ -0,0 +1,32 @@ +import { Message } from 'connector/_types' +import { filter, map, merge, of, Subject, tap } from 'rxjs' +import { Logger } from 'tslog' + +export type MessageClientType = ReturnType +export const MessageClient = (input: { logger?: Logger }) => { + const logger = input.logger + + const triggerSubject = new Subject() + + const messageQueue: Message[] = [] + + const addToQueue = (message: Message) => { + logger?.debug(`πŸ’¬βΈ message added to queue`, message) + messageQueue.push(message) + triggerSubject.next() + } + + const messageQueue$ = merge(of(true), triggerSubject).pipe( + map(() => messageQueue.shift()), + filter((message): message is Message => !!message), + tap((message) => { + logger?.debug(`πŸ’¬β­ processing message`, message) + }) + ) + + return { + addToQueue, + nextMessage: () => triggerSubject.next(), + messageQueue$, + } +} diff --git a/src/connector/observables/connection.ts b/src/connector/observables/connection.ts deleted file mode 100644 index 9a732b98..00000000 --- a/src/connector/observables/connection.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { combineLatest, filter, tap } from 'rxjs' -import { ConnectorSubscriptionsInput } from 'connector/_types' - -export const connection = (input: ConnectorSubscriptionsInput) => - combineLatest([ - input.webRtcClient.subjects.rtcStatusSubject, - input.signalingServerClient.subjects.wsConnectSubject, - ]).pipe( - tap(([webRtcStatus, shouldSignalingServerConnect]) => { - if (webRtcStatus === 'connected' && shouldSignalingServerConnect) { - input.signalingServerClient.subjects.wsConnectSubject.next(false) - } - }) - ) - -export const killSignalingServerConnection = ( - input: ConnectorSubscriptionsInput -) => - input.webRtcClient.subjects.rtcStatusSubject.pipe( - filter((status) => status === 'connected'), - tap(() => input.signalingServerClient.disconnect()) - ) diff --git a/src/connector/observables/pairing-state.ts b/src/connector/observables/pairing-state.ts deleted file mode 100644 index 8eecc579..00000000 --- a/src/connector/observables/pairing-state.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ConnectorSubscriptionsInput } from 'connector/_types' -import { tap } from 'rxjs' - -export const pairingState = (input: ConnectorSubscriptionsInput) => - input.storageClient.subjects.onPasswordChange.pipe( - tap(() => - input.storageClient - .getConnectionPassword() - .map((connectionPassword) => - input.connectorSubjects.pairingStateSubject.next( - connectionPassword ? 'paired' : 'notPaired' - ) - ) - ) - ) diff --git a/src/connector/observables/regenerate-connection-password.ts b/src/connector/observables/regenerate-connection-password.ts deleted file mode 100644 index 988ef563..00000000 --- a/src/connector/observables/regenerate-connection-password.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ConnectorSubscriptionsInput } from 'connector/_types' -import { delay, tap } from 'rxjs' - -export const regenerateConnectionPassword = ( - input: ConnectorSubscriptionsInput -) => - input.signalingServerClient.subjects.wsRegenerateConnectionPassword.pipe( - tap(() => { - input.storageClient.subjects.removeConnectionPasswordSubject.next() - input.webRtcClient.subjects.rtcRestartSubject.next() - }), - delay(0), - tap(() => - input.signalingServerClient.subjects.wsGenerateConnectionSecretsSubject.next() - ) - ) diff --git a/src/connector/observables/rtc-restart.ts b/src/connector/observables/rtc-restart.ts deleted file mode 100644 index 8f36486d..00000000 --- a/src/connector/observables/rtc-restart.ts +++ /dev/null @@ -1,34 +0,0 @@ -import log from 'loglevel' -import { withLatestFrom, tap, merge, filter } from 'rxjs' -import { ConnectorSubscriptionsInput } from 'connector/_types' - -export const rtcRestart = (input: ConnectorSubscriptionsInput) => { - const signalingSubjects = input.signalingServerClient.subjects - const webRtcSubjects = input.webRtcClient.subjects - - const forceRestart$ = webRtcSubjects.rtcRestartSubject - - const restartActiveConnectionWhenConnectionPasswordChanged$ = - input.storageClient.subjects.onPasswordChange.pipe( - withLatestFrom(webRtcSubjects.rtcStatusSubject), - filter(([_, status]) => status !== 'disconnected') - ) - - return merge( - forceRestart$, - restartActiveConnectionWhenConnectionPasswordChanged$, - input.webRtcClient.subjects.rtcConnectSubject - ).pipe( - withLatestFrom( - signalingSubjects.wsSourceSubject, - input.webRtcClient.subjects.rtcConnectSubject - ), - tap(([, source, shouldConnect]) => { - if (shouldConnect) { - log.debug(`πŸ•ΈπŸ”„ [${source}] restarting webRTC...`) - input.webRtcClient.createPeerConnection() - signalingSubjects.wsConnectSubject.next(true) - } - }) - ) -} diff --git a/src/connector/observables/store-connection-password.ts b/src/connector/observables/store-connection-password.ts deleted file mode 100644 index 360d2bb3..00000000 --- a/src/connector/observables/store-connection-password.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { filter, withLatestFrom, tap } from 'rxjs' -import { ConnectorSubscriptionsInput } from 'connector/_types' - -export const storeConnectionPassword = (input: ConnectorSubscriptionsInput) => - input.webRtcClient.subjects.rtcStatusSubject.pipe( - filter((status) => status === 'connected'), - withLatestFrom( - input.signalingServerClient.subjects.wsConnectionSecretsSubject - ), - tap(([, secretsResult]) => - secretsResult?.map((secrets) => - input.storageClient.subjects.addConnectionPasswordSubject.next( - secrets.encryptionKey - ) - ) - ) - ) diff --git a/src/connector/observables/ws-connect.ts b/src/connector/observables/ws-connect.ts deleted file mode 100644 index ab63fbcd..00000000 --- a/src/connector/observables/ws-connect.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { filter, withLatestFrom, tap } from 'rxjs' -import { ConnectorSubscriptionsInput } from 'connector/_types' - -export const wsConnect = (input: ConnectorSubscriptionsInput) => { - const signalingSubjects = input.signalingServerClient.subjects - const webRtcSubjects = input.webRtcClient.subjects - - return webRtcSubjects.rtcAddMessageToQueueSubject.pipe( - withLatestFrom( - webRtcSubjects.rtcStatusSubject, - signalingSubjects.wsStatusSubject - ), - filter( - ([, rtcStatus, wsStatus]) => - rtcStatus !== 'connected' && wsStatus !== 'connected' - ), - tap(() => { - signalingSubjects.wsConnectSubject.next(true) - }) - ) -} diff --git a/src/connector/observables/ws-connection-password-change.ts b/src/connector/observables/ws-connection-password-change.ts deleted file mode 100644 index c30edefd..00000000 --- a/src/connector/observables/ws-connection-password-change.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ConnectorSubscriptionsInput } from 'connector/_types' -import { tap } from 'rxjs' - -export const wsConnectionPasswordChange = ( - input: ConnectorSubscriptionsInput -) => - input.storageClient.subjects.onPasswordChange.pipe( - tap((buffer) => - input.signalingServerClient.subjects.wsConnectionPasswordSubject.next( - buffer - ) - ) - ) diff --git a/src/connector/observables/ws-incoming-message.ts b/src/connector/observables/ws-incoming-message.ts deleted file mode 100644 index adbf4080..00000000 --- a/src/connector/observables/ws-incoming-message.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { ConnectorSubscriptionsInput, Secrets } from '../_types' -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { decrypt } from 'crypto/encryption' -import { transformBufferToSealbox } from 'crypto/sealbox' -import { - DataTypes, - InvalidMessageError, - SignalingServerErrorResponse, - SignalingServerResponse, -} from 'io-types/types' -import { validateIncomingMessage } from 'io-types/validate' -import { Logger } from 'loglevel' -import { err, ok, okAsync, Result, ResultAsync } from 'neverthrow' -import { filter, map, concatMap, withLatestFrom, share } from 'rxjs' -import { parseJSON } from 'utils' -import { Buffer } from 'buffer' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -const distributeMessage = - (subjects: WebRtcSubjectsType, logger: Logger) => - (message: DataTypes): Result => { - switch (message.method) { - case 'answer': { - logger.debug(`πŸ“‘β¬‡οΈπŸ€› received answer`) - subjects.rtcRemoteAnswerSubject.next({ - ...message.payload, - type: 'answer', - }) - - return ok(undefined) - } - - case 'offer': - logger.debug(`πŸ“‘β¬‡οΈπŸ€œ received offer`) - subjects.rtcRemoteOfferSubject.next({ - ...message.payload, - type: 'offer', - }) - return ok(undefined) - - case 'iceCandidate': - logger.debug(`πŸ“‘β¬‡οΈπŸ₯Ά received iceCandidate`) - subjects.rtcRemoteIceCandidateSubject.next( - new RTCIceCandidate(message.payload) - ) - return ok(undefined) - - case 'iceCandidates': - logger.debug(`πŸ“‘β¬‡οΈπŸ₯Ά received iceCandidates`) - message.payload.forEach((item) => - subjects.rtcRemoteIceCandidateSubject.next(new RTCIceCandidate(item)) - ) - - return ok(undefined) - - default: - logger.error( - `πŸ“‘β¬‡οΈβŒ received unsupported method\n ${JSON.stringify(message)}` - ) - return err(Error('invalid message method')) - } - } - -const decryptMessagePayload = ( - message: DataTypes, - encryptionKey: Buffer, - logger: Logger -): ResultAsync => - transformBufferToSealbox(Buffer.from(message.encryptedPayload, 'hex')) - .asyncAndThen(({ ciphertextAndAuthTag, iv }) => - decrypt(ciphertextAndAuthTag, encryptionKey, iv).mapErr((error) => { - logger.debug(`πŸ“‘πŸ§©βŒ failed to decrypt payload`) - return error - }) - ) - .andThen((decrypted) => - parseJSON(decrypted.toString('utf8')).mapErr( - (error) => { - logger.debug( - `πŸ“‘πŸβŒ failed to parse decrypted payload: \n ${decrypted}` - ) - return error - } - ) - ) - .map((payload: DataTypes['payload']) => { - logger.debug( - `πŸ“‘πŸ’¬βœ… successfully decrypted payload\n${JSON.stringify(payload)}` - ) - logger.trace(payload) - return { ...message, payload } as unknown as DataTypes - }) - -const handleIncomingMessage = - (signalingSubjects: SignalingSubjectsType) => - ( - result: Result - ): Result => - result.andThen((message) => { - switch (message.info) { - case 'remoteData': { - return ok(message.data) - } - - case 'invalidMessageError': - case 'missingRemoteClientError': - case 'validationError': { - signalingSubjects.wsServerErrorResponseSubject.next(message) - return err(message) - } - - case 'confirmation': { - signalingSubjects.wsIncomingMessageConfirmationSubject.next(message) - break - } - } - - return ok(undefined) - }) - -export const wsIncomingMessage = (input: ConnectorSubscriptionsInput) => { - const signalingSubjects = input.signalingServerClient.subjects - const webRtcSubjects = input.webRtcClient.subjects - const logger = input.logger - - return signalingSubjects.wsIncomingRawMessageSubject.pipe( - map((messageEvent) => messageEvent.data), - map((rawMessage) => - parseJSON(rawMessage).mapErr( - (error): InvalidMessageError => { - logger.error(`πŸ“‘βŒ could not parse message: \n '${rawMessage}' `) - return { - info: 'invalidMessageError', - data: rawMessage, - error: error.message, - } - } - ) - ), - map((result) => - handleIncomingMessage(signalingSubjects)(result).andThen((message) => - message ? validateIncomingMessage(message) : ok(undefined) - ) - ), - withLatestFrom( - signalingSubjects.wsConnectionSecretsSubject.pipe( - filter((result): result is Result => !!result) - ) - ), - concatMap(([messageResult, secretsResult]) => - messageResult - .asyncAndThen((message) => - message - ? secretsResult - .asyncAndThen((secrets) => - decryptMessagePayload(message, secrets.encryptionKey, logger) - ) - .andThen(distributeMessage(webRtcSubjects, logger)) - : okAsync(undefined) - ) - // TODO: handle error - .mapErr((error) => { - logger.error(error) - }) - ), - share() - ) -} diff --git a/src/connector/observables/ws-send-message.ts b/src/connector/observables/ws-send-message.ts deleted file mode 100644 index cf3ee8f1..00000000 --- a/src/connector/observables/ws-send-message.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { ConnectorSubscriptionsInput, Secrets } from '../_types' -import { IceCandidate, IceCandidates, DataTypes } from 'io-types/types' -import { Logger } from 'loglevel' -import { Result, err, ok, ResultAsync } from 'neverthrow' -import { - merge, - filter, - map, - withLatestFrom, - tap, - from, - mergeMap, - Observable, - of, - timer, - concatMap, - bufferTime, - first, -} from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' -import { createIV, encrypt } from 'crypto/encryption' -import { Buffer } from 'buffer' -import { config } from 'config' - -const wsCreateMessage = ( - { payload, method, source }: Pick, - { encryptionKey, connectionId }: Secrets -): ResultAsync, Error> => - createIV() - .asyncAndThen((iv) => - encrypt(Buffer.from(JSON.stringify(payload)), encryptionKey, iv) - ) - .map((encrypted) => ({ - requestId: crypto.randomUUID(), - connectionId: connectionId.toString('hex'), - encryptedPayload: encrypted.combined.toString('hex'), - method, - source, - })) - -const wsMessageConfirmation = ( - subjects: SignalingSubjectsType, - messageResult: Result, Error>, - logger: Logger, - timeout = 3000 -): Observable< - Result - // eslint-disable-next-line max-params -> => { - if (messageResult.isErr()) return of(err(messageResult.error)) - - const message = messageResult.value - - subjects.wsOutgoingMessageSubject.next(JSON.stringify(message)) - - const success$ = subjects.wsIncomingMessageConfirmationSubject.pipe( - tap((message) => - logger.debug(`πŸ“‘β¬‡οΈπŸ‘Œ got message confirmation:\n${message.requestId}`) - ), - filter( - (incomingMessage) => message.requestId === incomingMessage.requestId - ), - map(() => ok(true)) - ) - - const serverError$ = subjects.wsServerErrorResponseSubject.pipe( - map((response) => - err({ requestId: message.requestId, reason: 'serverError', response }) - ) - ) - - const timeout$ = timer(timeout).pipe( - map(() => err({ requestId: message.requestId, reason: 'timeout' })) - ) - - const websocketError$ = subjects.wsErrorSubject.pipe( - map(() => err({ requestId: message.requestId, reason: 'error' })) - ) - - return merge(success$, serverError$, timeout$, websocketError$).pipe(first()) -} - -export const wsSendMessage = (input: ConnectorSubscriptionsInput) => { - const signalingSubjects = input.signalingServerClient.subjects - const webRtcSubjects = input.webRtcClient.subjects - const logger = input.logger - const localOffer$ = webRtcSubjects.rtcLocalOfferSubject.pipe( - filter( - ( - offer - ): offer is { - type: 'offer' - sdp: string - } => !!offer.sdp - ), - map(({ sdp, type }) => ({ method: type, payload: { sdp } })) - ) - - const localAnswer$ = webRtcSubjects.rtcLocalAnswerSubject.pipe( - filter( - ( - answer - ): answer is { - type: 'answer' - sdp: string - } => !!answer.sdp - ), - map(({ sdp, type }) => ({ method: type, payload: { sdp } })) - ) - - const localIceCandidates$ = webRtcSubjects.rtcLocalIceCandidateSubject.pipe( - filter( - (iceCandidate) => - !!iceCandidate.candidate && - !!iceCandidate.sdpMid && - iceCandidate.sdpMLineIndex !== null - ), - bufferTime(config.signalingServer.iceCandidatesBatchTime), - filter((iceCandidates) => iceCandidates.length > 0), - map((iceCandidates) => ({ - method: 'iceCandidates' as IceCandidates['method'], - payload: iceCandidates as IceCandidates['payload'], - })) - ) - - const localIceCandidate$ = webRtcSubjects.rtcLocalIceCandidateSubject.pipe( - filter( - (iceCandidate) => - !!iceCandidate.candidate && - !!iceCandidate.sdpMid && - iceCandidate.sdpMLineIndex !== null - ), - map((iceCandidate) => ({ - method: 'iceCandidate' as IceCandidate['method'], - payload: iceCandidate as IceCandidate['payload'], - })) - ) - - const connectionSecrets$ = signalingSubjects.wsConnectionSecretsSubject.pipe( - filter((secrets): secrets is Result => !!secrets) - ) - - return merge( - localOffer$, - localAnswer$, - // TODO: remove when mobile wallet adds support for batched iceCandidates - config.signalingServer.useBatchedIceCandidates - ? localIceCandidates$ - : localIceCandidate$ - ).pipe( - withLatestFrom(signalingSubjects.wsSourceSubject, connectionSecrets$), - concatMap(([{ payload, method }, source, secretsResult]) => - from( - secretsResult.asyncAndThen((secrets) => - wsCreateMessage({ method, source, payload }, secrets) - ) - ).pipe( - mergeMap((result) => - wsMessageConfirmation(signalingSubjects, result, logger) - ) - ) - ), - tap((result) => { - // TODO: handle error - if (result.isErr()) logger.error(result.error) - }) - ) -} diff --git a/src/connector/secrets-client.ts b/src/connector/secrets-client.ts new file mode 100644 index 00000000..37861abe --- /dev/null +++ b/src/connector/secrets-client.ts @@ -0,0 +1,33 @@ +import { Logger } from 'tslog' +import { ReplaySubject, share, tap } from 'rxjs' +import { deriveSecretsFromPassword } from './helpers/derive-secrets-from-connection-password' +import { generateConnectionPassword } from './helpers/generate-connection-password' +import { Secrets } from './_types' + +export const SecretsClient = (input: { logger?: Logger }) => { + const logger = input.logger + const secretsSubject = new ReplaySubject() + + const generateConnectionSecrets = () => + generateConnectionPassword().asyncAndThen((password) => + deriveSecretsFromPassword(password).map((secrets) => { + logger?.debug(`πŸ”πŸ”„ connection password generated`) + secretsSubject.next(secrets) + return password + }) + ) + + return { + generateConnectionSecrets, + deriveSecretsFromPassword: (password: Buffer) => + deriveSecretsFromPassword(password).map((secrets) => { + secretsSubject.next(secrets) + }), + secrets$: secretsSubject.pipe( + tap(() => { + logger?.debug(`πŸ”πŸ’Ύ connection password set`) + }), + share() + ), + } +} diff --git a/src/connector/signaling/__tests__/secrets.test.ts b/src/connector/signaling/__tests__/secrets.test.ts deleted file mode 100644 index 759b80ce..00000000 --- a/src/connector/signaling/__tests__/secrets.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import log from 'loglevel' -import { generateConnectionPasswordAndDeriveSecrets } from '../secrets' -describe('signaling server secrets', () => { - it('should generate connection password and derive secrets', async () => { - const result = await generateConnectionPasswordAndDeriveSecrets(5, log) - - if (result.isErr()) throw result.error - - const secrets = result.value - - expect(Buffer.isBuffer(secrets.encryptionKey)).toBeTruthy() - expect(Buffer.isBuffer(secrets.connectionId)).toBeTruthy() - }) -}) diff --git a/src/connector/signaling/__tests__/signaling-server-client.test.ts b/src/connector/signaling/__tests__/signaling-server-client.test.ts deleted file mode 100644 index b5b1fed7..00000000 --- a/src/connector/signaling/__tests__/signaling-server-client.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import WSS from 'jest-websocket-mock' -import { WebRtcSubjectsType } from '../../webrtc/subjects' -import { subscribeSpyTo } from '@hirez_io/observer-spy' -import { filter, firstValueFrom } from 'rxjs' -import { err, ok } from 'neverthrow' -import log from 'loglevel' -import { createIV, encrypt } from 'crypto/encryption' -import { delayAsync } from 'test-utils/delay-async' -import { SignalingSubjectsType } from 'connector/signaling/subjects' -import { - SignalingServerClient, - SignalingServerClientType, -} from 'connector/signaling/signaling-server-client' -import { wsMessageConfirmation } from 'connector/signaling/observables/ws-message-confirmation' -import { Connector, ConnectorType, Status } from 'connector' - -const url = - 'ws://localhost:1234/3ba6fa025c3c304988133c081e9e3f5347bf89421f6445b07abfacd94956a09a?target=wallet&source=extension' -let wss: WSS - -let signalingSubjects: SignalingSubjectsType -let webRtcSubjects: WebRtcSubjectsType -let signalingServerClient: SignalingServerClientType - -let wsStatusSpy: ReturnType> -let wsErrorSpy: ReturnType> -let wsIncomingMessageSpy: ReturnType< - typeof subscribeSpyTo> -> -let application: ConnectorType - -const waitUntilStatus = async (status: Status) => - firstValueFrom( - signalingSubjects.wsStatusSubject.pipe(filter((s) => s === status)) - ) - -describe('Signaling server client', () => { - beforeEach(async () => { - application = Connector({ - logLevel: 'silent', - signalingServerClient: SignalingServerClient({ - baseUrl: url, - }), - }) - signalingServerClient = application.signalingServerClient - signalingSubjects = signalingServerClient.subjects - webRtcSubjects = application.webRtcClient.subjects - - signalingSubjects.wsConnectionPasswordSubject.next( - Buffer.from([ - 101, 11, 188, 67, 254, 113, 165, 152, 53, 19, 118, 227, 195, 21, 110, - 83, 145, 197, 78, 134, 31, 238, 50, 160, 207, 34, 245, 16, 26, 135, 105, - 96, - ]) - ) - - WSS.clean() - await delayAsync(10) - wsStatusSpy = subscribeSpyTo(signalingSubjects.wsStatusSubject) - wsErrorSpy = subscribeSpyTo(signalingSubjects.wsErrorSubject) - wsIncomingMessageSpy = subscribeSpyTo( - signalingSubjects.wsIncomingRawMessageSubject - ) - wss = new WSS(url) - signalingSubjects.wsConnectSubject.next(true) - await waitUntilStatus('connected') - }) - - afterEach(() => { - application.destroy() - }) - - it('should successfully connect and emit status', async () => { - expect(wsStatusSpy.getValues()).toEqual([ - 'disconnected', - 'connecting', - 'connected', - ]) - }) - - it('should emit error and status', async () => { - wss.error() - expect(wsErrorSpy.getValues()[0]).toBeTruthy() - expect(wsStatusSpy.getValues()).toEqual([ - 'disconnected', - 'connecting', - 'connected', - 'disconnected', - ]) - }) - - it('should reconnect if disconnected', async () => { - WSS.clean() - wss = new WSS(url) - await waitUntilStatus('connected') - expect(wsStatusSpy.getValues()).toEqual([ - 'disconnected', - 'connecting', - 'connected', - 'disconnected', - 'connecting', - 'connected', - ]) - }) - - it('should send a message to ws server', async () => { - signalingSubjects.wsOutgoingMessageSubject.next('hi from client') - expect(wss).toReceiveMessage('hi from client') - }) - - it('should receive a message from ws server', async () => { - const message = JSON.stringify({ - encryptedPayload: 'secret stuff here', - connectionId: 'abc', - method: 'offer', - source: 'iOS', - requestId: crypto.randomUUID(), - }) - wss.send(message) - - const actual: MessageEvent[] = wsIncomingMessageSpy.getValues() - - expect(actual[0].data).toBe(message) - }) - - describe('message confirmation', () => { - const message = { - encryptedPayload: '123', - connectionId: '1', - method: 'answer', - source: 'extension', - requestId: crypto.randomUUID(), - } - - it('should send a message with ok confirmation', async () => { - let messageConfirmationSpy = subscribeSpyTo( - wsMessageConfirmation(signalingSubjects, log)(ok(message as any), 300) - ) - expect(wss).toReceiveMessage(JSON.stringify(message)) - - wss.send( - JSON.stringify({ info: 'confirmation', requestId: message.requestId }) - ) - - expect(messageConfirmationSpy.getValues()).toEqual([ok(true)]) - }) - - it('should fail message confirmation due to timeout', async () => { - let messageConfirmationSpy = subscribeSpyTo( - wsMessageConfirmation(signalingSubjects, log)(ok(message as any), 300) - ) - signalingSubjects.wsOutgoingMessageSubject.next(JSON.stringify(message)) - - expect(wss).toReceiveMessage(JSON.stringify(message)) - - await delayAsync() - - expect(messageConfirmationSpy.getValues()).toEqual([ - err({ requestId: message.requestId, reason: 'timeout' }), - ]) - }) - - it('should fail message confirmation due to ws error', async () => { - let messageConfirmationSpy = subscribeSpyTo( - wsMessageConfirmation(signalingSubjects, log)(ok(message as any), 300) - ) - wss.error() - - expect(messageConfirmationSpy.getValues()).toEqual([ - err({ requestId: message.requestId, reason: 'error' }), - ]) - }) - }) - - describe('decrypt message payload', () => { - it('should decrypt payload and send to offer subject', async () => { - const wsConnectionSecretsSpy = subscribeSpyTo( - signalingSubjects.wsConnectionSecretsSubject - ) - const rtcRemoteOfferSpy = subscribeSpyTo( - webRtcSubjects.rtcRemoteOfferSubject - ) - - await delayAsync(100) - - const secretsResult = wsConnectionSecretsSpy.getValueAt(0) - - if (!secretsResult) throw Error('missing secrets') - - if (secretsResult.isErr()) throw secretsResult.error - - const secrets = secretsResult.value - - const offerPayload = { - sdp: 'v=0\r\no=- 9071002879172211114 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=ice-ufrag:ANaE\r\na=ice-pwd:2nJbdtiNceHOsCsnJ4qUKTbG\r\na=ice-options:trickle\r\na=fingerprint:sha-256 3F:AF:30:C3:48:90:25:8F:98:0B:3E:E2:CA:4F:D4:A7:07:DE:5F:B4:EC:B1:14:B8:E3:D4:22:43:01:64:0D:63\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n', - } - - // eslint-disable-next-line max-nested-callbacks - const encryptedResult = await createIV().asyncAndThen((iv) => - encrypt( - Buffer.from(JSON.stringify(offerPayload)), - secrets.encryptionKey, - iv - ) - ) - - if (encryptedResult.isErr()) throw encryptedResult.error - - const encrypted = encryptedResult.value - - const requestId = crypto.randomUUID() - - const message = { - info: 'remoteData', - requestId, - data: { - encryptedPayload: encrypted.combined.toString('hex'), - connectionId: secrets.connectionId.toString('hex'), - method: 'offer', - source: 'wallet', - requestId, - }, - } - - wss.send(JSON.stringify(message)) - - await delayAsync() - - expect(rtcRemoteOfferSpy.getValueAt(0)).toEqual({ - ...offerPayload, - type: 'offer', - }) - }) - }) -}) diff --git a/src/connector/signaling/observables/ws-connect.ts b/src/connector/signaling/observables/ws-connect.ts deleted file mode 100644 index 399f8925..00000000 --- a/src/connector/signaling/observables/ws-connect.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { withLatestFrom, tap } from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -export const wsConnect = ( - subjects: SignalingSubjectsType, - connect: (connectionId: string) => void -) => - subjects.wsConnectSubject.pipe( - withLatestFrom( - subjects.wsConnectionSecretsSubject, - subjects.wsStatusSubject - ), - tap(([shouldConnect, secrets, status]) => { - if ( - status === 'disconnected' && - shouldConnect && - secrets && - secrets.isOk() - ) { - connect(secrets.value.connectionId.toString('hex')) - } - }) - ) diff --git a/src/connector/signaling/observables/ws-connection-secrets.ts b/src/connector/signaling/observables/ws-connection-secrets.ts deleted file mode 100644 index 754ac836..00000000 --- a/src/connector/signaling/observables/ws-connection-secrets.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { config } from 'config' -import { deriveSecretsFromConnectionPassword } from '../secrets' -import { secureRandom } from 'crypto/secure-random' -import { errAsync } from 'neverthrow' -import { tap, switchMap, share } from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' -import { Logger } from 'loglevel' - -export const wsGenerateConnectionSecrets = ( - subjects: SignalingSubjectsType, - logger: Logger -) => - subjects.wsGenerateConnectionSecretsSubject.pipe( - tap(() => { - logger.debug(`πŸ“‘πŸ” generating connection secrets`) - secureRandom(config.secrets.connectionPasswordByteLength).map((buffer) => - subjects.wsConnectionPasswordSubject.next(buffer) - ) - }) - ) - -export const wsConnectionPassword = ( - subjects: SignalingSubjectsType, - logger: Logger -) => - subjects.wsConnectionPasswordSubject.pipe( - switchMap((password) => - password - ? deriveSecretsFromConnectionPassword(password, logger) - : errAsync(Error('missing connection password')) - ), - share(), - tap((result) => subjects.wsConnectionSecretsSubject.next(result)) - ) diff --git a/src/connector/signaling/observables/ws-disconnect.ts b/src/connector/signaling/observables/ws-disconnect.ts deleted file mode 100644 index 99e7aaef..00000000 --- a/src/connector/signaling/observables/ws-disconnect.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - combineLatest, - switchMap, - interval, - withLatestFrom, - filter, - take, - tap, -} from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -export const wsDisconnect = ( - subjects: SignalingSubjectsType, - disconnect: () => void -) => - combineLatest([subjects.wsConnectSubject, subjects.wsStatusSubject]).pipe( - switchMap(([shouldConnect, status]) => { - if (['connecting', 'connected'].includes(status) && !shouldConnect) { - return interval(100).pipe( - withLatestFrom(subjects.wsIsSendingMessageSubject), - filter(([, wsIsSendingMessage]) => !wsIsSendingMessage), - take(1), - tap(disconnect) - ) - } else { - return [] - } - }) - ) diff --git a/src/connector/signaling/observables/ws-message-confirmation.ts b/src/connector/signaling/observables/ws-message-confirmation.ts deleted file mode 100644 index 7527a20f..00000000 --- a/src/connector/signaling/observables/ws-message-confirmation.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { DataTypes } from 'io-types/types' -import { Logger } from 'loglevel' -import { err, ok, Result } from 'neverthrow' -import { merge, Observable, of, tap, filter, map, timer, first } from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -export const wsMessageConfirmation = - (subjects: SignalingSubjectsType, logger: Logger) => - ( - messageResult: Result, Error>, - timeout = 3000 - ): Observable< - Result - > => { - if (messageResult.isErr()) return of(err(messageResult.error)) - - const message = messageResult.value - const { requestId } = message - subjects.wsOutgoingMessageSubject.next(JSON.stringify(message)) - return merge( - subjects.wsIncomingMessageConfirmationSubject.pipe( - tap((message) => - logger.debug(`πŸ“‘β¬‡οΈπŸ‘Œ got message confirmation:\n${message.requestId}`) - ), - filter( - (incomingMessage) => message.requestId === incomingMessage.requestId - ), - map(() => ok(true)) - ), - subjects.wsServerErrorResponseSubject.pipe( - map((message) => err({ requestId, reason: 'serverError', message })) - ), - timer(timeout).pipe(map(() => err({ requestId, reason: 'timeout' }))), - subjects.wsErrorSubject.pipe( - map(() => err({ requestId, reason: 'error' })) - ) - ).pipe(first()) - } diff --git a/src/connector/signaling/observables/ws-outgoing-message.ts b/src/connector/signaling/observables/ws-outgoing-message.ts deleted file mode 100644 index fe5e56dd..00000000 --- a/src/connector/signaling/observables/ws-outgoing-message.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - tap, - concatMap, - interval, - filter, - take, - debounceTime, - merge, - of, -} from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -export const wsOutgoingMessage = ( - subjects: SignalingSubjectsType, - sendMessage: (message: string) => void, - getWs: () => WebSocket | undefined -) => - subjects.wsOutgoingMessageSubject.pipe( - tap(() => subjects.wsIsSendingMessageSubject.next(true)), - concatMap((message) => - merge(of(true), interval(100)).pipe( - filter(() => { - const ws = getWs() - return ws ? ws.OPEN === ws.readyState : false - }), - take(1), - tap(() => sendMessage(message)) - ) - ), - debounceTime(1000), - tap(() => subjects.wsIsSendingMessageSubject.next(false)) - ) diff --git a/src/connector/signaling/observables/ws-reconnect.ts b/src/connector/signaling/observables/ws-reconnect.ts deleted file mode 100644 index 5c30eb72..00000000 --- a/src/connector/signaling/observables/ws-reconnect.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { config } from 'config' -import { Logger } from 'loglevel' -import { - combineLatest, - skip, - interval, - withLatestFrom, - filter, - exhaustMap, - tap, - first, - mergeMap, -} from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -export const wsReconnect = (subjects: SignalingSubjectsType, logger: Logger) => - combineLatest([ - subjects.wsStatusSubject, - subjects.wsConnectSubject, - subjects.wsConnectionSecretsSubject, - ]).pipe( - skip(1), - filter(([status, shouldConnect, connectionSecretsResult]) => - status === 'disconnected' && shouldConnect && connectionSecretsResult - ? connectionSecretsResult.isOk() - : false - ), - exhaustMap(() => - interval(config.signalingServer.reconnect.interval).pipe( - withLatestFrom(subjects.wsConnectSubject, subjects.wsStatusSubject), - filter( - ([, shouldConnect, status]) => - shouldConnect && status === 'disconnected' - ), - mergeMap(([index]) => - subjects.wsStatusSubject.pipe( - filter((status) => { - if (index > 0) - logger.debug( - `πŸ“‘πŸ”„ lost connection to signaling server, attempting to reconnect... status: ${status}, attempt: ${ - index + 1 - }` - ) - - subjects.wsConnectSubject.next(true) - return status === 'connected' - }), - tap(() => { - if (index > 0) - logger.debug( - 'πŸ“‘πŸ€™ successfully reconnected to signaling server' - ) - }) - ) - ), - first() - ) - ) - ) diff --git a/src/connector/signaling/observables/ws-updated-connection-secrets.ts b/src/connector/signaling/observables/ws-updated-connection-secrets.ts deleted file mode 100644 index 945e8989..00000000 --- a/src/connector/signaling/observables/ws-updated-connection-secrets.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Logger } from 'loglevel' -import { filter, withLatestFrom, tap } from 'rxjs' -import { SignalingSubjectsType } from 'connector/signaling/subjects' - -export const wsUpdatedConnectionSecrets = ( - subjects: SignalingSubjectsType, - disconnect: () => void, - logger: Logger - // eslint-disable-next-line max-params -) => - subjects.wsConnectionSecretsSubject.pipe( - withLatestFrom(subjects.wsConnectSubject, subjects.wsStatusSubject), - filter(([, shouldConnect, status]) => shouldConnect), - tap(([secrets, , status]) => { - logger.debug(`πŸ“‘πŸ”πŸ”„ connection secrets updated`) - if (['connected', 'connecting'].includes(status)) { - disconnect() - } - }) - ) diff --git a/src/connector/signaling/secrets.ts b/src/connector/signaling/secrets.ts deleted file mode 100644 index d5f1a1d9..00000000 --- a/src/connector/signaling/secrets.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { config } from 'config' -import { secureRandom } from 'crypto/secure-random' -import { sha256 } from 'crypto/sha256' -import { Logger } from 'loglevel' -import { ResultAsync } from 'neverthrow' -import { Buffer } from 'buffer' -import { Secrets } from '../_types' - -export const deriveSecretsFromConnectionPassword = ( - encryptionKey: Buffer, - logger: Logger -): ResultAsync => - sha256(encryptionKey).map((connectionId) => { - const secrets = { - connectionId, - encryptionKey, - } - logger.debug( - `πŸ” encryptionKey:\n${encryptionKey.toString( - 'hex' - )}\nconnection ID:\n${connectionId.toString('hex')}\nBuffer:\n[${ - encryptionKey.toJSON().data - }]` - ) - return secrets - }) - -export const generateConnectionPasswordAndDeriveSecrets = ( - byteCount = config.secrets.connectionPasswordByteLength, - logger: Logger -): ResultAsync => - secureRandom(byteCount).asyncAndThen((buffer) => - deriveSecretsFromConnectionPassword(buffer, logger) - ) diff --git a/src/connector/signaling/signaling-client.ts b/src/connector/signaling/signaling-client.ts new file mode 100644 index 00000000..7ff9144a --- /dev/null +++ b/src/connector/signaling/signaling-client.ts @@ -0,0 +1,243 @@ +import { + Answer, + Confirmation, + DataTypes, + IceCandidate, + MessageSources, + Offer, + RemoteData, +} from 'io-types/types' +import { Logger } from 'tslog' +import { + concatMap, + filter, + first, + from, + map, + merge, + mergeMap, + Observable, + of, + switchMap, + tap, +} from 'rxjs' +import { parseRawMessage } from '../helpers/parse-raw-message' +import { SignalingSubjectsType } from './subjects' +import { + remoteClientConnected, + remoteClientDisconnected, + Secrets, +} from 'connector/_types' +import { decryptMessagePayload } from 'connector/helpers' +import { err, ok, Ok, Result, ResultAsync } from 'neverthrow' +import { createIV, encrypt } from 'crypto/encryption' +import { stringify } from 'utils/stringify' + +export type SignalingClientType = ReturnType + +export const SignalingClient = (input: { + baseUrl: string + subjects: SignalingSubjectsType + secrets: Secrets + target: MessageSources + source: MessageSources + logger?: Logger +}) => { + const logger = input.logger + const subjects = input.subjects + const connectionId = Buffer.from(input.secrets.connectionId).toString('hex') + const url = `${input.baseUrl}/${connectionId}?target=${input.target}&source=${input.source}` + + subjects.statusSubject.next('connecting') + logger?.debug(`πŸ›°βšͺ️ signaling server: connecting`, { url }) + const ws = new WebSocket(url) + + const onConfirmation$ = input.subjects.onMessageSubject.pipe( + filter( + (message): message is Confirmation => message.info === 'confirmation' + ) + ) + + const waitForConfirmation = (requestId: string) => + onConfirmation$.pipe( + filter((incomingMessage) => incomingMessage.requestId === requestId) + ) + + const onMessage = (event: MessageEvent) => { + parseRawMessage(event.data).map((message) => { + if (message.info === 'remoteData') + logger?.trace( + `πŸ›°πŸ’¬β¬‡οΈ received: ${message.data.method} (${message.requestId})` + ) + else if (message.info !== 'confirmation') + logger?.trace(`πŸ›°πŸ’¬β¬‡οΈ received:`, message) + + subjects.onMessageSubject.next(message) + }) + } + + const onOpen = () => { + logger?.debug(`πŸ›°πŸŸ’ signaling server: connected`) + subjects.statusSubject.next('connected') + } + + const onClose = (closeEvent: CloseEvent) => { + logger?.debug(`πŸ›°πŸ”΄ signaling server: disconnected`) + subjects.statusSubject.next('disconnected') + } + + const onError = (event: Event) => { + logger?.debug(`πŸ›°βŒ signaling server error`, event) + subjects.onErrorSubject.next(event) + } + + const prepareMessage = ({ + payload, + method, + source, + }: Pick): ResultAsync< + Omit, + Error + > => + createIV() + .asyncAndThen((iv) => + encrypt( + Buffer.from(JSON.stringify(payload)), + input.secrets.encryptionKey, + iv + ) + ) + .map((encrypted) => ({ + requestId: crypto.randomUUID(), + connectionId: connectionId, + encryptedPayload: encrypted.combined.toString('hex'), + method, + source, + })) + + const sendMessage = ( + message: Pick + ): Observable> => + from(prepareMessage(message)).pipe( + mergeMap((result) => { + if (result.isErr()) return of(err(result.error)) + + const encryptedMessage = result.value + + const sendMessage$ = of(stringify(encryptedMessage)).pipe( + tap((result) => + result.map((data) => { + logger?.trace( + `πŸ›°πŸ’¬β¬†οΈ sending: ${message.method} (${encryptedMessage.requestId})` + ) + return ws.send(data) + }) + ), + filter(() => false) + ) + + return merge( + sendMessage$, + waitForConfirmation(encryptedMessage.requestId) + ).pipe(map(() => ok(undefined))) + }), + first() + ) + + const waitForRemoteClient = (waitFor: Set) => + subjects.onMessageSubject.pipe( + filter((message) => waitFor.has(message.info)), + first() + ) + + const onOffer$ = input.subjects.onMessageSubject.pipe( + filter( + (message): message is RemoteData => + message.info === 'remoteData' && message.data.method === 'offer' + ), + switchMap((message) => + decryptMessagePayload( + message.data, + input.secrets.encryptionKey + ) + ), + filter((result): result is Ok => !result.isErr()), + map( + (result): RTCSessionDescriptionInit => ({ + ...result.value, + type: 'offer', + }) + ) + ) + + const onAnswer$ = input.subjects.onMessageSubject.pipe( + filter( + (message): message is RemoteData => + message.info === 'remoteData' && message.data.method === 'answer' + ), + switchMap((message) => + decryptMessagePayload( + message.data, + input.secrets.encryptionKey + ) + ), + filter((result): result is Ok => !result.isErr()), + map( + (result): RTCSessionDescriptionInit => ({ + ...result.value, + type: 'answer', + }) + ) + ) + + const onIceCandidate$ = input.subjects.onMessageSubject.pipe( + filter( + (message): message is RemoteData => + message.info === 'remoteData' && message.data.method === 'iceCandidate' + ), + concatMap((message) => + decryptMessagePayload( + message.data, + input.secrets.encryptionKey + ) + ), + filter( + (result): result is Ok => !result.isErr() + ), + map((result) => new RTCIceCandidate(result.value)) + ) + + ws.onmessage = onMessage + ws.onopen = onOpen + ws.onclose = onClose + ws.onerror = onError + + return { + remoteClientConnected$: waitForRemoteClient(remoteClientConnected), + remoteClientDisconnected$: waitForRemoteClient(remoteClientDisconnected), + sendMessage, + status$: subjects.statusSubject.asObservable(), + onError$: subjects.onErrorSubject.asObservable(), + onConnect$: subjects.statusSubject.pipe( + filter((status) => status === 'connected') + ), + onDisconnect$: subjects.statusSubject.pipe( + filter((status) => status === 'disconnected') + ), + onOffer$, + onAnswer$, + onIceCandidate$, + subjects, + disconnect: () => { + ws.close() + }, + destroy: () => { + ws.close() + ws.removeEventListener('message', onMessage) + ws.removeEventListener('close', onClose) + ws.removeEventListener('error', onError) + ws.removeEventListener('open', onOpen) + logger?.debug(`πŸ›°πŸ§Ή destroying signaling instance`) + }, + } +} diff --git a/src/connector/signaling/signaling-server-client.ts b/src/connector/signaling/signaling-server-client.ts deleted file mode 100644 index aabb9ce7..00000000 --- a/src/connector/signaling/signaling-server-client.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { config } from 'config' -import log, { Logger } from 'loglevel' -import { SignalingSubjectsType, SignalingSubjects } from './subjects' -import { SignalingSubscriptions } from './subscriptions' - -type Source = 'wallet' | 'extension' - -export type SignalingServerClientType = ReturnType -export type SignalingServerClientInput = { - logger?: Logger - baseUrl?: string - target?: Source - source?: Source - subjects?: SignalingSubjectsType -} - -export const SignalingServerClient = ({ - logger = log, - baseUrl = config.signalingServer.baseUrl, - target = 'wallet', - source = 'extension', - subjects = SignalingSubjects(), -}: SignalingServerClientInput) => { - const sendMessageDirection = `[${source} => ${target}]` - let t0 = 0 - let t1 = 0 - let ws: WebSocket | undefined - subjects.wsSourceSubject.next(source) - - const connect = (connectionId: string) => { - logger.debug( - `πŸ“‘βšͺ️ connecting to signaling server\n${baseUrl}/${connectionId}?target=${target}&source=${source}` - ) - subjects.wsStatusSubject.next('connecting') - removeListeners() - t0 = performance.now() - ws = new WebSocket( - `${baseUrl}/${connectionId}?target=${target}&source=${source}` - ) - addListeners(ws) - } - - const disconnect = () => { - logger.debug(`πŸ“‘βšͺ️ disconnecting from signaling server`) - subjects.wsStatusSubject.next('disconnecting') - ws?.close() - removeListeners() - ws = undefined - subjects.wsStatusSubject.next('disconnected') - } - - const addListeners = (ws: WebSocket) => { - ws.onmessage = onMessage - ws.onopen = onOpen - ws.onclose = onClose - ws.onerror = onError - } - - const removeListeners = () => { - ws?.removeEventListener('message', onMessage) - ws?.removeEventListener('close', onClose) - ws?.removeEventListener('error', onError) - ws?.removeEventListener('open', onOpen) - } - - const onMessage = (event: MessageEvent) => { - subjects.wsIncomingRawMessageSubject.next(event) - } - - const onOpen = () => { - t1 = performance.now() - logger.debug( - `πŸ“‘πŸŸ’ connected to signaling server\ntarget=${target}&source=${source}\nconnect time: ${( - t1 - t0 - ).toFixed(0)} ms` - ) - subjects.wsStatusSubject.next('connected') - } - - const onClose = () => { - logger.debug('πŸ“‘πŸ”΄ disconnected from signaling server') - subjects.wsStatusSubject.next('disconnected') - } - - const onError = (event: Event) => { - logger.error(`πŸ“‘βŒ got websocket error`) - logger.trace(event) - subjects.wsErrorSubject.next(event) - } - - const sendMessage = (message: string) => { - logger.debug(`πŸ“‘β¬†οΈπŸ’¬ ${sendMessageDirection} sending ws message`) - ws?.send(message) - } - - const getWs = () => ws - - const subscriptions = SignalingSubscriptions( - subjects, - - { - sendMessage, - connect, - disconnect, - getWs, - }, - logger - ) - - const destroy = () => { - subjects.wsConnectSubject.next(false) - disconnect() - subscriptions.unsubscribe() - } - - return { - destroy, - subjects, - connect: (value: boolean) => subjects.wsConnectSubject.next(value), - disconnect, - } -} diff --git a/src/connector/signaling/subjects.ts b/src/connector/signaling/subjects.ts index 3c634cd1..cc882c09 100644 --- a/src/connector/signaling/subjects.ts +++ b/src/connector/signaling/subjects.ts @@ -1,33 +1,10 @@ -import { - Confirmation, - MessageSources, - SignalingServerErrorResponse, -} from 'io-types/types' -import { Result } from 'neverthrow' -import { BehaviorSubject, ReplaySubject, Subject } from 'rxjs' -import { Secrets, Status } from '../_types' -import { Buffer } from 'buffer' +import { Status } from 'connector/_types' +import { SignalingServerResponse } from 'io-types/types' +import { BehaviorSubject, Subject } from 'rxjs' export type SignalingSubjectsType = ReturnType export const SignalingSubjects = () => ({ - wsOfferReceivedSubject: new BehaviorSubject(false), - wsSourceSubject: new ReplaySubject(), - wsOutgoingMessageSubject: new Subject(), - wsIncomingRawMessageSubject: new Subject>(), - wsErrorSubject: new Subject(), - wsStatusSubject: new BehaviorSubject('disconnected'), - wsConnectSubject: new BehaviorSubject(false), - wsConnectionPasswordSubject: new BehaviorSubject( - undefined - ), - wsConnectionSecretsSubject: new BehaviorSubject< - Result | undefined - >(undefined), - wsGenerateConnectionSecretsSubject: new Subject(), - wsIncomingMessageConfirmationSubject: new Subject(), - wsServerErrorResponseSubject: new Subject(), - wsIsSendingMessageSubject: new BehaviorSubject(false), - wsAutoConnect: new BehaviorSubject(false), - wsLoadOrCreateConnectionPasswordSubject: new Subject(), - wsRegenerateConnectionPassword: new Subject(), + onMessageSubject: new Subject(), + onErrorSubject: new Subject(), + statusSubject: new BehaviorSubject('disconnected'), }) diff --git a/src/connector/signaling/subscriptions.ts b/src/connector/signaling/subscriptions.ts deleted file mode 100644 index 01a3bab5..00000000 --- a/src/connector/signaling/subscriptions.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Subscription } from 'rxjs' -import { wsOutgoingMessage } from './observables/ws-outgoing-message' -import { wsConnect } from './observables/ws-connect' -import { wsDisconnect } from './observables/ws-disconnect' -import { wsReconnect } from './observables/ws-reconnect' -import { - wsGenerateConnectionSecrets, - wsConnectionPassword, -} from './observables/ws-connection-secrets' -import { SignalingSubjectsType } from './subjects' -import { Logger } from 'loglevel' -import { wsUpdatedConnectionSecrets } from './observables/ws-updated-connection-secrets' - -export type SignalingSubscriptionsDependencies = { - sendMessage: (message: string) => void - connect: (connectionId: string) => void - disconnect: () => void - getWs: () => WebSocket | undefined -} - -export const SignalingSubscriptions = ( - subjects: SignalingSubjectsType, - dependencies: SignalingSubscriptionsDependencies, - logger: Logger -) => { - const subscriptions = new Subscription() - - subscriptions.add( - wsOutgoingMessage( - subjects, - dependencies.sendMessage, - dependencies.getWs - ).subscribe() - ) - subscriptions.add(wsConnect(subjects, dependencies.connect).subscribe()) - subscriptions.add(wsDisconnect(subjects, dependencies.disconnect).subscribe()) - subscriptions.add(wsReconnect(subjects, logger).subscribe()) - subscriptions.add(wsGenerateConnectionSecrets(subjects, logger).subscribe()) - subscriptions.add(wsConnectionPassword(subjects, logger).subscribe()) - subscriptions.add( - wsUpdatedConnectionSecrets( - subjects, - dependencies.disconnect, - logger - ).subscribe() - ) - - return subscriptions -} diff --git a/src/connector/storage/observables/connection-password.ts b/src/connector/storage/observables/connection-password.ts deleted file mode 100644 index 56f81470..00000000 --- a/src/connector/storage/observables/connection-password.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { StorageSubjectsType } from 'connector/storage/subjects' -import { switchMap } from 'rxjs' -import { ChromeApiType } from 'chrome/chrome-api' - -export const addConnectionPassword = ( - subjects: StorageSubjectsType, - chromeApi: ChromeApiType -) => - subjects.addConnectionPasswordSubject.pipe( - switchMap((buffer) => - chromeApi.storage.setItem('connectionPassword', buffer.toString('hex')) - ) - ) - -export const removeConnectionPassword = ( - subjects: StorageSubjectsType, - chromeApi: ChromeApiType -) => - subjects.removeConnectionPasswordSubject.pipe( - switchMap(() => chromeApi.storage.removeItem('connectionPassword')) - ) diff --git a/src/connector/storage/storage-client.ts b/src/connector/storage/storage-client.ts deleted file mode 100644 index bbe42b40..00000000 --- a/src/connector/storage/storage-client.ts +++ /dev/null @@ -1,52 +0,0 @@ -import log, { Logger } from 'loglevel' -import { StorageSubjects, StorageSubjectsType } from './subjects' -import { storageSubscriptions } from './subscriptions' -import { Buffer } from 'buffer' -import { createChromeApi } from 'chrome/chrome-api' - -export type StorageClientType = ReturnType -export type StorageInput = { - id?: string - subjects?: StorageSubjectsType - logger?: Logger -} -export const StorageClient = ({ - id = crypto.randomUUID(), - subjects = StorageSubjects(), - logger = log, -}: StorageInput) => { - logger.debug(`πŸ“¦ storage client with id: '${id}' initiated`) - - const { chromeAPI, getConnectionPassword, removeConnectionPassword } = - createChromeApi(id, logger) - - const subscription = storageSubscriptions(subjects, chromeAPI) - - const onPasswordChange = (changes: { - [key: string]: chrome.storage.StorageChange - }) => { - const value = changes[`${id}:connectionPassword`] - if (changes[`${id}:connectionPassword`]) { - logger.debug(`πŸ” detected password change\n${JSON.stringify(value)}`) - subjects.onPasswordChange.next( - value.newValue ? Buffer.from(value.newValue, 'hex') : undefined - ) - } - } - - chromeAPI.storage.addListener(onPasswordChange) - - const destroy = () => { - chromeAPI.storage.removeListener(onPasswordChange) - subscription.unsubscribe() - } - - return { - subjects, - destroy, - id, - getConnectionPassword, - removeConnectionPassword, - chromeAPI, - } -} diff --git a/src/connector/storage/subjects.ts b/src/connector/storage/subjects.ts deleted file mode 100644 index ec0e3071..00000000 --- a/src/connector/storage/subjects.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BehaviorSubject, Subject } from 'rxjs' -import { Buffer } from 'buffer' - -export type StorageSubjectsType = ReturnType -export const StorageSubjects = () => ({ - addConnectionPasswordSubject: new Subject(), - removeConnectionPasswordSubject: new Subject(), - onPasswordChange: new Subject(), - activeConnections: new BehaviorSubject(false), -}) diff --git a/src/connector/storage/subscriptions.ts b/src/connector/storage/subscriptions.ts deleted file mode 100644 index 83d334a4..00000000 --- a/src/connector/storage/subscriptions.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ChromeApiType } from 'chrome/chrome-api' -import { Subscription } from 'rxjs' -import { - addConnectionPassword, - removeConnectionPassword, -} from './observables/connection-password' -import { StorageSubjectsType } from './subjects' - -export const storageSubscriptions = ( - subjects: StorageSubjectsType, - chromeAPI: ChromeApiType -) => { - const subscription = new Subscription() - subscription.add(addConnectionPassword(subjects, chromeAPI).subscribe()) - subscription.add(removeConnectionPassword(subjects, chromeAPI).subscribe()) - return subscription -} diff --git a/src/connector/subjects.ts b/src/connector/subjects.ts deleted file mode 100644 index c7f948c6..00000000 --- a/src/connector/subjects.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BehaviorSubject } from 'rxjs' - -export type ConnectorSubjectsType = ReturnType - -export const ConnectorSubjects = () => ({ - pairingStateSubject: new BehaviorSubject<'paired' | 'notPaired' | 'loading'>( - 'loading' - ), -}) diff --git a/src/connector/subscriptions.ts b/src/connector/subscriptions.ts deleted file mode 100644 index dcecace8..00000000 --- a/src/connector/subscriptions.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Observable, Subscription } from 'rxjs' -import { - connection, - killSignalingServerConnection, -} from './observables/connection' -import { wsSendMessage } from './observables/ws-send-message' -import { wsIncomingMessage } from './observables/ws-incoming-message' -import { rtcRestart } from './observables/rtc-restart' -import { wsConnect } from './observables/ws-connect' -import { wsConnectionPasswordChange } from './observables/ws-connection-password-change' -import { storeConnectionPassword } from './observables/store-connection-password' -import { regenerateConnectionPassword } from './observables/regenerate-connection-password' -import { ConnectorSubscriptionsInput } from './_types' -import { pairingState } from './observables/pairing-state' - -export const ConnectorSubscriptions = (input: ConnectorSubscriptionsInput) => { - const subscriptions = new Subscription() - - const observables = [ - connection, - wsSendMessage, - wsIncomingMessage, - rtcRestart, - wsConnect, - wsConnectionPasswordChange, - storeConnectionPassword, - regenerateConnectionPassword, - pairingState, - killSignalingServerConnection, - ] - - observables.forEach( - (fn: (input: ConnectorSubscriptionsInput) => Observable) => - subscriptions.add(fn(input).subscribe()) - ) - - return subscriptions -} diff --git a/src/connector/webrtc/__tests__/data-chunking.test.ts b/src/connector/webrtc/__tests__/data-chunking.test.ts deleted file mode 100644 index 32a654aa..00000000 --- a/src/connector/webrtc/__tests__/data-chunking.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import log from 'loglevel' -import { Chunked, messageToChunked } from '../data-chunking' - -describe('data chunking', () => { - beforeEach(() => { - log.setLevel('silent') - }) - - describe('happy paths', () => { - it('should transform a message to a chunked message', async () => { - const message = 'foobar' - const chunkedResult = await messageToChunked( - Buffer.from(message, 'utf-8') - ) - if (chunkedResult.isErr()) throw chunkedResult.error - expect(chunkedResult.value).toHaveProperty('chunks') - expect(chunkedResult.value).toHaveProperty('metaData') - }) - - it('should transform chunked message into message', async () => { - const message = `Cerberus is the unique consensus protocol underpinning Radix. It took seven years of research, starting in 2013 and culminating in the Cerberus Whitepaper in 2020. How Cerberus is going to be implemented in its final fully sharded form is the focus of Radix Labs and Cassandra. - -In its final form, Cerberus represents a radically different paradigm in the design of decentralized Distributed Ledger Technology systems. It is the only protocol that is designed so that all transactions are atomically composed across shards. This is a critical feature if DeFi is to ever scale to billions of users. - -Cerberus takes a well-proven 'single-pipe' BFT (Byzantine Fault Tolerance) consensus process and parallelizes it across an extensive set of instances or shards – practically an unlimited number of shards. It achieves this parallelization through a unique new 'braided' synchronization of consensus across shards, as required by the 'dependencies' of each transaction. This requires the use of a specialized application layer called the Radix Engine. - -All of this provides Cerberus with practically infinite 'linear' scalability. This means that as more nodes are added to the Radix Public Network, throughput increases linearly. As a result, Cerberus is the only consensus protocol that is capable of supporting the global financial system on a decentralized network.` - - const chunkedResult = await messageToChunked( - Buffer.from(message, 'utf-8'), - 400 - ) - if (chunkedResult.isErr()) throw chunkedResult.error - - const chunkedMessage = Chunked(chunkedResult.value.metaData, log) - - let allChunksReceivedResult = chunkedMessage.allChunksReceived() - if (allChunksReceivedResult.isErr()) throw allChunksReceivedResult.error - expect(allChunksReceivedResult.value).toBe(false) - - chunkedResult.value.chunks.forEach(chunkedMessage.addChunk) - - allChunksReceivedResult = chunkedMessage.allChunksReceived() - if (allChunksReceivedResult.isErr()) throw allChunksReceivedResult.error - expect(allChunksReceivedResult.value).toBe(true) - - const validateResult = await chunkedMessage.validate() - if (validateResult.isErr()) throw validateResult.error - - const messageResult = await chunkedMessage.toString() - if (messageResult.isErr()) throw messageResult.error - expect(message).toEqual(messageResult.value) - }) - }) -}) diff --git a/src/connector/webrtc/__tests__/send-message.test.ts b/src/connector/webrtc/__tests__/send-message.test.ts deleted file mode 100644 index 63122a5d..00000000 --- a/src/connector/webrtc/__tests__/send-message.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-disable max-nested-callbacks */ -import log from 'loglevel' -import { delay, firstValueFrom, Subject, Subscription } from 'rxjs' -import { rtcOutgoingMessage } from '../observables/rtc-outgoing-message' -import { rtcMessageQueue } from '../observables/rtc-message-queue' -import { WebRtcSubjects, WebRtcSubjectsType } from '../subjects' -import { ChunkedMessageType } from '../data-chunking' -import { ok, Result } from 'neverthrow' - -describe('send message over webRTC data channel', () => { - let webRtcSubjects: WebRtcSubjectsType - let rtcOutgoingMessage$: ReturnType - let rtcMessageQueue$: ReturnType - let mockedIncomingMessageSubject = new Subject< - Result - >() - let subscriptions: Subscription - const logger = log - logger.setLevel('silent') - - beforeEach(() => { - subscriptions = new Subscription() - webRtcSubjects = WebRtcSubjects() - rtcOutgoingMessage$ = rtcOutgoingMessage( - webRtcSubjects, - mockedIncomingMessageSubject, - logger - ) - rtcMessageQueue$ = rtcMessageQueue(webRtcSubjects, logger) - }) - afterEach(() => { - subscriptions.unsubscribe() - }) - it('should receive message confirmation', (done) => { - firstValueFrom(rtcOutgoingMessage$).then((result) => { - if (result.isErr()) { - throw result.error - } - expect(result.value.packageType).toBe('receiveMessageConfirmation') - done() - }) - - webRtcSubjects.rtcOutgoingMessageSubject.next('test') - - firstValueFrom( - webRtcSubjects.rtcOutgoingChunkedMessageSubject.pipe(delay(0)) - ).then((message) => { - const messageId = JSON.parse(message).messageId - mockedIncomingMessageSubject.next( - ok({ - packageType: 'receiveMessageConfirmation', - messageId, - }) - ) - }) - }) - - it('should receive timeout', (done) => { - firstValueFrom(rtcOutgoingMessage$).then((result) => { - if (result.isErr()) { - expect(result.error).toBe('timeout') - return done() - } - throw new Error('should have received timeout error') - }) - - webRtcSubjects.rtcOutgoingMessageSubject.next('test') - }) - - it('should retry message if timed out', async () => { - firstValueFrom(rtcMessageQueue$) - webRtcSubjects.rtcAddMessageToQueueSubject.next('test') - webRtcSubjects.rtcStatusSubject.next('connected') - - expect( - await firstValueFrom(webRtcSubjects.rtcSendMessageRetrySubject) - ).toBe('test') - }) -}) diff --git a/src/connector/webrtc/data-channel-client.ts b/src/connector/webrtc/data-channel-client.ts new file mode 100644 index 00000000..4af69f88 --- /dev/null +++ b/src/connector/webrtc/data-channel-client.ts @@ -0,0 +1,104 @@ +import { ChunkedMessageType } from '../helpers/data-chunking' +import { Subject, Subscription, tap } from 'rxjs' +import { Logger } from 'tslog' +import { parseJSON } from 'utils' +import { toBuffer } from 'utils/to-buffer' +import { WebRtcSubjectsType } from './subjects' +import { stringify } from 'utils/stringify' +import { handleIncomingChunkedMessages } from './helpers/handle-incoming-chunked-messages' +import { Message } from 'connector/_types' + +export const DataChannelClient = (input: { + dataChannel: RTCDataChannel + logger?: Logger + subjects: WebRtcSubjectsType + onMessageSubject: Subject +}) => { + const logger = input.logger + const dataChannel = input.dataChannel + const subjects = input.subjects + + const onDataChannelOpen = () => { + logger?.debug(`πŸ•ΈπŸŸ’ data channel open`) + subjects.dataChannelStatusSubject.next('open') + } + + const onDataChannelClose = () => { + logger?.debug(`πŸ•ΈπŸ”΄ data channel closed`) + subjects.dataChannelStatusSubject.next('closed') + } + + const onDataChannelMessage = (event: MessageEvent) => { + // webRTC clients are sending data in different formats + parseJSON(toBuffer(event.data).toString('utf-8')) + .map((message) => { + logger?.debug(`πŸ•ΈπŸ’¬β¬‡οΈ received data channel message`, message) + return subjects.onDataChannelMessageSubject.next(message) + }) + .mapErr((err) => { + logger?.debug(`πŸ•ΈπŸ’¬β¬‡οΈ received data channel message`, err) + }) + } + + const onDataChannelError = () => { + logger?.debug(`πŸ•ΈβŒ data channel error`) + } + + dataChannel.onopen = onDataChannelOpen + dataChannel.onclose = onDataChannelClose + dataChannel.onmessage = onDataChannelMessage + dataChannel.onerror = onDataChannelError + + const sendMessageOverDataChannel = (message: string) => { + logger?.debug(`πŸ•ΈπŸ’¬β¬†οΈ sendMessageOverDataChannel`, message) + dataChannel.send(message) + } + + const sendConfirmationMessage = (messageId: string) => { + stringify({ + packageType: 'receiveMessageConfirmation', + messageId, + }).map(sendMessageOverDataChannel) + } + + const sendErrorMessage = (messageId: string) => { + stringify({ + packageType: 'receiveMessageError', + messageId, + error: 'messageHashesMismatch', + }).map(sendMessageOverDataChannel) + } + + const subscriptions = new Subscription() + + subscriptions.add( + subjects.sendMessageOverDataChannelSubject + .pipe(tap(sendMessageOverDataChannel)) + .subscribe() + ) + + subscriptions.add( + handleIncomingChunkedMessages(subjects) + .pipe( + tap((result) => + result + .map(({ messageId, message }) => { + sendConfirmationMessage(messageId) + input.onMessageSubject.next(message) + }) + .mapErr(sendErrorMessage) + ) + ) + .subscribe() + ) + + return { + subjects, + destroy: () => { + dataChannel.removeEventListener('message', onDataChannelMessage) + dataChannel.removeEventListener('open', onDataChannelOpen) + dataChannel.removeEventListener('close', onDataChannelClose) + subscriptions.unsubscribe() + }, + } +} diff --git a/src/connector/webrtc/helpers/handle-incoming-chunked-messages.ts b/src/connector/webrtc/helpers/handle-incoming-chunked-messages.ts new file mode 100644 index 00000000..3d78cc05 --- /dev/null +++ b/src/connector/webrtc/helpers/handle-incoming-chunked-messages.ts @@ -0,0 +1,42 @@ +import { Chunked, MessageChunk, MetaData } from 'connector/helpers' +import { Message } from 'connector/_types' +import { exhaustMap, filter, first, mergeMap, tap } from 'rxjs' +import { parseJSON } from 'utils' +import { WebRtcSubjectsType } from '../subjects' + +const waitForMetaData = (subjects: WebRtcSubjectsType) => + subjects.onDataChannelMessageSubject.pipe( + filter((message): message is MetaData => message.packageType === 'metaData') + ) + +const waitForMessageChuck = (subjects: WebRtcSubjectsType, messageId: string) => + subjects.onDataChannelMessageSubject.pipe( + filter( + (message): message is MessageChunk => + message.packageType === 'chunk' && message.messageId === messageId + ) + ) + +export const handleIncomingChunkedMessages = (subjects: WebRtcSubjectsType) => + waitForMetaData(subjects).pipe( + exhaustMap((metadata) => { + const chunked = Chunked(metadata) + const messageChunk$ = waitForMessageChuck(subjects, metadata.messageId) + return messageChunk$.pipe( + tap((chunk) => chunked.addChunk(chunk)), + filter(() => { + const result = chunked.allChunksReceived() + if (result.isErr()) return true + return result.value + }), + first(), + mergeMap(() => + chunked + .toString() + .andThen(parseJSON) + .map((message) => ({ messageId: metadata.messageId, message })) + .mapErr(() => metadata.messageId) + ) + ) + }) + ) diff --git a/src/connector/webrtc/helpers/prepare-message.ts b/src/connector/webrtc/helpers/prepare-message.ts new file mode 100644 index 00000000..2d34e820 --- /dev/null +++ b/src/connector/webrtc/helpers/prepare-message.ts @@ -0,0 +1,16 @@ +import { messageToChunked } from 'connector/helpers' +import { Message } from 'connector/_types' +import { Result } from 'neverthrow' +import { stringify } from 'utils/stringify' +import { toBuffer } from 'utils/to-buffer' + +export const prepareMessage = (message: Message) => + stringify(message) + .map(toBuffer) + .asyncAndThen(messageToChunked) + .andThen((value) => + Result.combine([ + stringify(value.metaData), + ...value.chunks.map(stringify), + ]).map((chunks) => ({ chunks, messageId: value.metaData.messageId })) + ) diff --git a/src/connector/webrtc/helpers/send-chunks.ts b/src/connector/webrtc/helpers/send-chunks.ts new file mode 100644 index 00000000..5c0dd8b9 --- /dev/null +++ b/src/connector/webrtc/helpers/send-chunks.ts @@ -0,0 +1,8 @@ +import { filter, of, tap } from 'rxjs' +import { WebRtcSubjectsType } from '../subjects' + +export const sendChunks = (subjects: WebRtcSubjectsType, chunks: string[]) => + of(...chunks).pipe( + tap((chunk) => subjects.sendMessageOverDataChannelSubject.next(chunk)), + filter(() => false) + ) diff --git a/src/connector/webrtc/helpers/send-message-over-data-channel-and-wait-for-confirmation.ts b/src/connector/webrtc/helpers/send-message-over-data-channel-and-wait-for-confirmation.ts new file mode 100644 index 00000000..e0d797e0 --- /dev/null +++ b/src/connector/webrtc/helpers/send-message-over-data-channel-and-wait-for-confirmation.ts @@ -0,0 +1,22 @@ +import { Message } from 'connector/_types' +import { err, ok, Result } from 'neverthrow' +import { from, map, merge, mergeMap, Observable, of } from 'rxjs' +import { WebRtcSubjectsType } from '../subjects' +import { prepareMessage } from './prepare-message' +import { waitForConfirmation } from './wait-for-confirmation' +import { sendChunks } from './send-chunks' + +export const sendMessageOverDataChannelAndWaitForConfirmation = ( + subjects: WebRtcSubjectsType, + message: Message +) => + from(prepareMessage(message)).pipe( + mergeMap((result): Observable> => { + if (result.isErr()) return of(err(result.error)) + const { chunks, messageId } = result.value + return merge( + sendChunks(subjects, chunks), + waitForConfirmation(subjects, messageId) + ).pipe(map(() => ok(null))) + }) + ) diff --git a/src/connector/webrtc/helpers/wait-for-confirmation.ts b/src/connector/webrtc/helpers/wait-for-confirmation.ts new file mode 100644 index 00000000..f4062705 --- /dev/null +++ b/src/connector/webrtc/helpers/wait-for-confirmation.ts @@ -0,0 +1,15 @@ +import { MessageConfirmation } from 'connector/helpers' +import { filter } from 'rxjs' +import { WebRtcSubjectsType } from '../subjects' + +export const waitForConfirmation = ( + subjects: WebRtcSubjectsType, + messageId: string +) => + subjects.onDataChannelMessageSubject.pipe( + filter( + (message): message is MessageConfirmation => + message.packageType === 'receiveMessageConfirmation' && + message.messageId === messageId + ) + ) diff --git a/src/connector/webrtc/helpers/wait-for-data-channel-status.ts b/src/connector/webrtc/helpers/wait-for-data-channel-status.ts new file mode 100644 index 00000000..4612de9d --- /dev/null +++ b/src/connector/webrtc/helpers/wait-for-data-channel-status.ts @@ -0,0 +1,10 @@ +import { filter } from 'rxjs' +import { WebRtcClient } from '../webrtc-client' + +export const waitForDataChannelStatus = ( + webRtcClient: WebRtcClient, + value: 'open' | 'closed' +) => + webRtcClient.subjects.dataChannelStatusSubject.pipe( + filter((status) => status === value) + ) diff --git a/src/connector/webrtc/ice-candidate-client.ts b/src/connector/webrtc/ice-candidate-client.ts new file mode 100644 index 00000000..4097a40a --- /dev/null +++ b/src/connector/webrtc/ice-candidate-client.ts @@ -0,0 +1,124 @@ +import { IceCandidate, MessageSources } from 'io-types/types' +import { Logger } from 'tslog' +import { + concatMap, + filter, + first, + map, + merge, + mergeMap, + scan, + Subscription, + tap, +} from 'rxjs' +import { WebRtcSubjectsType } from './subjects' +import { ResultAsync } from 'neverthrow' +import { errorIdentity } from 'utils/error-identity' +import { IceCandidateMessage } from 'connector/_types' + +export const cacheRemoteIceCandidates = (subjects: WebRtcSubjectsType) => + subjects.onRemoteIceCandidateSubject.pipe( + scan((acc, curr) => [...acc, curr], [] as RTCIceCandidate[]), + tap((iceCandidates) => + subjects.remoteIceCandidatesSubject.next(iceCandidates) + ) + ) + +export const IceCandidateClient = (input: { + subjects: WebRtcSubjectsType + peerConnection: RTCPeerConnection + source: MessageSources + logger?: Logger +}) => { + const subjects = input.subjects + const peerConnection = input.peerConnection + const logger = input.logger + + const onIcecandidate = (event: RTCPeerConnectionIceEvent) => { + if (event.candidate) subjects.onIceCandidateSubject.next(event.candidate) + } + const onIceconnectionStateChange = () => { + logger?.debug( + `πŸ•ΈπŸ§Š iceConnectionState: ${peerConnection.iceConnectionState}` + ) + subjects.iceConnectionStateSubject.next(peerConnection.iceConnectionState) + } + + peerConnection.onicecandidate = onIcecandidate + peerConnection.oniceconnectionstatechange = onIceconnectionStateChange + + const addIceCandidate = (iceCandidate: RTCIceCandidate) => + ResultAsync.fromPromise( + peerConnection.addIceCandidate(iceCandidate), + errorIdentity + ) + + const subscriptions = new Subscription() + + const iceCandidate$ = subjects.onIceCandidateSubject.asObservable().pipe( + map(({ candidate, sdpMid, sdpMLineIndex }) => ({ + candidate, + sdpMid, + sdpMLineIndex, + })), + filter( + (iceCandidate): iceCandidate is IceCandidate['payload'] => + !!iceCandidate.candidate + ), + map( + (payload): IceCandidateMessage => ({ + method: 'iceCandidate' as IceCandidate['method'], + payload, + source: input.source, + }) + ) + ) + + const haveRemoteOffer$ = subjects.onSignalingStateChangeSubject.pipe( + filter((value) => value === 'have-remote-offer') + ) + const waitForRemoteDescription$ = merge( + haveRemoteOffer$, + subjects.onRemoteAnswerSubject + ) + + const onRemoteIceCandidate$ = merge( + subjects.remoteIceCandidatesSubject.pipe( + first(), + mergeMap((iceCandidates) => iceCandidates) + ), + subjects.onRemoteIceCandidateSubject + ) + + subscriptions.add( + subjects.onRemoteIceCandidateSubject + .pipe( + scan((acc, curr) => [...acc, curr], [] as RTCIceCandidate[]), + tap((iceCandidates) => + subjects.remoteIceCandidatesSubject.next(iceCandidates) + ) + ) + .subscribe() + ) + + subscriptions.add( + waitForRemoteDescription$ + .pipe( + mergeMap(() => onRemoteIceCandidate$), + concatMap(addIceCandidate) + ) + .subscribe() + ) + + return { + iceCandidate$, + destroy: () => { + peerConnection.removeEventListener('icecandidate', onIcecandidate) + peerConnection.removeEventListener( + 'iceconnectionstatechange', + onIceconnectionStateChange + ) + subscriptions.unsubscribe() + }, + } +} diff --git a/src/connector/webrtc/observables/rtc-connection-state.ts b/src/connector/webrtc/observables/rtc-connection-state.ts deleted file mode 100644 index 065e80be..00000000 --- a/src/connector/webrtc/observables/rtc-connection-state.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { tap, distinctUntilChanged, filter } from 'rxjs' - -export const rtcIceConnectionState = ( - subjects: WebRtcSubjectsType, - closePeerConnection: () => void -) => - subjects.rtcIceConnectionStateSubject.pipe( - distinctUntilChanged(), - filter((rtcIceConnectionState) => - ['failed', 'disconnected'].includes(rtcIceConnectionState || '') - ), - tap(() => { - closePeerConnection() - }) - ) diff --git a/src/connector/webrtc/observables/rtc-connection.ts b/src/connector/webrtc/observables/rtc-connection.ts deleted file mode 100644 index 092d9fb7..00000000 --- a/src/connector/webrtc/observables/rtc-connection.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { PeerConnectionType } from 'connector/webrtc/peer-connection' -import { combineLatest, tap } from 'rxjs' - -export const rtcConnection = ( - subjects: WebRtcSubjectsType, - getPeerConnection: () => PeerConnectionType | undefined, - createPeerConnection: () => void, - destroyPeerConnection: () => void - // eslint-disable-next-line max-params -) => - combineLatest([subjects.rtcConnectSubject, subjects.rtcStatusSubject]).pipe( - tap(([shouldConnect, rtcStatusSubject]) => { - const peerConnectionInstance = getPeerConnection() - if ( - shouldConnect && - !peerConnectionInstance && - rtcStatusSubject === 'disconnected' - ) { - createPeerConnection() - } else if ( - !shouldConnect && - peerConnectionInstance && - rtcStatusSubject !== 'disconnected' - ) { - destroyPeerConnection() - } else if ( - shouldConnect && - peerConnectionInstance && - rtcStatusSubject === 'disconnected' - ) { - subjects.rtcRestartSubject.next() - } - }) - ) diff --git a/src/connector/webrtc/observables/rtc-create-offer.ts b/src/connector/webrtc/observables/rtc-create-offer.ts deleted file mode 100644 index 4957b887..00000000 --- a/src/connector/webrtc/observables/rtc-create-offer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { Logger } from 'loglevel' -import { ResultAsync } from 'neverthrow' -import { switchMap, tap } from 'rxjs' - -export const rtcCreateOffer = ( - subjects: WebRtcSubjectsType, - createPeerConnectionOffer: () => ResultAsync< - RTCSessionDescriptionInit, - Error - >, - setLocalDescription: ( - sessionDescription: RTCSessionDescriptionInit - ) => ResultAsync, - logger: Logger - // eslint-disable-next-line max-params -) => - subjects.rtcCreateOfferSubject.pipe( - switchMap(() => createPeerConnectionOffer().andThen(setLocalDescription)), - tap((result) => { - // TODO: handle error - if (result.isErr()) { - return logger.error(result.error) - } - subjects.rtcLocalOfferSubject.next(result.value) - }) - ) diff --git a/src/connector/webrtc/observables/rtc-incoming-message.ts b/src/connector/webrtc/observables/rtc-incoming-message.ts deleted file mode 100644 index 264604db..00000000 --- a/src/connector/webrtc/observables/rtc-incoming-message.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { exhaustMap, filter, first, tap, map, share } from 'rxjs' -import { Chunked, ChunkedMessageType } from 'connector/webrtc/data-chunking' -import { Logger } from 'loglevel' -import { ok, err } from 'neverthrow' -import { parseJSON } from 'utils' -import { toBuffer } from 'utils/to-buffer' -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' - -export const rtcParsedIncomingMessage = ( - subjects: WebRtcSubjectsType, - logger: Logger -) => - subjects.rtcIncomingChunkedMessageSubject.pipe( - // TODO: add runtime message validation - map((rawMessage) => { - const message = toBuffer(rawMessage).toString('utf-8') - logger.debug( - `πŸ•Έβ¬‡οΈπŸ”ͺπŸ’¬ incoming chunked message:\nsize: ${message.length} Bytes\n${message}` - ) - return parseJSON(message) - }), - share() - ) - -export const rtcIncomingMessage = ( - subjects: WebRtcSubjectsType, - logger: Logger -) => - rtcParsedIncomingMessage(subjects, logger).pipe( - exhaustMap((messageResult) => { - const chunkedResult = messageResult.andThen((message) => - message.packageType === 'metaData' - ? ok(Chunked(message, logger)) - : err(Error(`expected metaData got '${message.packageType}'`)) - ) - if (chunkedResult.isErr()) return [chunkedResult] - const chunked = chunkedResult.value - - return rtcParsedIncomingMessage(subjects, logger).pipe( - tap((result) => - result.map((message) => - message.packageType === 'chunk' - ? chunked.addChunk(message) - : undefined - ) - ), - filter(() => { - const allChunksReceived = chunked.allChunksReceived() - return allChunksReceived.isOk() && allChunksReceived.value - }), - first(), - tap(() => - chunked - .toString() - .map((message) => { - subjects.rtcOutgoingConfirmationMessageSubject.next({ - packageType: 'receiveMessageConfirmation', - messageId: chunked.metaData.messageId, - }) - subjects.rtcIncomingMessageSubject.next(message) - return undefined - }) - .mapErr((error) => { - logger.error(error) - return subjects.rtcOutgoingErrorMessageSubject.next({ - packageType: 'receiveMessageError', - messageId: chunked.metaData.messageId, - error: 'messageHashesMismatch', - }) - }) - ) - ) - }) - ) diff --git a/src/connector/webrtc/observables/rtc-message-queue.ts b/src/connector/webrtc/observables/rtc-message-queue.ts deleted file mode 100644 index fd097264..00000000 --- a/src/connector/webrtc/observables/rtc-message-queue.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { rtcOutgoingMessage } from './rtc-outgoing-message' -import { Logger } from 'loglevel' -import { tap, concatMap, filter, merge, of, first } from 'rxjs' -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { rtcParsedIncomingMessage } from './rtc-incoming-message' - -const sendMessage = ( - message: any, - webRtcSubjects: WebRtcSubjectsType, - logger: Logger -) => - of(true).pipe( - tap(() => { - logger.debug( - `πŸ“±πŸ’¬β¬†οΈ sending message to wallet\n${JSON.stringify(message)}` - ) - webRtcSubjects.rtcOutgoingMessageSubject.next( - typeof message === 'string' ? message : JSON.stringify(message) - ) - }), - filter((value): value is never => false) - ) - -const sendMessageAndWaitForResponse = ( - message: any, - webRtcSubjects: WebRtcSubjectsType, - logger: Logger -) => - merge( - rtcOutgoingMessage( - webRtcSubjects, - rtcParsedIncomingMessage(webRtcSubjects, logger), - logger - ), - sendMessage(message, webRtcSubjects, logger) - ) - -const messageQueue = (webRtcSubjects: WebRtcSubjectsType, logger: Logger) => - webRtcSubjects.rtcAddMessageToQueueSubject.pipe( - tap((message) => - logger.debug(`πŸ•ΈπŸ’¬βΈ message added to queue\n${JSON.stringify(message)}`) - ) - ) - -const retryMessage = (webRtcSubjects: WebRtcSubjectsType, logger: Logger) => - webRtcSubjects.rtcSendMessageRetrySubject.pipe( - tap((message) => - logger.debug( - `πŸ•ΈπŸ’¬πŸ”„ failed to send message retrying... \n${JSON.stringify(message)}` - ) - ) - ) - -export const rtcMessageQueue = ( - webRtcSubjects: WebRtcSubjectsType, - logger: Logger -) => - merge( - messageQueue(webRtcSubjects, logger), - retryMessage(webRtcSubjects, logger) - ).pipe( - concatMap((message) => - webRtcSubjects.rtcStatusSubject.pipe( - filter((status) => status === 'connected'), - concatMap(() => - sendMessageAndWaitForResponse(message, webRtcSubjects, logger) - ), - first(), - tap((result) => { - result - .map(() => - logger.debug( - `πŸ“±πŸ’¬β¬‡οΈ wallet confirmed message\n${JSON.stringify(message)}` - ) - ) - .mapErr((error) => { - if (typeof error === 'string' && error === 'timeout') { - webRtcSubjects.rtcSendMessageRetrySubject.next(message) - } - }) - }) - ) - ) - ) diff --git a/src/connector/webrtc/observables/rtc-outgoing-message.ts b/src/connector/webrtc/observables/rtc-outgoing-message.ts deleted file mode 100644 index 69826ce0..00000000 --- a/src/connector/webrtc/observables/rtc-outgoing-message.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { config } from 'config' -import { - ChunkedMessageType, - messageToChunked, -} from 'connector/webrtc/data-chunking' -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { Logger } from 'loglevel' -import { err, Result } from 'neverthrow' -import { - concatMap, - from, - mergeMap, - timer, - map, - first, - filter, - tap, - merge, - Observable, -} from 'rxjs' -import { toBuffer } from 'utils/to-buffer' - -const dataChannelConfirmation = ( - messageIdResult: Result, - rtcParsedIncomingMessage$: Observable>, - logger: Logger -) => - rtcParsedIncomingMessage$.pipe( - filter( - (messageResult) => - messageIdResult.isOk() && - messageResult.isOk() && - messageResult.value.packageType === 'receiveMessageConfirmation' && - messageResult.value.messageId === messageIdResult.value - ), - tap(() => { - const messageId = messageIdResult._unsafeUnwrap() - return logger.debug( - `πŸ•ΈπŸ’¬πŸ‘Œ received message confirmation for messageId:\n'${messageId}'` - ) - }) - ) - -const prepareAndSendMessage = ( - rawMessage: string, - subjects: WebRtcSubjectsType -) => - from( - messageToChunked(toBuffer(rawMessage)).map((message) => { - const chunks = [ - JSON.stringify(message.metaData), - ...message.chunks.map((chunk) => JSON.stringify(chunk)), - ] - chunks.forEach((chunk) => - subjects.rtcOutgoingChunkedMessageSubject.next(chunk) - ) - return message.metaData.messageId - }) - ) - -const messageTimeout = (result: Result, logger: Logger) => - timer(config.webRTC.confirmationTimeout).pipe( - map(() => - result.andThen((messageId) => { - logger.debug( - `πŸ•ΈπŸ’¬βŒ confirmation message timeout for messageId:\n'${messageId}'` - ) - return err('timeout') - }) - ) - ) - -const waitForMessageConfirmation = ( - result: Result, - rtcParsedIncomingMessage$: Observable>, - logger: Logger -) => - merge( - dataChannelConfirmation(result, rtcParsedIncomingMessage$, logger), - messageTimeout(result, logger) - ) - -export const rtcOutgoingMessage = ( - subjects: WebRtcSubjectsType, - rtcParsedIncomingMessage$: Observable>, - logger: Logger -) => - subjects.rtcOutgoingMessageSubject.pipe( - concatMap((rawMessage) => - prepareAndSendMessage(rawMessage, subjects).pipe( - mergeMap((result) => - waitForMessageConfirmation(result, rtcParsedIncomingMessage$, logger) - ), - first() - ) - ) - ) diff --git a/src/connector/webrtc/observables/rtc-remote.ts b/src/connector/webrtc/observables/rtc-remote.ts deleted file mode 100644 index 0c520b90..00000000 --- a/src/connector/webrtc/observables/rtc-remote.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { Logger } from 'loglevel' -import { ResultAsync } from 'neverthrow' -import { concatMap, tap, switchMap } from 'rxjs' - -export const rtcRemoteIceCandidate = ( - subjects: WebRtcSubjectsType, - addIceCandidate: ( - iceCandidate: RTCIceCandidateInit - ) => ResultAsync, - logger: Logger -) => - subjects.rtcRemoteIceCandidateSubject.pipe( - concatMap((iceCandidate) => { - logger.debug(`πŸ•Έβ¬‡οΈπŸ₯Ά adding ice candidate`) - return addIceCandidate(new RTCIceCandidate(iceCandidate)) - }), - tap((result) => { - // TODO: handle error - if (result.isErr()) logger.error(result.error) - }) - ) - -export const rtcRemoteOfferSubject = ( - subjects: WebRtcSubjectsType, - setRemoteDescription: ( - sessionDescription: RTCSessionDescriptionInit - ) => ResultAsync, - createPeerConnectionAnswer: () => ResultAsync< - RTCSessionDescriptionInit, - Error - >, - setLocalDescription: ( - sessionDescription: RTCSessionDescriptionInit - ) => ResultAsync, - logger: Logger - // eslint-disable-next-line max-params -) => - subjects.rtcRemoteOfferSubject.pipe( - switchMap((offer) => - setRemoteDescription(offer) - .andThen(createPeerConnectionAnswer) - .andThen(setLocalDescription) - ), - tap((result) => { - // TODO: handle error - if (result.isErr()) { - return logger.error(result.error) - } - subjects.rtcLocalAnswerSubject.next(result.value) - }) - ) - -export const rtcRemoteAnswer = ( - subjects: WebRtcSubjectsType, - setRemoteDescription: ( - sessionDescription: RTCSessionDescriptionInit - ) => ResultAsync, - logger: Logger -) => - subjects.rtcRemoteAnswerSubject.pipe( - switchMap((answer) => setRemoteDescription(answer)), - tap((result) => { - // TODO: handle error - if (result.isErr()) { - return logger.error(result.error) - } - }) - ) diff --git a/src/connector/webrtc/observables/rtc-send-message.ts b/src/connector/webrtc/observables/rtc-send-message.ts deleted file mode 100644 index 56862ed8..00000000 --- a/src/connector/webrtc/observables/rtc-send-message.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' -import { Logger } from 'loglevel' -import { map, merge, tap } from 'rxjs' - -export const rtcSendMessage = ( - subjects: WebRtcSubjectsType, - sendMessage: (message: string) => void, - logger: Logger -) => - merge( - subjects.rtcOutgoingChunkedMessageSubject, - subjects.rtcOutgoingErrorMessageSubject.pipe( - map((message) => JSON.stringify(message)) - ), - subjects.rtcOutgoingConfirmationMessageSubject.pipe( - tap((message) => { - logger.debug( - `πŸ•ΈπŸ’¬πŸ‘Œ sending webRTC data channel confirmation for messageId: ${message.messageId}` - ) - }), - map((message) => JSON.stringify(message)) - ) - ).pipe(tap(sendMessage)) diff --git a/src/connector/webrtc/peer-connection-client.ts b/src/connector/webrtc/peer-connection-client.ts new file mode 100644 index 00000000..6c986254 --- /dev/null +++ b/src/connector/webrtc/peer-connection-client.ts @@ -0,0 +1,110 @@ +import { SignalingClientType } from 'connector/signaling/signaling-client' +import { Answer, MessageSources, Offer } from 'io-types/types' +import { ResultAsync } from 'neverthrow' +import { mergeMap, Subscription, switchMap } from 'rxjs' +import { Logger } from 'tslog' +import { errorIdentity } from 'utils/error-identity' +import { WebRtcSubjectsType } from './subjects' + +export const PeerConnectionClient = (input: { + subjects: WebRtcSubjectsType + peerConnection: RTCPeerConnection + logger?: Logger + shouldCreateOffer: boolean + source: MessageSources + signalingClient: SignalingClientType + restart: () => void +}) => { + const peerConnection = input.peerConnection + const subjects = input.subjects + const logger = input.logger + const subscriptions = new Subscription() + + const signalingClient = input.signalingClient + const onRemoteOffer$ = signalingClient.onOffer$ + const onRemoteAnswer$ = signalingClient.onAnswer$ + + const onNegotiationNeeded = async () => { + if (input.shouldCreateOffer) + subscriptions.add( + signalingClient.remoteClientConnected$ + .pipe( + switchMap(() => + createOffer() + .andThen(setLocalDescription) + .map(() => peerConnection.localDescription!) + .map(({ sdp }) => ({ + method: 'offer' as Offer['method'], + payload: { sdp }, + source: input.source, + })) + .map((offer) => subjects.offerSubject.next(offer)) + ) + ) + .subscribe() + ) + } + + const onSignalingStateChange = () => { + logger?.debug(`πŸ•ΈπŸ› signalingState: ${peerConnection.signalingState}`) + subjects.onSignalingStateChangeSubject.next(peerConnection.signalingState) + } + + peerConnection.onnegotiationneeded = onNegotiationNeeded + peerConnection.onsignalingstatechange = onSignalingStateChange + + const setLocalDescription = (description: RTCSessionDescriptionInit) => + ResultAsync.fromPromise( + peerConnection.setLocalDescription(description), + errorIdentity + ).map(() => peerConnection.localDescription!) + + const setRemoteDescription = (description: RTCSessionDescriptionInit) => + ResultAsync.fromPromise( + peerConnection.setRemoteDescription(description), + errorIdentity + ) + + const createAnswer = () => + ResultAsync.fromPromise(peerConnection.createAnswer(), errorIdentity) + + const createOffer = () => + ResultAsync.fromPromise(peerConnection.createOffer(), errorIdentity) + + subscriptions.add( + onRemoteOffer$ + .pipe( + switchMap((offer) => + setRemoteDescription(offer) + .andThen(createAnswer) + .andThen(setLocalDescription) + .map(({ sdp }) => ({ + method: 'answer' as Answer['method'], + payload: { sdp }, + source: input.source, + })) + .map((answer) => subjects.answerSubject.next(answer)) + ) + ) + .subscribe() + ) + + subscriptions.add( + onRemoteAnswer$.pipe(mergeMap(setRemoteDescription)).subscribe() + ) + + return { + destroy: () => { + peerConnection.removeEventListener( + 'signalingstatechange', + onSignalingStateChange + ) + peerConnection.removeEventListener( + 'onnegotiationneeded', + onNegotiationNeeded + ) + peerConnection.close() + subscriptions.unsubscribe() + }, + } +} diff --git a/src/connector/webrtc/peer-connection.ts b/src/connector/webrtc/peer-connection.ts deleted file mode 100644 index 69e7efd0..00000000 --- a/src/connector/webrtc/peer-connection.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Logger } from 'loglevel' -import { ResultAsync } from 'neverthrow' -import { Subscription } from 'rxjs' -import { errorIdentity } from 'utils/error-identity' -import { - rtcRemoteIceCandidate, - rtcRemoteOfferSubject, - rtcRemoteAnswer, -} from './observables/rtc-remote' -import { rtcCreateOffer } from './observables/rtc-create-offer' -import { rtcSendMessage } from './observables/rtc-send-message' -import { WebRtcSubjectsType } from './subjects' - -export type PeerConnectionType = ReturnType - -export const PeerConnection = ( - subjects: WebRtcSubjectsType, - peerConnectionConfig: RTCConfiguration, - dataChannelConfig: RTCDataChannelInit, - logger: Logger - // eslint-disable-next-line max-params -) => { - subjects.rtcStatusSubject.next('connecting') - const peerConnection = new RTCPeerConnection(peerConnectionConfig) - logger.debug(`πŸ•Έ created webRTC peer connection instance`) - logger.trace(peerConnectionConfig) - - const onicecandidate = (e: RTCPeerConnectionIceEvent) => { - if (e.candidate) { - logger.debug(`πŸ•Έβ¬†οΈπŸ§Š got local ice candidate`) - subjects.rtcLocalIceCandidateSubject.next(e.candidate) - } - } - - const oniceconnectionstatechange = () => { - logger.debug(`πŸ•ΈπŸ§Š iceConnectionState: ${peerConnection.iceConnectionState}`) - subjects.rtcIceConnectionStateSubject.next( - peerConnection.iceConnectionState - ) - } - - peerConnection.oniceconnectionstatechange = oniceconnectionstatechange - - peerConnection.onicecandidate = onicecandidate - - const setRemoteDescription = ( - sessionDescription: RTCSessionDescriptionInit - ): ResultAsync => { - logger.trace( - `πŸ•ΈπŸ‘Ύ setting remote webRTC description: ${sessionDescription.type}` - ) - logger.trace(sessionDescription) - return ResultAsync.fromPromise( - peerConnection.setRemoteDescription(sessionDescription), - errorIdentity - ) - } - - const setLocalDescription = ( - sessionDescription: RTCSessionDescriptionInit - ) => { - logger.trace( - `πŸ•ΈπŸ‘Š setting local webRTC description: ${sessionDescription.type}` - ) - return ResultAsync.fromPromise( - peerConnection.setLocalDescription(sessionDescription), - errorIdentity - ).map(() => sessionDescription) - } - - const createPeerConnectionAnswer = (): ResultAsync< - RTCSessionDescriptionInit, - Error - > => { - logger.debug(`πŸ•Έβ¬†οΈπŸ€› creating answer`) - return ResultAsync.fromPromise(peerConnection.createAnswer(), errorIdentity) - } - - const createPeerConnectionOffer = (): ResultAsync< - RTCSessionDescriptionInit, - Error - > => { - logger.debug(`πŸ•Έβ¬†οΈπŸ€œ creating offer`) - return ResultAsync.fromPromise(peerConnection.createOffer(), errorIdentity) - } - - const addIceCandidate = (iceCandidate: RTCIceCandidateInit) => - ResultAsync.fromPromise( - peerConnection.addIceCandidate(iceCandidate), - errorIdentity - ) - - const dataChannel = peerConnection.createDataChannel( - 'data', - dataChannelConfig - ) - - const onmessage = (ev: MessageEvent) => { - subjects.rtcIncomingChunkedMessageSubject.next(ev.data) - } - - const onopen = () => { - logger.debug(`πŸ•ΈπŸŸ’ webRTC data channel open`) - subjects.rtcStatusSubject.next('connected') - } - - const onclose = () => { - logger.debug(`πŸ•ΈπŸ”΄ webRTC data channel closed`) - subjects.rtcStatusSubject.next('disconnected') - } - - dataChannel.onmessage = onmessage - dataChannel.onopen = onopen - dataChannel.onclose = onclose - - const sendMessage = (message: string) => { - logger.debug( - `πŸ•Έβ¬†οΈπŸ”ͺπŸ’¬ sending chunked message:\nsize: ${message.length} Bytes\n${message}` - ) - dataChannel.send(message) - } - - const destroy = () => { - logger.debug(`🧹 destroying webRTC instance`) - subscriptions.unsubscribe() - dataChannel.close() - peerConnection.close() - peerConnection.removeEventListener('icecandidate', onicecandidate) - peerConnection.removeEventListener( - 'iceconnectionstatechange', - oniceconnectionstatechange - ) - dataChannel.removeEventListener('message', onmessage) - dataChannel.removeEventListener('open', onopen) - dataChannel.removeEventListener('close', onclose) - } - - const subscriptions = new Subscription() - subscriptions.add( - rtcRemoteIceCandidate(subjects, addIceCandidate, logger).subscribe() - ) - subscriptions.add( - rtcRemoteOfferSubject( - subjects, - setRemoteDescription, - createPeerConnectionAnswer, - setLocalDescription, - logger - ).subscribe() - ) - subscriptions.add( - rtcRemoteAnswer(subjects, setRemoteDescription, logger).subscribe() - ) - subscriptions.add( - rtcCreateOffer( - subjects, - createPeerConnectionOffer, - setLocalDescription, - logger - ).subscribe() - ) - subscriptions.add(rtcSendMessage(subjects, sendMessage, logger).subscribe()) - - return { peerConnection, dataChannel, destroy } -} diff --git a/src/connector/webrtc/subjects.ts b/src/connector/webrtc/subjects.ts index bb10877c..0e994e53 100644 --- a/src/connector/webrtc/subjects.ts +++ b/src/connector/webrtc/subjects.ts @@ -1,27 +1,26 @@ -import { Status } from '../_types' -import { BehaviorSubject, Subject } from 'rxjs' -import { MessageConfirmation, MessageErrorTypes } from './data-chunking' +import { Answer, Offer } from 'io-types/types' +import { BehaviorSubject, ReplaySubject, Subject } from 'rxjs' +import { IceCandidateMessage } from 'connector/_types' +import { ChunkedMessageType } from 'connector/helpers' export type WebRtcSubjectsType = ReturnType export const WebRtcSubjects = () => ({ - rtcConnectSubject: new BehaviorSubject(false), - rtcStatusSubject: new BehaviorSubject('disconnected'), - rtcIncomingChunkedMessageSubject: new Subject(), - rtcIncomingMessageSubject: new Subject(), - rtcOutgoingMessageSubject: new Subject(), - rtcOutgoingConfirmationMessageSubject: new Subject(), - rtcOutgoingErrorMessageSubject: new Subject(), - rtcOutgoingChunkedMessageSubject: new Subject(), - rtcLocalIceCandidateSubject: new Subject(), - rtcLocalAnswerSubject: new Subject(), - rtcLocalOfferSubject: new Subject(), - rtcRemoteOfferSubject: new Subject(), - rtcRemoteAnswerSubject: new Subject(), - rtcRemoteIceCandidateSubject: new Subject(), - rtcCreateOfferSubject: new Subject(), - rtcIceConnectionStateSubject: new Subject(), - rtcRestartSubject: new Subject(), - rtcAddMessageToQueueSubject: new Subject(), - rtcSendMessageRetrySubject: new Subject(), + onNegotiationNeededSubject: new Subject(), + onIceCandidateSubject: new Subject(), + iceCandidatesSubject: new BehaviorSubject([]), + onRemoteIceCandidateSubject: new Subject(), + remoteIceCandidatesSubject: new BehaviorSubject([]), + offerSubject: new ReplaySubject< + Pick + >(), + answerSubject: new ReplaySubject< + Pick + >(), + onRemoteAnswerSubject: new Subject(), + onSignalingStateChangeSubject: new Subject(), + dataChannelStatusSubject: new BehaviorSubject<'open' | 'closed'>('closed'), + onDataChannelMessageSubject: new Subject(), + sendMessageOverDataChannelSubject: new Subject(), + iceConnectionStateSubject: new Subject(), }) diff --git a/src/connector/webrtc/subscriptions.ts b/src/connector/webrtc/subscriptions.ts deleted file mode 100644 index f74b09d0..00000000 --- a/src/connector/webrtc/subscriptions.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Subscription } from 'rxjs' -import { WebRtcSubjectsType } from 'connector/webrtc/subjects' - -import { rtcIncomingMessage } from './observables/rtc-incoming-message' -import { rtcIceConnectionState } from './observables/rtc-connection-state' -import { rtcConnection } from './observables/rtc-connection' -import { rtcMessageQueue } from './observables/rtc-message-queue' -import { Logger } from 'loglevel' - -export type WebRtcSubscriptionsType = ReturnType -export type WebRtcSubscriptionDependencies = { - closePeerConnection: () => void - createPeerConnection: () => void - destroy: () => void - getPeerConnectionInstance: () => - | { - peerConnection: RTCPeerConnection - dataChannel: RTCDataChannel - destroy: () => void - } - | undefined -} -export const WebRtcSubscriptions = ( - subjects: WebRtcSubjectsType, - dependencies: WebRtcSubscriptionDependencies, - logger: Logger -) => { - const subscriptions = new Subscription() - - subscriptions.add(rtcIncomingMessage(subjects, logger).subscribe()) - - subscriptions.add( - rtcIceConnectionState( - subjects, - dependencies.closePeerConnection - ).subscribe() - ) - - subscriptions.add( - rtcConnection( - subjects, - dependencies.getPeerConnectionInstance, - dependencies.createPeerConnection, - dependencies.destroy - ).subscribe() - ) - - subscriptions.add(rtcMessageQueue(subjects, logger).subscribe()) - - return subscriptions -} diff --git a/src/connector/webrtc/webrtc-client.ts b/src/connector/webrtc/webrtc-client.ts index 604f05a3..2bf33ed8 100644 --- a/src/connector/webrtc/webrtc-client.ts +++ b/src/connector/webrtc/webrtc-client.ts @@ -1,66 +1,163 @@ -import log, { Logger } from 'loglevel' -import { WebRtcSubscriptions } from 'connector/webrtc/subscriptions' -import { PeerConnectionType, PeerConnection } from './peer-connection' -import { WebRtcSubjectsType, WebRtcSubjects } from './subjects' import { config } from 'config' +import { Logger } from 'tslog' +import { DataChannelClient } from './data-channel-client' +import { WebRtcSubjectsType } from './subjects' +import { IceCandidateClient } from './ice-candidate-client' +import { PeerConnectionClient } from './peer-connection-client' +import { + combineLatest, + concatMap, + filter, + first, + merge, + Subject, + Subscription, + switchMap, + tap, + withLatestFrom, +} from 'rxjs' +import { Message } from 'connector/_types' +import { SignalingClientType } from 'connector/signaling/signaling-client' +import { MessageSources } from 'io-types/types' +import { MessageClientType } from 'connector/messages/message-client' -export type WebRtcClientType = ReturnType -export type WebRtcClientInput = { - logger?: Logger - peerConnectionConfig?: RTCConfiguration - dataChannelConfig?: RTCDataChannelInit - subjects?: WebRtcSubjectsType -} -export const WebRtcClient = ({ - peerConnectionConfig = config.webRTC.peerConnectionConfig, - dataChannelConfig = config.webRTC.dataChannelConfig, - subjects = WebRtcSubjects(), - logger = log, -}: WebRtcClientInput) => { - let peerConnectionInstance: PeerConnectionType | undefined - - const createPeerConnection = () => { - peerConnectionInstance?.destroy() - peerConnectionInstance = PeerConnection( - subjects, - peerConnectionConfig, - dataChannelConfig, - logger - ) - } +export type WebRtcClient = ReturnType - const closePeerConnection = () => { - peerConnectionInstance?.peerConnection.close() - peerConnectionInstance?.dataChannel.close() +export const WebRtcClient = ( + input: typeof config['webRTC'] & { + shouldCreateOffer: boolean + logger?: Logger + subjects: WebRtcSubjectsType + onMessageSubject: Subject + signalingClient: SignalingClientType + source: MessageSources + messageClient: MessageClientType + restart: () => void } +) => { + const logger = input.logger + const subjects = input.subjects + const restart = input.restart + const signalingClient = input.signalingClient - const getPeerConnectionInstance = () => peerConnectionInstance + const peerConnection: RTCPeerConnection = new RTCPeerConnection( + input.peerConnectionConfig + ) - const destroyPeerConnectionInstance = () => { - if (peerConnectionInstance) { - peerConnectionInstance.destroy() - } - } + const dataChannel = peerConnection.createDataChannel( + 'data', + input.dataChannelConfig + ) - const dependencies = { - closePeerConnection, - createPeerConnection, - destroy: destroyPeerConnectionInstance, - getPeerConnectionInstance, - } + const peerConnectionClient = PeerConnectionClient({ + peerConnection, + subjects, + logger, + shouldCreateOffer: input.shouldCreateOffer, + source: input.source, + signalingClient: input.signalingClient, + restart: input.restart, + }) + + const dataChannelClient = DataChannelClient({ + dataChannel, + logger, + subjects, + onMessageSubject: input.onMessageSubject, + }) + + const iceCandidateClient = IceCandidateClient({ + logger, + subjects, + peerConnection, + source: input.source, + }) + + const onRemoteIceCandidate$ = input.signalingClient.onIceCandidate$ + const onLocalIceCandidate$ = iceCandidateClient.iceCandidate$ + const onLocalOffer$ = subjects.offerSubject + const onLocalAnswer$ = subjects.answerSubject + + const subscriptions = new Subscription() + + subscriptions.add( + merge(onLocalOffer$, onLocalAnswer$, onLocalIceCandidate$) + .pipe(concatMap(input.signalingClient.sendMessage)) + .subscribe() + ) + + subscriptions.add( + onRemoteIceCandidate$ + .pipe( + tap((iceCandidate) => + subjects.onRemoteIceCandidateSubject.next(iceCandidate) + ) + ) + .subscribe() + ) + + subscriptions.add( + signalingClient.status$ + .pipe( + filter((status) => status === 'connected'), + first(), + switchMap(() => + signalingClient.status$.pipe( + withLatestFrom(dataChannelClient.subjects.dataChannelStatusSubject), + filter( + ([signalingServerStatus, dataChannelStatus]) => + signalingServerStatus === 'disconnected' && + dataChannelStatus === 'closed' + ), + first(), + tap(() => restart()) + ) + ) + ) + .subscribe() + ) + + subscriptions.add( + combineLatest([ + dataChannelClient.subjects.dataChannelStatusSubject, + signalingClient.status$, + ]) + .pipe( + tap(([dataChannelStatus, signalingServerStatus]) => { + if ( + dataChannelStatus === 'open' && + signalingServerStatus === 'connected' + ) + signalingClient.disconnect() + }) + ) + .subscribe() + ) - const subscriptions = WebRtcSubscriptions(subjects, dependencies, logger) + subscriptions.add( + subjects.iceConnectionStateSubject + .pipe( + filter((state) => state === 'disconnected'), + tap(() => { + restart() + }) + ) + .subscribe() + ) const destroy = () => { - subjects.rtcConnectSubject.next(false) - destroyPeerConnectionInstance() + input.logger?.debug(`πŸ•ΈπŸ§Ή destroying webRTC instance`) + iceCandidateClient.destroy() + dataChannelClient.destroy() + peerConnectionClient.destroy() subscriptions.unsubscribe() } return { - connect: (value: boolean) => subjects.rtcConnectSubject.next(value), + peerConnection, + dataChannelClient, + iceCandidateClient, subjects, destroy, - createPeerConnection, } } diff --git a/src/contexts/connector-context.ts b/src/contexts/connector-context.ts deleted file mode 100644 index 02aa5d3e..00000000 --- a/src/contexts/connector-context.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ConnectorType } from 'connector/connector' -import { createContext } from 'react' - -export const ConnectorContext = createContext(null) diff --git a/src/hooks/use-connection-password.tsx b/src/hooks/use-connection-password.tsx deleted file mode 100644 index 701e5797..00000000 --- a/src/hooks/use-connection-password.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from 'react' -import { useConnectionSecrets } from './use-connection-secrets' - -export const useConnectionPassword = () => { - const [connectionPassword, setConnectionPassword] = useState< - string | undefined - >(undefined) - - const connectionSecrets = useConnectionSecrets() - - useEffect(() => { - if (!connectionSecrets) return - else if (connectionSecrets.isErr()) setConnectionPassword('unset') - else - setConnectionPassword( - connectionSecrets.value.encryptionKey.toString('hex') - ) - }, [connectionSecrets]) - - return connectionPassword -} diff --git a/src/hooks/use-connection-secrets.tsx b/src/hooks/use-connection-secrets.tsx deleted file mode 100644 index 6dcc2b12..00000000 --- a/src/hooks/use-connection-secrets.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Result } from 'neverthrow' -import { useEffect, useState } from 'react' -import { tap } from 'rxjs' -import { Secrets } from 'connector' -import { useConnector } from './use-connector' - -export const useConnectionSecrets = () => { - const connector = useConnector() - const [secrets, setSecrets] = useState | undefined>( - undefined - ) - - useEffect(() => { - if (!connector) return - const subscription = connector.connectionSecrets$ - .pipe(tap((result) => setSecrets(result))) - .subscribe() - - return () => { - subscription.unsubscribe() - } - }, [connector]) - - return secrets -} diff --git a/src/hooks/use-connection-status.tsx b/src/hooks/use-connection-status.tsx deleted file mode 100644 index 9d45d66c..00000000 --- a/src/hooks/use-connection-status.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useState } from 'react' -import { tap } from 'rxjs' -import { Status } from 'connector' -import { useConnector } from './use-connector' - -export const useConnectionStatus = () => { - const connector = useConnector() - const [status, setStatus] = useState() - - useEffect(() => { - if (!connector) return - const subscription = connector.connectionStatus$ - .pipe(tap((result) => setStatus(result))) - .subscribe() - - return () => { - subscription.unsubscribe() - } - }, [connector]) - - return status -} diff --git a/src/hooks/use-connector.ts b/src/hooks/use-connector.ts deleted file mode 100644 index 80b5dcf6..00000000 --- a/src/hooks/use-connector.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ConnectorContext } from 'contexts/connector-context' -import { useContext } from 'react' - -export const useConnector = () => { - const connector = useContext(ConnectorContext) - return connector -} diff --git a/src/hooks/use-paring-state.tsx b/src/hooks/use-paring-state.tsx deleted file mode 100644 index ec86338e..00000000 --- a/src/hooks/use-paring-state.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { PairingState } from 'connector' -import { useEffect, useState } from 'react' -import { tap } from 'rxjs' -import { useConnector } from './use-connector' - -export const usePairingState = () => { - const [pairingState, setPairingState] = useState('loading') - const connector = useConnector() - - useEffect(() => { - if (!connector) return - - const subscription = connector.pairingState$ - .pipe(tap(setPairingState)) - .subscribe() - - return () => { - subscription.unsubscribe() - } - }, [connector]) - - return pairingState -} diff --git a/src/io-types/types.ts b/src/io-types/types.ts index 9369ff28..69b56286 100644 --- a/src/io-types/types.ts +++ b/src/io-types/types.ts @@ -7,7 +7,7 @@ const IceCandidates = literal('iceCandidates') const Types = union([Offer, Answer, IceCandidate, IceCandidates]) -const Sources = union([literal('wallet'), literal('extension')]) +export const Sources = union([literal('wallet'), literal('extension')]) export const AnswerIO = object({ requestId: string(), @@ -69,10 +69,10 @@ export type Confirmation = { requestId: DataTypes['requestId'] } -export type RemoteData = { +export type RemoteData = { info: 'remoteData' - requestId: DataTypes['requestId'] - data: DataTypes + requestId: T['requestId'] + data: T } export type RemoteClientDisconnected = { diff --git a/src/pairing/components/connection-password.test.tsx b/src/pairing/components/connection-password.test.tsx deleted file mode 100644 index 4b74a55e..00000000 --- a/src/pairing/components/connection-password.test.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' - -import { ConnectionPassword } from './connection-password' - -test('should render get wallet link and QR code', async () => { - const { container } = render() - - const getWalletLink: HTMLAnchorElement = screen.getByText( - `Don't have Radix Wallet?` - ) - const QrCode = container.querySelector('svg') - - expect(getWalletLink).not.toBeEmptyDOMElement() - expect(getWalletLink.href).toBe('https://radixdlt.com/') - - expect(QrCode).not.toBeEmptyDOMElement() -}) - -test('should not render if connectionPassword is missing', async () => { - render() - - const getWalletLink = screen.queryByText(`Don't have Radix Wallet?`) - expect(getWalletLink).not.toBeInTheDocument() -}) diff --git a/src/pairing/components/connection-password.tsx b/src/pairing/components/connection-password.tsx index df7ed348..018af0ee 100644 --- a/src/pairing/components/connection-password.tsx +++ b/src/pairing/components/connection-password.tsx @@ -1,12 +1,12 @@ import { Box, Link, QrCode } from '../../components' import { PairingHeader } from './pairing-header' -type ConnectionPasswordProps = { - value: string | undefined -} - -export const ConnectionPassword = ({ value }: ConnectionPasswordProps) => { - if (!value || value === 'unset') return null +export const ConnectionPassword = ({ + connectionPassword, +}: { + connectionPassword?: string +}) => { + if (!connectionPassword) return null return ( <> @@ -15,7 +15,7 @@ export const ConnectionPassword = ({ value }: ConnectionPasswordProps) => { using it with dApps in this web browser. - + { - const onForgetWalletSpy = jest.fn() - render( - - ) - - const forgetWalletButton: HTMLAnchorElement = screen.getByText( - 'Forget this Radix Wallet' - ) - - fireEvent( - forgetWalletButton, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ) - - expect(onForgetWalletSpy).toHaveBeenCalled() -}) diff --git a/src/pairing/components/connection-status.tsx b/src/pairing/components/connection-status.tsx index 948715e6..fdff1938 100644 --- a/src/pairing/components/connection-status.tsx +++ b/src/pairing/components/connection-status.tsx @@ -3,14 +3,10 @@ import { PairingHeader } from './pairing-header' import WalletConnectedIcon from '../assets/wallet-connect-active-icon.svg' type ConnectionStatusProps = { - activeConnection: boolean onForgetWallet: () => void } -export const ConnectionStatus = ({ - activeConnection, - onForgetWallet, -}: ConnectionStatusProps) => ( +export const ConnectionStatus = ({ onForgetWallet }: ConnectionStatusProps) => ( <> diff --git a/src/pairing/main.tsx b/src/pairing/main.tsx index 65dc420b..9a16d77b 100644 --- a/src/pairing/main.tsx +++ b/src/pairing/main.tsx @@ -1,50 +1,7 @@ -import React, { useEffect } from 'react' +import React from 'react' import ReactDOM from 'react-dom/client' import '../../fonts.css' -import { Connector } from 'connector/connector' -import { ConnectorContext } from 'contexts/connector-context' import { Paring } from 'pairing/pairing' -import { useConnector } from 'hooks/use-connector' -import { StorageClient } from 'connector/storage/storage-client' -import { config } from 'config' import './style.css' -import { filter, first, Subscription, tap } from 'rxjs' -const PairingWrapper = () => { - const connector = useConnector() - - useEffect(() => { - if (!connector) return - const subscription = new Subscription() - connector.getConnectionPassword().map((password) => { - if (!password) connector.connect() - }) - - subscription.add( - connector.connectionStatus$ - .pipe( - filter((status) => status === 'connected'), - first(), - tap(() => connector.disconnect()) - ) - .subscribe() - ) - - return () => { - subscription.unsubscribe() - connector.destroy() - } - }, [connector]) - - return -} - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -) +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/src/pairing/pairing.stories.tsx b/src/pairing/pairing.stories.tsx deleted file mode 100644 index de055f26..00000000 --- a/src/pairing/pairing.stories.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ConnectionPassword as ConnectionPasswordC } from './components/connection-password' -import { ConnectionStatus as ConnectionStatusC } from './components/connection-status' -import { config } from '../config' -import { Box } from '../components' - -export const ConnectionPassword = () => ( - - - -) - -export const ConnectionStatus = () => ( - - {}} /> - -) diff --git a/src/pairing/pairing.tsx b/src/pairing/pairing.tsx index de70b5c0..e60d6625 100644 --- a/src/pairing/pairing.tsx +++ b/src/pairing/pairing.tsx @@ -1,28 +1,89 @@ import { ConnectionPassword } from './components/connection-password' -import { useConnectionPassword } from 'hooks/use-connection-password' import { ConnectionStatus } from './components/connection-status' import { PopupWindow } from 'components' -import { useConnectionStatus } from 'hooks/use-connection-status' -import { usePairingState } from 'hooks/use-paring-state' -import { useConnector } from 'hooks/use-connector' +import { useEffect, useState } from 'react' +import { chromeLocalStore } from 'chrome/helpers/chrome-local-store' +import { ConnectorClient } from 'connector/connector-client' +import { config } from 'config' +import { Logger } from 'tslog' +import { ok } from 'neverthrow' export const Paring = () => { - const connectionPassword = useConnectionPassword() - const pairingState = usePairingState() - const connector = useConnector() - const connectionStatus = useConnectionStatus() + const [pairingState, setPairingState] = useState< + 'loading' | 'notPaired' | 'paired' + >('loading') + const [connectionPassword, setConnectionPassword] = useState< + string | undefined + >() + + useEffect(() => { + const connectorClient = ConnectorClient({ + source: 'extension', + target: 'wallet', + signalingServerBaseUrl: config.signalingServer.baseUrl, + isInitiator: false, + logger: new Logger({ + prettyLogTemplate: '{{hh}}:{{MM}}:{{ss}}:{{ms}}\t{{logLevelName}}\t', + minLevel: 2, + }), + }) + + chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes['connectionPassword']) { + const { newValue } = changes['connectionPassword'] + if (!newValue) connect() + } + }) + + const subscription = connectorClient.connectionPassword$.subscribe( + (password) => { + setConnectionPassword(password.toString('hex')) + } + ) + + const connect = () => + chromeLocalStore + .getItem('connectionPassword') + .andThen(({ connectionPassword }) => { + if (connectionPassword) { + connectorClient.setConnectionPassword(connectionPassword) + return ok(null) + } else { + connectorClient.connect() + setPairingState('notPaired') + return connectorClient + .generateConnectionPassword() + .andThen((buffer) => + connectorClient.connected().andThen(() => { + connectorClient.disconnect() + return chromeLocalStore.setItem({ + connectionPassword: buffer.toString('hex'), + }) + }) + ) + } + }) + .map(() => { + setPairingState('paired') + }) + + connect() + + return () => { + connectorClient.destroy() + subscription.unsubscribe() + } + }, [setPairingState, setConnectionPassword]) return ( {pairingState === 'notPaired' && ( - + )} {pairingState === 'paired' && ( { - connector?.generateConnectionPassword() - connector?.connect() + chromeLocalStore.removeItem('connectionPassword') }} /> )} diff --git a/src/utils/stringify.ts b/src/utils/stringify.ts new file mode 100644 index 00000000..37e2902c --- /dev/null +++ b/src/utils/stringify.ts @@ -0,0 +1,9 @@ +import { err, ok, Result } from 'neverthrow' + +export const stringify = (input: unknown): Result => { + try { + return ok(JSON.stringify(input)) + } catch (error) { + return err(error as Error) + } +} diff --git a/vite.config.ts b/vite.config.ts index 8e85a000..4a72dbc3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,7 +19,6 @@ const manifest = defineManifest(async () => { const matches = ['https://*/*'] if (isDevToolsActive) { - permissions.push('contextMenus') matches.push('http://*/*') } @@ -32,9 +31,7 @@ const manifest = defineManifest(async () => { default_popup: 'src/pairing/index.html', }, background: { - service_worker: `src/chrome/background${ - isDevToolsActive ? '-with-dev-tools' : '' - }.ts`, + service_worker: `src/chrome/background.ts`, type: 'module', }, content_scripts: [ @@ -64,9 +61,4 @@ const buildConfig: UserConfigExport = { }, } -if (isDevToolsActive) { - buildConfig.build.rollupOptions.input['devTools'] = - 'src/chrome/dev-tools/dev-tools.html' -} - export default defineConfig(buildConfig) diff --git a/yarn.lock b/yarn.lock index 31ecb536..35ef1c0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9797,6 +9797,11 @@ tslib@^2.1.0, tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== +tslog@^4.7.1: + version "4.7.1" + resolved "https://registry.yarnpkg.com/tslog/-/tslog-4.7.1.tgz#c439a5d900bfa020da5f04b0719acb064a7bee4e" + integrity sha512-Ez90j4FKCUp9bBlgPq96aYDUbXRIOxz6Vxn/4Iw2/IiVxLB5wsUVkWfeK4oqdRMoW8qBVJz9oIT+ysjfyIRufw== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"