diff --git a/.env.development b/.env.development index 9d5d54cb71c..d0671a37068 100644 --- a/.env.development +++ b/.env.development @@ -72,4 +72,8 @@ NODE_ENV=development VITE_FEATURE_MIXPANEL=true VITE_FEATURE_TX_HISTORY_BYE_BYE=true + +VITE_SWAPS_SERVER_URL=/swaps-api +VITE_USER_SERVER_URL=/user-api +VITE_NOTIFICATION_SERVER_URL=/notifications-api VITE_FEATURE_SWAPPER_FIAT_RAMPS=true diff --git a/.yarnrc.yml b/.yarnrc.yml index 039a809f305..793d73c89d3 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,5 +1,7 @@ nodeLinker: node-modules +npmAuthToken: "${NPM_AUTH-fallback}" + npmRegistryServer: "https://registry.npmjs.org" plugins: @@ -15,4 +17,3 @@ unsafeHttpWhitelist: - 127.0.0.1 yarnPath: .yarn/releases/yarn-3.5.0.cjs -npmAuthToken: ${NPM_AUTH-fallback} diff --git a/headers/csps/shapeshiftServer.ts b/headers/csps/shapeshiftServer.ts new file mode 100644 index 00000000000..aca7c3d6113 --- /dev/null +++ b/headers/csps/shapeshiftServer.ts @@ -0,0 +1,5 @@ +import type { Csp } from '../types' + +export const csp: Csp = { + 'connect-src': ['http://localhost:3002', 'http://localhost:3001'], +} diff --git a/scripts/generateAssetData/color-map.json b/scripts/generateAssetData/color-map.json index 7623bfea611..8313d0fdbca 100644 --- a/scripts/generateAssetData/color-map.json +++ b/scripts/generateAssetData/color-map.json @@ -3013,7 +3013,6 @@ "eip155:1/erc20:0x79eb84b5e30ef2481c8f00fd0aa7aad6ac0aa54d": "#F4B654", "eip155:1/erc20:0x79eddec7f9648cf9953db39507efa9f772c83c6e": "#DB1A04", "eip155:1/erc20:0x79f05c263055ba20ee0e814acd117c20caa10e0c": "#1C46BA", - "eip155:1/erc20:0x79fd640000f8563a866322483524a4b48f1ed702": "#C0E4E4", "eip155:1/erc20:0x79fe521199697c9cf94d16d5d49277c933ccc38b": "#C52CDE", "eip155:1/erc20:0x7a097173f1c9e78d84800381c1da649b688cd823": "#130C2C", "eip155:1/erc20:0x7a2bc711e19ba6aff6ce8246c546e8c4b4944dfd": "#64C034", @@ -7831,6 +7830,7 @@ "eip155:42161/erc20:0x39bce681d72720f80424914800a78c63fdfaf645": "#079277", "eip155:42161/erc20:0x3a1429d50e0cbbc45c997af600541fe1cc3d2923": "#040404", "eip155:42161/erc20:0x3a18dcc9745edcd1ef33ecb93b0b6eba5671e7ca": "#A13334", + "eip155:42161/erc20:0x3a26d8f73057a2487f9f752d1a71cd2c56fa412c": "#E0AFC5", "eip155:42161/erc20:0x3a8b787f78d775aecfeea15706d4221b40f345ab": "#E0E0E0", "eip155:42161/erc20:0x3a98e79cdc7d8b2716a8696e25af028e429f11da": "#0454FB", "eip155:42161/erc20:0x3ad63b3c0ea6d7a093ff98fde040baddc389ecdc": "#D5ECE6", @@ -14140,6 +14140,7 @@ "eip155:8453/erc20:0xa1ebb0922a7f43df50b34ad9bf2f602f88aab869": "#F4F6F4", "eip155:8453/erc20:0xa202b2b7b4d2fe56bf81492ffdda657fe512de07": "#0A42BA", "eip155:8453/erc20:0xa23dd9379f2e12d9ce76ec738b9f5e520d1d861c": "#D31B1A", + "eip155:8453/erc20:0xa260d72df8ff2696f3a8d0be46b7bc4d743be764": "#E9C90E", "eip155:8453/erc20:0xa26a3903cbba4e6c2c757d7601fbc569efc479ef": "#079277", "eip155:8453/erc20:0xa26a4611b8313bbb25ccb1a9e227ecc536a2f8f7": "#723CD3", "eip155:8453/erc20:0xa2cac0023a4797b4729db94783405189a4203afc": "#E0AFC5", @@ -14924,4 +14925,4 @@ "eip155:8453/erc20:0xff702347d81725ed8bbe341392af511e29cfed98": "#262626", "eip155:8453/erc20:0xff9c8aad2629d1be9833fd162a87ab7ce1d68fdc": "#F1F5F2", "eip155:8453/slip44:60": "#5C6BC0" -} \ No newline at end of file +} diff --git a/src/context/AppProvider/AppContext.tsx b/src/context/AppProvider/AppContext.tsx index a12f23b43e0..bde9109d38c 100644 --- a/src/context/AppProvider/AppContext.tsx +++ b/src/context/AppProvider/AppContext.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useDiscoverAccounts } from './hooks/useDiscoverAccounts' +import { useManageUser } from './hooks/useManageUser' import { usePortfolioFetch } from './hooks/usePortfolioFetch' import { useSnapStatusHandler } from './hooks/useSnapStatusHandler' @@ -74,6 +75,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { // Handle Ledger device connection state and wallet disconnection useLedgerConnectionState() + useManageUser() + useEffect(() => { const handleLedgerOpenApp = ({ chainId, reject }: LedgerOpenAppEventArgs) => { const onCancel = () => { diff --git a/src/context/AppProvider/hooks/useManageUser.tsx b/src/context/AppProvider/hooks/useManageUser.tsx new file mode 100644 index 00000000000..690141441bf --- /dev/null +++ b/src/context/AppProvider/hooks/useManageUser.tsx @@ -0,0 +1,143 @@ +import { useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { useCallback } from 'react' + +import { getExpoToken } from '@/context/WalletProvider/MobileWallet/mobileMessageHandlers' +import { isMobile } from '@/lib/globals' +import { selectWalletEnabledAccountIds } from '@/state/slices/common-selectors' +import { useAppSelector } from '@/state/store' + +interface UserResponse { + id: string + email: string + userAccounts: { + id: string + accountId: string + }[] + devices: { + id: string + deviceToken: string + deviceType: 'MOBILE' | 'WEB' + isActive: boolean + }[] +} + +interface UserInitResponse { + user: UserResponse + websocketChannel?: string + expoToken?: string +} + +const getUserOrCreate = async (accountIds: string[]): Promise => { + const serverUrl = import.meta.env.VITE_USER_SERVER_URL + + if (!serverUrl) { + throw new Error('USER_SERVER_URL not configured') + } + + try { + const response = await axios.post(`${serverUrl}/users/get-or-create`, { + accountIds, + }) + + const user = response.data + + const websocketChannel = `user:${user.id}` + + return { + user, + websocketChannel, + } + } catch (error) { + console.error('Failed to get or create user:', error) + throw error + } +} + +const registerDevice = async ( + userId: string, + deviceToken: string, + deviceType: 'MOBILE' | 'WEB', +): Promise => { + const serverUrl = import.meta.env.VITE_USER_SERVER_URL + + const response = await axios.post(`${serverUrl}/users/${userId}/devices`, { + userId, + deviceToken, + deviceType, + }) + + return response.data +} + +export const useManageUser = () => { + const walletEnabledAccountIds = useAppSelector(selectWalletEnabledAccountIds) + + const { data: mobileExpoToken } = useQuery({ + queryKey: ['getExpoToken'], + queryFn: () => getExpoToken(), + enabled: isMobile, + gcTime: Infinity, + staleTime: Infinity, + }) + + const { + data: userData, + isLoading, + error, + } = useQuery({ + queryKey: ['getOrCreateUser', ...walletEnabledAccountIds], + queryFn: () => getUserOrCreate(walletEnabledAccountIds), + enabled: walletEnabledAccountIds.length > 0, + staleTime: Infinity, + gcTime: Infinity, + }) + + const initializeUserWithDevice = useCallback( + async (expoToken: string | null | undefined) => { + if (!userData?.user) return + + try { + let deviceToken: string + let deviceType: 'MOBILE' | 'WEB' + + if (isMobile && expoToken) { + deviceToken = expoToken + deviceType = 'MOBILE' + } else { + deviceToken = userData.websocketChannel || `user:${userData.user.id}` + deviceType = 'WEB' + } + + await registerDevice(userData.user.id, deviceToken, deviceType) + + return { + userId: userData.user.id, + expoToken: isMobile ? expoToken : undefined, + websocketChannel: userData.websocketChannel, + } + } catch (error) { + console.error('Failed to initialize user with device:', error) + throw error + } + }, + [userData], + ) + + const { data: deviceData } = useQuery({ + queryKey: ['registerDevice', userData?.user?.id, mobileExpoToken], + queryFn: () => initializeUserWithDevice(mobileExpoToken), + enabled: !!userData?.user?.id, + staleTime: Infinity, + gcTime: Infinity, + }) + + return { + user: userData?.user, + expoToken: deviceData?.expoToken, + websocketChannel: deviceData?.websocketChannel, + isLoading, + error, + initializeUserWithDevice, + } +} diff --git a/src/context/WalletProvider/MobileWallet/mobileMessageHandlers.ts b/src/context/WalletProvider/MobileWallet/mobileMessageHandlers.ts index 6f866f70f20..52c2ea91a6f 100644 --- a/src/context/WalletProvider/MobileWallet/mobileMessageHandlers.ts +++ b/src/context/WalletProvider/MobileWallet/mobileMessageHandlers.ts @@ -14,6 +14,7 @@ type Command = | 'listWallets' | 'getWalletCount' | 'reloadWebview' + | 'getExpoToken' | 'requestStoreReview' | 'getAppVersion' @@ -44,6 +45,9 @@ type Message = | { cmd: 'reloadWebview' } + | { + cmd: 'getExpoToken' + } | { cmd: 'vibrate' level: HapticLevel @@ -187,6 +191,11 @@ export const reloadWebview = (): Promise => { return postMessage({ cmd: 'reloadWebview' }) } +/** +export const getExpoToken = (): Promise => { + return postMessage({ cmd: 'getExpoToken' }) +} + /** * Trigger device haptic feedback via the mobile app. * No-ops when not running inside a React Native WebView. diff --git a/src/hooks/useNotificationWebsocket.ts b/src/hooks/useNotificationWebsocket.ts new file mode 100644 index 00000000000..91963e7bc43 --- /dev/null +++ b/src/hooks/useNotificationWebsocket.ts @@ -0,0 +1,121 @@ +import { useQuery } from '@tanstack/react-query' +import type { Socket } from 'socket.io-client' +import { io } from 'socket.io-client' + +import { useManageUser } from '@/context/AppProvider/hooks/useManageUser' + +interface WebSocketConfig { + serverUrl: string + userId: string +} + +const createWebSocketConnection = (config: WebSocketConfig): Promise => { + return new Promise((resolve, reject) => { + const socket = io(config.serverUrl, { + transports: ['websocket'], + autoConnect: true, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + }) + + socket.on('connect', () => { + console.log('WebSocket connected for user:', config.userId) + + socket.emit('authenticate', { userId: config.userId }, (response: any) => { + if (response?.success) { + console.log('Successfully registered to WebSocket') + resolve(socket) + } else { + const error = new Error(response?.error || 'Failed to register to WebSocket') + console.error('WebSocket registration failed:', error) + reject(error) + } + }) + }) + + socket.on('connect_error', error => { + console.error('WebSocket connection error:', error) + reject(error) + }) + + const timeout = setTimeout(() => { + reject(new Error('WebSocket connection timeout')) + }, 10000) + + socket.on('connect', () => { + clearTimeout(timeout) + }) + + socket.on('connect_error', () => { + clearTimeout(timeout) + }) + }) +} + +const serverUrl = import.meta.env.VITE_SWAPS_SERVER_URL + +export const useNotificationWebsocket = () => { + const { user } = useManageUser() + + const { + data: socket, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ['websocket', user?.id], + queryFn: () => { + if (!user?.id || !serverUrl) { + throw new Error('User ID or server URL not available') + } + return createWebSocketConnection({ + serverUrl, + userId: user.id, + }) + }, + enabled: !!user?.id && !!serverUrl, + staleTime: Infinity, + gcTime: Infinity, + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + }) + + const addEventListener = (event: string, handler: (...args: any[]) => void) => { + if (socket) { + socket.on(event, handler) + return () => socket.off(event, handler) + } + return () => {} + } + + const removeEventListener = (event: string, handler: (...args: any[]) => void) => { + if (socket) { + socket.off(event, handler) + } + } + + const emit = (event: string, data: any) => { + if (socket?.connected) { + socket.emit(event, data) + } else { + console.warn('Cannot emit event: WebSocket not connected') + } + } + + return { + socket, + isLoading, + error, + refetch, + isConnected: socket?.connected ?? false, + addEventListener, + removeEventListener, + emit, + onNotification: (handler: (data: any) => void) => addEventListener('notification', handler), + onSwapUpdate: (handler: (data: any) => void) => addEventListener('swap_update', handler), + onConnect: (handler: () => void) => addEventListener('connect', handler), + onDisconnect: (handler: (reason: string) => void) => addEventListener('disconnect', handler), + onError: (handler: (error: Error) => void) => addEventListener('error', handler), + } +} diff --git a/src/hooks/useWebSocket/useWebSocket.tsx b/src/hooks/useWebSocket/useWebSocket.tsx new file mode 100644 index 00000000000..a5d7a42c98c --- /dev/null +++ b/src/hooks/useWebSocket/useWebSocket.tsx @@ -0,0 +1,169 @@ +import { useQuery } from '@tanstack/react-query' +import noop from 'lodash/noop' +import { useCallback, useMemo } from 'react' + +import { useUser } from '@/hooks/useUser/useUser' +import { WebSocketManager, WebSocketServiceType } from '@/lib/websocket/WebSocketManager' +import type { WebSocketEventHandler } from '@/lib/websocket/WebSocketService' + +// Use Vite proxy paths for WebSocket connections +const SWAPS_SERVER_URL = import.meta.env.VITE_SWAPS_SERVER_URL +const NOTIFICATIONS_SERVER_URL = import.meta.env.VITE_NOTIFICATIONS_SERVER_URL + +type UseWebSocketReturn = { + isConnected: boolean + isLoading: boolean + error: Error | null + on: (event: string, handler: WebSocketEventHandler) => () => void + off: (event: string, handler?: WebSocketEventHandler) => void + emit: (event: string, data: unknown) => void + reconnect: () => void + connectedServices: WebSocketServiceType[] +} + +type UseWebSocketOptions = { + serviceType: WebSocketServiceType +} + +export const useWebSocket = (options: UseWebSocketOptions): UseWebSocketReturn => { + const { serviceType } = options + const { user, isLoading: isLoadingUser } = useUser() + + const { isLoading, error, refetch } = useQuery({ + queryKey: ['websocket', serviceType, user?.id], + queryFn: async () => { + if (!user?.id) { + throw new Error('User ID is required for WebSocket connection') + } + + const manager = WebSocketManager.getInstance({ + userId: user.id, + services: { + [WebSocketServiceType.Swaps]: { + serverUrl: SWAPS_SERVER_URL || '', + enabled: !!SWAPS_SERVER_URL, + }, + [WebSocketServiceType.Notifications]: { + serverUrl: NOTIFICATIONS_SERVER_URL || '', + enabled: !!NOTIFICATIONS_SERVER_URL, + }, + }, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + timeout: 10000, + }) + + await manager.connect(serviceType) + return manager + }, + enabled: !!user?.id && !isLoadingUser, + staleTime: Infinity, + gcTime: Infinity, + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + }) + + const getManager = useCallback((): WebSocketManager | null => { + if (!user?.id) { + return null + } + + try { + return WebSocketManager.getInstance({ + userId: user.id, + services: { + [WebSocketServiceType.Swaps]: { + serverUrl: SWAPS_SERVER_URL || '', + enabled: !!SWAPS_SERVER_URL, + }, + [WebSocketServiceType.Notifications]: { + serverUrl: NOTIFICATIONS_SERVER_URL || '', + enabled: !!NOTIFICATIONS_SERVER_URL, + }, + }, + }) + } catch { + return null + } + }, [user?.id]) + + const on = useCallback( + (event: string, handler: WebSocketEventHandler): (() => void) => { + const manager = getManager() + if (!manager) { + console.warn(`Cannot add listener for "${event}": WebSocket not configured`) + return noop + } + + try { + return manager.on(serviceType, event, handler) + } catch (error) { + console.warn(`Cannot add listener for "${event}":`, error) + return noop + } + }, + [getManager, serviceType], + ) + + const off = useCallback( + (event: string, handler?: WebSocketEventHandler): void => { + const manager = getManager() + if (!manager) { + return + } + + try { + manager.off(serviceType, event, handler) + } catch (error) { + console.warn(`Cannot remove listener for "${event}":`, error) + } + }, + [getManager, serviceType], + ) + + const emit = useCallback( + (event: string, data: unknown): void => { + const manager = getManager() + if (!manager) { + console.warn(`Cannot emit event "${event}": WebSocket not configured`) + return + } + + try { + manager.emit(serviceType, event, data) + } catch (error) { + console.error(`Failed to emit event "${event}":`, error) + } + }, + [getManager, serviceType], + ) + + const reconnect = useCallback(() => { + const manager = getManager() + if (manager) { + manager.reconnect(serviceType).catch(console.error) + } + refetch() + }, [getManager, serviceType, refetch]) + + const isConnected = useMemo(() => { + const manager = getManager() + return manager?.isConnected(serviceType) ?? false + }, [getManager, serviceType]) + + const connectedServices = useMemo(() => { + const manager = getManager() + return manager?.getConnectedServices() ?? [] + }, [getManager]) + + return { + isConnected, + isLoading, + error, + on, + off, + emit, + reconnect, + connectedServices, + } +} diff --git a/src/lib/tradeExecution.ts b/src/lib/tradeExecution.ts index 778ddbc1b88..d4b545c5b3d 100644 --- a/src/lib/tradeExecution.ts +++ b/src/lib/tradeExecution.ts @@ -25,6 +25,7 @@ import { TradeExecutionEvent, } from '@shapeshiftoss/swapper' import { TxStatus } from '@shapeshiftoss/unchained-client' +import axios from 'axios' import { EventEmitter } from 'node:events' import { assertGetCosmosSdkChainAdapter } from './utils/cosmosSdk' @@ -36,7 +37,7 @@ import { getConfig } from '@/config' import { queryClient } from '@/context/QueryClientProvider/queryClient' import { fetchIsSmartContractAddressQuery } from '@/hooks/useIsSmartContractAddress/useIsSmartContractAddress' import { poll } from '@/lib/poll/poll' -import { selectCurrentSwap } from '@/state/slices/selectors' +import { selectCurrentSwap, selectWalletEnabledAccountIds } from '@/state/slices/selectors' import { swapSlice } from '@/state/slices/swapSlice/swapSlice' import { selectFirstHopSellAccountId } from '@/state/slices/tradeInputSlice/selectors' import { store } from '@/state/store' @@ -163,6 +164,31 @@ export class TradeExecution { store.dispatch(swapSlice.actions.upsertSwap(updatedSwap)) + const enabledAccountIds = selectWalletEnabledAccountIds(store.getState()) + const userData = queryClient.getQueryData<{ user: { id: string } }>([ + 'getOrCreateUser', + ...enabledAccountIds, + ]) + + await axios.post(`${import.meta.env.VITE_SWAPS_SERVER_URL}/swaps`, { + swapId: swap.id, + sellTxHash, + userId: userData?.user?.id, + sellAsset: updatedSwap.sellAsset, + buyAsset: updatedSwap.buyAsset, + sellAmountCryptoBaseUnit: updatedSwap.sellAmountCryptoBaseUnit, + expectedBuyAmountCryptoBaseUnit: updatedSwap.expectedBuyAmountCryptoBaseUnit, + sellAmountCryptoPrecision: updatedSwap.sellAmountCryptoPrecision, + expectedBuyAmountCryptoPrecision: updatedSwap.expectedBuyAmountCryptoPrecision, + source: updatedSwap.source, + swapperName: updatedSwap.swapperName, + sellAccountId: accountId, + buyAccountId: accountId, + receiveAddress: updatedSwap.receiveAddress, + isStreaming: updatedSwap.isStreaming, + metadata: updatedSwap.metadata, + }) + const { cancelPolling } = poll({ fn: async () => { const { status, message, buyTxHash, relayerTxHash, relayerExplorerTxLink } = diff --git a/src/lib/websocket/WebSocketManager.ts b/src/lib/websocket/WebSocketManager.ts new file mode 100644 index 00000000000..8e5c92e1e9c --- /dev/null +++ b/src/lib/websocket/WebSocketManager.ts @@ -0,0 +1,154 @@ +import type { WebSocketConfig, WebSocketEventHandler } from './WebSocketService' +import { WebSocketConnectionState, WebSocketService } from './WebSocketService' + +export enum WebSocketServiceType { + Swaps = 'swaps', + Notifications = 'notifications', +} + +type ServiceConfig = { + serverUrl: string + enabled: boolean +} + +type WebSocketManagerConfig = { + userId: string + services: Record + reconnectionAttempts?: number + reconnectionDelay?: number + timeout?: number +} + +export class WebSocketManager { + private static instance: WebSocketManager | null = null + private services: Map = new Map() + private config: WebSocketManagerConfig + private userId: string + + private constructor(config: WebSocketManagerConfig) { + this.config = config + this.userId = config.userId + } + + static getInstance(config: WebSocketManagerConfig): WebSocketManager { + if (!WebSocketManager.instance) { + WebSocketManager.instance = new WebSocketManager(config) + } else { + // Update userId if it changed + WebSocketManager.instance.userId = config.userId + } + return WebSocketManager.instance + } + + static resetInstance(): void { + if (WebSocketManager.instance) { + WebSocketManager.instance.disconnectAll() + WebSocketManager.instance = null + } + } + + private getServiceConfig(serviceType: WebSocketServiceType): WebSocketConfig { + const serviceConfig = this.config.services[serviceType] + + // If serverUrl is a relative path (starts with /), it will use current origin + // This works with Vite's proxy in development + const serverUrl = serviceConfig.serverUrl + + return { + serverUrl, + userId: this.userId, + reconnectionAttempts: this.config.reconnectionAttempts, + reconnectionDelay: this.config.reconnectionDelay, + timeout: this.config.timeout, + } + } + + async connect(serviceType: WebSocketServiceType): Promise { + const serviceConfig = this.config.services[serviceType] + + if (!serviceConfig.enabled) { + throw new Error(`Service ${serviceType} is not enabled`) + } + + if (!serviceConfig.serverUrl) { + throw new Error(`Service ${serviceType} has no server URL configured`) + } + + const existingService = this.services.get(serviceType) + if (existingService?.isConnected()) { + return + } + + const config = this.getServiceConfig(serviceType) + const service = new WebSocketService(config) + console.log({ service }) + + await service.connect() + this.services.set(serviceType, service) + } + + disconnect(serviceType: WebSocketServiceType): void { + const service = this.services.get(serviceType) + if (service) { + service.disconnect() + this.services.delete(serviceType) + } + } + + disconnectAll(): void { + this.services.forEach(service => service.disconnect()) + this.services.clear() + } + + async reconnect(serviceType: WebSocketServiceType): Promise { + this.disconnect(serviceType) + await this.connect(serviceType) + } + + on(serviceType: WebSocketServiceType, event: string, handler: WebSocketEventHandler): () => void { + const service = this.services.get(serviceType) + if (!service) { + throw new Error(`Service ${serviceType} is not connected`) + } + return service.on(event, handler) + } + + off(serviceType: WebSocketServiceType, event: string, handler?: WebSocketEventHandler): void { + const service = this.services.get(serviceType) + if (service) { + service.off(event, handler) + } + } + + emit(serviceType: WebSocketServiceType, event: string, data: unknown): void { + const service = this.services.get(serviceType) + if (!service) { + throw new Error(`Service ${serviceType} is not connected`) + } + service.emit(event, data) + } + + isConnected(serviceType: WebSocketServiceType): boolean { + const service = this.services.get(serviceType) + return service?.isConnected() ?? false + } + + getConnectionState(serviceType: WebSocketServiceType): WebSocketConnectionState { + const service = this.services.get(serviceType) + return service?.getConnectionState() ?? WebSocketConnectionState.Disconnected + } + + getConnectedServices(): WebSocketServiceType[] { + const connected: WebSocketServiceType[] = [] + this.services.forEach((service, type) => { + if (service.isConnected()) { + connected.push(type) + } + }) + return connected + } + + isServiceEnabled(serviceType: WebSocketServiceType): boolean { + return this.config.services[serviceType]?.enabled ?? false + } +} diff --git a/src/lib/websocket/WebSocketService.ts b/src/lib/websocket/WebSocketService.ts new file mode 100644 index 00000000000..9cd022d8b27 --- /dev/null +++ b/src/lib/websocket/WebSocketService.ts @@ -0,0 +1,198 @@ +import type { Socket } from 'socket.io-client' +import { io } from 'socket.io-client' + +export enum WebSocketConnectionState { + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', + Error = 'error', +} + +export type WebSocketConfig = { + serverUrl: string + userId: string + reconnectionAttempts?: number + reconnectionDelay?: number + timeout?: number +} + +export type WebSocketEventHandler = (...args: unknown[]) => void + +export class WebSocketError extends Error { + code?: string + + constructor(message: string, code?: string) { + super(message) + this.name = 'WebSocketError' + this.code = code + } +} + +export class WebSocketService { + private socket: Socket | null = null + private config: WebSocketConfig + private connectionState: WebSocketConnectionState = WebSocketConnectionState.Disconnected + + constructor(config: WebSocketConfig) { + this.config = { + reconnectionAttempts: 5, + reconnectionDelay: 1000, + timeout: 10000, + ...config, + } + } + + connect(): Promise { + console.log({ thisSocket: this.socket, config: this.config }) + if (this.socket?.connected) { + return Promise.resolve(this.socket) + } + + this.connectionState = WebSocketConnectionState.Connecting + + return new Promise((resolve, reject) => { + const socket = io(this.config.serverUrl, { + autoConnect: false, + reconnection: true, + reconnectionAttempts: this.config.reconnectionAttempts, + reconnectionDelay: this.config.reconnectionDelay, + }) + + const authenticate = (): Promise => { + return new Promise((resolveAuth, rejectAuth) => { + socket.emit('authenticate', { userId: this.config.userId }, (response: unknown) => { + const typedResponse = response as { success?: boolean; error?: string } + + console.log({ typedResponse }) + + if (typedResponse?.success) { + this.connectionState = WebSocketConnectionState.Connected + resolveAuth() + } else { + const error = new WebSocketError( + typedResponse?.error || 'Authentication failed', + 'AUTH_FAILED', + ) + this.connectionState = WebSocketConnectionState.Error + rejectAuth(error) + } + }) + }) + } + + const timeout = setTimeout(() => { + console.log('timeout') + socket.close() + this.connectionState = WebSocketConnectionState.Error + reject(new WebSocketError('Connection timeout', 'TIMEOUT')) + }, this.config.timeout) + + socket.on('connect', () => { + console.log('Socket connected, clearing timeout') + clearTimeout(timeout) + + authenticate() + .then(() => { + this.socket = socket + resolve(socket) + }) + .catch(error => { + console.error('Authentication failed:', error) + socket.close() + reject(error) + }) + }) + + socket.connect() + + // Handle automatic reconnections - re-authenticate after reconnect + socket.io.on('reconnect', () => { + this.connectionState = WebSocketConnectionState.Connected + authenticate().catch(error => { + console.error('Re-authentication failed after reconnect:', error) + socket.close() + }) + }) + + socket.io.on('reconnect_attempt', () => { + this.connectionState = WebSocketConnectionState.Connecting + }) + + socket.io.on('reconnect_failed', () => { + this.connectionState = WebSocketConnectionState.Error + }) + + socket.on('connect_error', error => { + console.error('Socket connection error:', error) + clearTimeout(timeout) + this.connectionState = WebSocketConnectionState.Error + reject(new WebSocketError(`Connection error: ${error.message}`, 'CONNECT_ERROR')) + }) + + socket.on('error', error => { + console.error('Socket error:', error) + }) + + socket.on('disconnect', reason => { + this.connectionState = WebSocketConnectionState.Disconnected + + // Handle scenarios where Socket.IO won't auto-reconnect + if (reason === 'io server disconnect') { + // Server forcibly disconnected, attempt manual reconnect + socket.connect() + } + }) + }) + } + + disconnect(): void { + if (this.socket) { + this.socket.close() + this.socket = null + this.connectionState = WebSocketConnectionState.Disconnected + } + } + + on(event: string, handler: WebSocketEventHandler): () => void { + if (!this.socket) { + throw new WebSocketError('Socket is not connected', 'NOT_CONNECTED') + } + + this.socket.on(event, handler) + + // Return cleanup function + return () => { + this.socket?.off(event, handler) + } + } + + off(event: string, handler?: WebSocketEventHandler): void { + if (this.socket) { + if (handler) { + this.socket.off(event, handler) + } else { + this.socket.off(event) + } + } + } + + emit(event: string, data: unknown): void { + if (!this.socket?.connected) { + throw new WebSocketError('Cannot emit: Socket is not connected', 'NOT_CONNECTED') + } + + this.socket.emit(event, data) + } + + isConnected(): boolean { + return this.socket?.connected ?? false + } + + getConnectionState(): WebSocketConnectionState { + return this.connectionState + } + + getSocket(): Socket | null { + return this.socket + } +} diff --git a/src/services/websocket.ts b/src/services/websocket.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 28e2d6448fb..930d1128546 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -168,6 +168,7 @@ interface ImportMetaEnv { readonly VITE_SOLANA_NODE_URL: string readonly VITE_THORCHAIN_MIDGARD_URL: string readonly VITE_MAYACHAIN_MIDGARD_URL: string + readonly VITE_SWAPS_SERVER_URL: string // Only present in *some* envs readonly VITE_MIXPANEL_TOKEN?: string diff --git a/vite.config.mts b/vite.config.mts index 502f47126e3..6c7aa78bdd0 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -111,6 +111,26 @@ export default defineConfig(({ mode }) => { port: 3000, headers, host: '0.0.0.0', + proxy: { + '/user-api': { + target: 'http://localhost:3002', + changeOrigin: true, + rewrite: path => path.replace(/^\/user-api/, ''), + ws: true, + }, + '/swaps-api': { + target: 'http://localhost:3001', + changeOrigin: true, + rewrite: path => path.replace(/^\/swaps-api/, ''), + ws: true, + }, + '/notifications-api': { + target: 'http://localhost:3003', + changeOrigin: true, + rewrite: path => path.replace(/^\/notifications-api/, ''), + ws: true, + }, + }, }, preview: { port: 3000,