From c9aadfae8e12b36efbf2b8a861c425d3f9fb5905 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 11:39:47 -0700 Subject: [PATCH] feat: add mobile websocket sync --- docs/ROADMAP.md | 9 +- mobile/lib/supabase.ts | 20 ++--- mobile/package.json | 3 +- mobile/providers/TradingDataProvider.tsx | 107 ++++++++++++++++++++++- pnpm-lock.yaml | 3 + shared/supabase-config.ts | 54 ++++++++++++ web/src/lib/supabaseClient.ts | 18 ++-- 7 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 shared/supabase-config.ts diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b43a0a0..67b6dfe 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,8 +29,8 @@ It assumes: - [x] Monorepo foundation scaffolded with root workspace config, shared runtime, shared product identity, local package linking, and verification scripts - [x] Backend migrated into `backend/` and passing typecheck, build, test, and backend verification gates - [x] Web migrated into `web/` with shared runtime, shared kill-switch gate, shared telemetry bootstrap, and normalized backend URL resolution -- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, and live backend polling -- [-] DRY cleanup completed for runtime/config/bootstrap concerns, but not yet for all auth/session internals +- [x] Mobile migrated into `mobile/` with product identity, shared runtime bootstrap, launch-time kill-switch gate, transitional Supabase auth, and live backend polling plus websocket-backed updates +- [-] DRY cleanup completed for runtime/config/bootstrap concerns and shared Supabase bootstrap, but not yet for all auth/session internals - [!] Full common-platform auth replacement remains a follow-up for web and mobile; current implementation uses transitional Supabase-backed auth to stay compatible with the backend's current JWT boundary ## 3. Guiding Rules @@ -308,7 +308,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell. - [x] Implement launch-time kill-switch and maintenance handling - [ ] Add telemetry startup and error capture - [x] Define initial mobile scope -- [-] Connect to backend and websocket/status contracts +- [x] Connect to backend and websocket/status contracts - [ ] Add push-notification-ready architecture - [x] Define mobile action policy for monitor-first versus control-first flows - [x] Define alert and incident UX @@ -323,6 +323,7 @@ Build mobile as a real ecosystem surface, not a mock UI shell. - [x] Positions - [x] Recent history - [x] Settings and sign out +- [x] Live state refresh via websocket with polling fallback - [-] Safe operator controls limited to explicitly approved actions - [x] Maintain monitor-first, but not monitor-only scope @@ -440,7 +441,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup - [ ] Define Expo structure - [ ] Define navigation shell - [ ] Define auth bootstrap and secure storage -- [ ] Define status polling/live update strategy +- [x] Define status polling/live update strategy - [ ] Define alert/incident UX - [ ] Define operator-safe interventions - [ ] Define offline and degraded-state behavior diff --git a/mobile/lib/supabase.ts b/mobile/lib/supabase.ts index 035aec4..546ede8 100644 --- a/mobile/lib/supabase.ts +++ b/mobile/lib/supabase.ts @@ -1,26 +1,16 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { createClient } from '@supabase/supabase-js'; +import { getMobileSupabaseConfig } from '../../shared/supabase-config.js'; -const supabaseUrl = - process.env.EXPO_PUBLIC_SUPABASE_URL || - process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_URL || - process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_URL; +const supabaseConfig = getMobileSupabaseConfig(); -const supabaseAnonKey = - process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || - process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_ANON_KEY || - process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_ANON_KEY; - -const fallbackSupabaseUrl = 'https://placeholder.bytilyst.local'; -const fallbackSupabaseAnonKey = 'placeholder-anon-key'; - -if (!supabaseUrl || !supabaseAnonKey) { +if (!supabaseConfig.isConfigured) { console.warn('[mobile] Missing Supabase environment variables'); } export const mobileSupabase = createClient( - supabaseUrl || fallbackSupabaseUrl, - supabaseAnonKey || fallbackSupabaseAnonKey, + supabaseConfig.url, + supabaseConfig.anonKey, { auth: { storage: AsyncStorage, diff --git a/mobile/package.json b/mobile/package.json index 935327e..46053d0 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -45,7 +45,8 @@ "react-native-svg": "15.12.1", "react-native-url-polyfill": "^2.0.0", "react-native-web": "^0.21.0", - "react-native-webview": "13.15.0" + "react-native-webview": "13.15.0", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/mobile/providers/TradingDataProvider.tsx b/mobile/providers/TradingDataProvider.tsx index 0ba28e7..e2561fd 100644 --- a/mobile/providers/TradingDataProvider.tsx +++ b/mobile/providers/TradingDataProvider.tsx @@ -1,5 +1,6 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; +import { io, type Socket } from 'socket.io-client'; import { mobileRuntime } from '@/lib/runtime'; import { useMobileAuth } from '@/providers/MobileAuthProvider'; @@ -121,6 +122,7 @@ interface TradingDataContextValue { } const TradingDataContext = createContext(null); +const tradingSocketUrl = mobileRuntime.tradingApiUrl.replace(/\/api$/, ''); const EMPTY_STATE: TradingPortfolioSummary = { netPnl: 0, @@ -179,10 +181,113 @@ export function TradingDataProvider({ children }: { children: ReactNode }) { } const interval = setInterval(() => { void fetchState(); - }, 15000); + }, 60000); return () => clearInterval(interval); }, [accessToken, user, fetchState]); + useEffect(() => { + if (!accessToken || !user) { + return; + } + + let socket: Socket | null = null; + + const mergePartialState = (nextState: BotState) => { + setBotState((prev) => ({ + ...(prev || nextState), + ...nextState, + health: nextState.health || prev?.health, + })); + }; + + socket = io(tradingSocketUrl, { + transports: ['polling', 'websocket'], + auth: { token: accessToken }, + }); + + socket.on('connect', () => { + setConnected(true); + setError(null); + }); + + socket.on('disconnect', () => { + setConnected(false); + }); + + socket.on('connect_error', (socketError) => { + setError(socketError.message); + }); + + socket.on('state', (nextState: BotState) => { + mergePartialState(nextState); + }); + + socket.on('health_update', (health: HealthSnapshot) => { + setBotState((prev) => (prev ? { ...prev, health } : prev)); + }); + + socket.on('symbol_update', ({ symbol, data }: { symbol: string; data: BotState['symbols'][string] }) => { + setBotState((prev) => { + if (!prev) return prev; + return { + ...prev, + symbols: { + ...prev.symbols, + [symbol]: data, + }, + }; + }); + }); + + socket.on('new_alert', (alert: BotState['alerts'][number]) => { + setBotState((prev) => { + if (!prev) return prev; + return { + ...prev, + alerts: [alert, ...prev.alerts].slice(0, 25), + }; + }); + }); + + socket.on('positions_update', (positions: BotState['positions']) => { + setBotState((prev) => (prev ? { ...prev, positions } : prev)); + }); + + socket.on('orders_update', (orders: BotState['orders']) => { + setBotState((prev) => (prev ? { ...prev, orders } : prev)); + }); + + socket.on('history_update', (trade: BotState['history'][number]) => { + setBotState((prev) => { + if (!prev) return prev; + return { + ...prev, + history: [trade, ...prev.history].slice(0, 100), + }; + }); + }); + + socket.on('settings_update', (settings: BotState['settings']) => { + setBotState((prev) => (prev ? { ...prev, settings } : prev)); + }); + + socket.on('account_snapshot', () => { + void fetchState(); + }); + + socket.on('order_failure', () => { + void fetchState(); + }); + + socket.on('operational_event', () => { + void fetchState(); + }); + + return () => { + socket?.close(); + }; + }, [accessToken, user, fetchState]); + const postTradingAction = useCallback( async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => { if (!accessToken) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4144f5..aaf3079 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,9 @@ importers: react-native-webview: specifier: 13.15.0 version: 13.15.0(react-native@0.81.4(@babel/core@7.29.0)(@react-native/metro-config@0.84.1(@babel/core@7.29.0))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) + socket.io-client: + specifier: ^4.8.3 + version: 4.8.3 devDependencies: '@babel/core': specifier: ^7.25.2 diff --git a/shared/supabase-config.ts b/shared/supabase-config.ts new file mode 100644 index 0000000..555190f --- /dev/null +++ b/shared/supabase-config.ts @@ -0,0 +1,54 @@ +export interface SupabaseRuntimeConfig { + url: string; + anonKey: string; + isConfigured: boolean; +} + +const fallbackSupabaseUrl = 'https://placeholder.bytilyst.local'; +const fallbackSupabaseAnonKey = 'placeholder-anon-key'; + +function pickConfiguredValue(...values: Array): string | undefined { + return values.find((value) => typeof value === 'string' && value.trim().length > 0)?.trim(); +} + +export function getWebSupabaseConfig(): SupabaseRuntimeConfig { + const url = pickConfiguredValue( + process.env.NEXT_PUBLIC_SUPABASE_URL, + process.env.VITE_SUPABASE_URL, + process.env.VITE_PLATFORM_SUPABASE_URL, + process.env.VITE_PUBLIC_SUPABASE_URL + ); + + const anonKey = pickConfiguredValue( + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + process.env.VITE_SUPABASE_ANON_KEY, + process.env.VITE_PLATFORM_SUPABASE_ANON_KEY, + process.env.VITE_PUBLIC_SUPABASE_ANON_KEY + ); + + return { + url: url || fallbackSupabaseUrl, + anonKey: anonKey || fallbackSupabaseAnonKey, + isConfigured: Boolean(url && anonKey), + }; +} + +export function getMobileSupabaseConfig(): SupabaseRuntimeConfig { + const url = pickConfiguredValue( + process.env.EXPO_PUBLIC_SUPABASE_URL, + process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_URL, + process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_URL + ); + + const anonKey = pickConfiguredValue( + process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY, + process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_ANON_KEY, + process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_ANON_KEY + ); + + return { + url: url || fallbackSupabaseUrl, + anonKey: anonKey || fallbackSupabaseAnonKey, + isConfigured: Boolean(url && anonKey), + }; +} diff --git a/web/src/lib/supabaseClient.ts b/web/src/lib/supabaseClient.ts index 993dc72..c69cc3c 100644 --- a/web/src/lib/supabaseClient.ts +++ b/web/src/lib/supabaseClient.ts @@ -1,21 +1,13 @@ import { createClient } from '@supabase/supabase-js'; +import { getWebSupabaseConfig } from '../../../shared/supabase-config.js'; -const supabaseUrl = - import.meta.env.VITE_SUPABASE_URL || - import.meta.env.VITE_PLATFORM_SUPABASE_URL || - import.meta.env.VITE_PUBLIC_SUPABASE_URL; -const supabaseAnonKey = - import.meta.env.VITE_SUPABASE_ANON_KEY || - import.meta.env.VITE_PLATFORM_SUPABASE_ANON_KEY || - import.meta.env.VITE_PUBLIC_SUPABASE_ANON_KEY; -const fallbackSupabaseUrl = 'https://placeholder.bytilyst.local'; -const fallbackSupabaseAnonKey = 'placeholder-anon-key'; +const supabaseConfig = getWebSupabaseConfig(); -if (!supabaseUrl || !supabaseAnonKey) { +if (!supabaseConfig.isConfigured) { console.warn('Missing Supabase environment variables'); } export const supabase = createClient( - supabaseUrl || fallbackSupabaseUrl, - supabaseAnonKey || fallbackSupabaseAnonKey + supabaseConfig.url, + supabaseConfig.anonKey );