feat: add mobile websocket sync

This commit is contained in:
Saravana Achu Mac 2026-04-04 11:39:47 -07:00
parent 0d9654e742
commit c9aadfae8e
7 changed files with 180 additions and 34 deletions

View File

@ -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

View File

@ -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,

View File

@ -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",

View File

@ -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<TradingDataContextValue | null>(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) {

3
pnpm-lock.yaml generated
View File

@ -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

54
shared/supabase-config.ts Normal file
View File

@ -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>): 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),
};
}

View File

@ -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
);