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] 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] 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] 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
|
- [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, but not yet for all auth/session internals
|
- [-] 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
|
- [!] 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
|
## 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
|
- [x] Implement launch-time kill-switch and maintenance handling
|
||||||
- [ ] Add telemetry startup and error capture
|
- [ ] Add telemetry startup and error capture
|
||||||
- [x] Define initial mobile scope
|
- [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
|
- [ ] Add push-notification-ready architecture
|
||||||
- [x] Define mobile action policy for monitor-first versus control-first flows
|
- [x] Define mobile action policy for monitor-first versus control-first flows
|
||||||
- [x] Define alert and incident UX
|
- [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] Positions
|
||||||
- [x] Recent history
|
- [x] Recent history
|
||||||
- [x] Settings and sign out
|
- [x] Settings and sign out
|
||||||
|
- [x] Live state refresh via websocket with polling fallback
|
||||||
- [-] Safe operator controls limited to explicitly approved actions
|
- [-] Safe operator controls limited to explicitly approved actions
|
||||||
- [x] Maintain monitor-first, but not monitor-only scope
|
- [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 Expo structure
|
||||||
- [ ] Define navigation shell
|
- [ ] Define navigation shell
|
||||||
- [ ] Define auth bootstrap and secure storage
|
- [ ] Define auth bootstrap and secure storage
|
||||||
- [ ] Define status polling/live update strategy
|
- [x] Define status polling/live update strategy
|
||||||
- [ ] Define alert/incident UX
|
- [ ] Define alert/incident UX
|
||||||
- [ ] Define operator-safe interventions
|
- [ ] Define operator-safe interventions
|
||||||
- [ ] Define offline and degraded-state behavior
|
- [ ] Define offline and degraded-state behavior
|
||||||
|
|||||||
@ -1,26 +1,16 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { getMobileSupabaseConfig } from '../../shared/supabase-config.js';
|
||||||
|
|
||||||
const supabaseUrl =
|
const supabaseConfig = getMobileSupabaseConfig();
|
||||||
process.env.EXPO_PUBLIC_SUPABASE_URL ||
|
|
||||||
process.env.EXPO_PUBLIC_PLATFORM_SUPABASE_URL ||
|
|
||||||
process.env.EXPO_PUBLIC_PUBLIC_SUPABASE_URL;
|
|
||||||
|
|
||||||
const supabaseAnonKey =
|
if (!supabaseConfig.isConfigured) {
|
||||||
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) {
|
|
||||||
console.warn('[mobile] Missing Supabase environment variables');
|
console.warn('[mobile] Missing Supabase environment variables');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mobileSupabase = createClient(
|
export const mobileSupabase = createClient(
|
||||||
supabaseUrl || fallbackSupabaseUrl,
|
supabaseConfig.url,
|
||||||
supabaseAnonKey || fallbackSupabaseAnonKey,
|
supabaseConfig.anonKey,
|
||||||
{
|
{
|
||||||
auth: {
|
auth: {
|
||||||
storage: AsyncStorage,
|
storage: AsyncStorage,
|
||||||
|
|||||||
@ -45,7 +45,8 @@
|
|||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-web": "^0.21.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": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import { mobileRuntime } from '@/lib/runtime';
|
import { mobileRuntime } from '@/lib/runtime';
|
||||||
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
import { useMobileAuth } from '@/providers/MobileAuthProvider';
|
||||||
|
|
||||||
@ -121,6 +122,7 @@ interface TradingDataContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
|
const TradingDataContext = createContext<TradingDataContextValue | null>(null);
|
||||||
|
const tradingSocketUrl = mobileRuntime.tradingApiUrl.replace(/\/api$/, '');
|
||||||
|
|
||||||
const EMPTY_STATE: TradingPortfolioSummary = {
|
const EMPTY_STATE: TradingPortfolioSummary = {
|
||||||
netPnl: 0,
|
netPnl: 0,
|
||||||
@ -179,10 +181,113 @@ export function TradingDataProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
void fetchState();
|
void fetchState();
|
||||||
}, 15000);
|
}, 60000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [accessToken, user, fetchState]);
|
}, [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(
|
const postTradingAction = useCallback(
|
||||||
async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => {
|
async (path: '/internal/trading/pause' | '/internal/trading/resume', reason?: string) => {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -190,6 +190,9 @@ importers:
|
|||||||
react-native-webview:
|
react-native-webview:
|
||||||
specifier: 13.15.0
|
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)
|
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:
|
devDependencies:
|
||||||
'@babel/core':
|
'@babel/core':
|
||||||
specifier: ^7.25.2
|
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 { createClient } from '@supabase/supabase-js';
|
||||||
|
import { getWebSupabaseConfig } from '../../../shared/supabase-config.js';
|
||||||
|
|
||||||
const supabaseUrl =
|
const supabaseConfig = getWebSupabaseConfig();
|
||||||
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';
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey) {
|
if (!supabaseConfig.isConfigured) {
|
||||||
console.warn('Missing Supabase environment variables');
|
console.warn('Missing Supabase environment variables');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const supabase = createClient(
|
export const supabase = createClient(
|
||||||
supabaseUrl || fallbackSupabaseUrl,
|
supabaseConfig.url,
|
||||||
supabaseAnonKey || fallbackSupabaseAnonKey
|
supabaseConfig.anonKey
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user