feat: add mobile websocket sync
This commit is contained in:
parent
0d9654e742
commit
c9aadfae8e
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
54
shared/supabase-config.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user