From 366cbf53f8348cdf80179c72533e10de038b2804 Mon Sep 17 00:00:00 2001 From: Shashwat Agrawal Date: Sat, 11 Jul 2026 15:28:01 +0530 Subject: [PATCH] feat(frontend): add WSS client for login and market snapshots Wire the React app to the backend secure WebSocket API: connect, login, subscribe to market/snapshot, and render live bid/ask levels. Closes #4 #5. --- frontend/README.md | 81 ++---- frontend/index.html | 2 +- frontend/src/App.tsx | 218 +++++++++++++++- frontend/src/hooks/useExchangeSocket.ts | 184 ++++++++++++++ frontend/src/index.css | 315 ++++++++++++++++++++---- frontend/src/types.ts | 30 +++ 6 files changed, 717 insertions(+), 113 deletions(-) create mode 100644 frontend/src/hooks/useExchangeSocket.ts create mode 100644 frontend/src/types.ts diff --git a/frontend/README.md b/frontend/README.md index 7dbf7eb..26174d6 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,22 @@ -# React + TypeScript + Vite +# Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React + TypeScript client for the exchange WebSocket API. -Currently, two official plugins are available: +## Dev server -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm install +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +Open the URL Vite prints (usually `http://localhost:5173`). -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## Connecting to the backend -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +1. Start the C++ backend on port `7000` with SSL certs (see `backend/README.md`). +2. In the UI, connect to `wss://localhost:7000`. +3. Log in with a demo user (`shashwat` / `shashwat` or `nero` / `nero`). +4. Subscribe to `market/snapshot` to stream live bid/ask levels. + +Browsers reject self-signed certificates by default. Trust `backend/certs/cert.pem` +locally, or accept the certificate warning before the WebSocket handshake succeeds. diff --git a/frontend/index.html b/frontend/index.html index 0fca6f0..48c3de0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + Exchange
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 339363b..7b2c9bf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,217 @@ -function App() { - return

Hello world!

; +import { useState, type FormEvent } from 'react'; +import { useExchangeSocket } from './hooks/useExchangeSocket'; +import { DEFAULT_WS_URL, type BookLevels } from './types'; + +const LEVELS = [ + 'level_1', + 'level_2', + 'level_3', + 'level_4', + 'level_5', +] as const; + +function formatPrice(n: number): string { + return n.toFixed(6); } -export default App; +function BookSide({ + title, + levels, + side, +}: { + title: string; + levels: BookLevels | undefined; + side: 'ask' | 'bid'; +}) { + return ( +
+

{title}

+ + + + + + + + + {LEVELS.map((key, i) => ( + + + + + ))} + +
LevelPrice
{i + 1} + {levels ? formatPrice(levels[key]) : '—'} +
+
+ ); +} + +function statusLabel(status: string): string { + return status.replaceAll('_', ' '); +} + +export default function App() { + const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL); + const [user, setUser] = useState('shashwat'); + const [password, setPassword] = useState('shashwat'); + + const { + connectionStatus, + authStatus, + username, + snapshot, + subscribed, + lastError, + log, + connect, + disconnect, + login, + subscribe, + unsubscribe, + } = useExchangeSocket(wsUrl); + + const onLogin = (e: FormEvent) => { + e.preventDefault(); + login(user, password); + }; + + const connected = connectionStatus === 'connected'; + const authenticated = authStatus === 'authenticated'; + + return ( +
+
+
+

Exchange

+

Live market data over WSS

+
+
+ + {statusLabel(connectionStatus)} + + + {statusLabel(authStatus)} + {username ? ` · ${username}` : ''} + +
+
+ + {lastError &&
{lastError}
} + +
+

Connection

+
+ + {!connected ? ( + + ) : ( + + )} +
+

+ Backend listens on wss://localhost:7000. Self-signed certs + must be trusted by the browser (import backend/certs/cert.pem + or accept the certificate warning). +

+
+ +
+

Login

+
+ + + +
+

+ Demo users: shashwat / shashwat, nero / nero +

+
+ +
+
+

Market Snapshot

+
+ + +
+
+ +
+ + +
+ {!subscribed && authenticated && ( +

Subscribe to receive live snapshots every second.

+ )} +
+ +
+

Event Log

+
    + {log.length === 0 &&
  • No events yet
  • } + {log.map((line, i) => ( +
  • + {line} +
  • + ))} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useExchangeSocket.ts b/frontend/src/hooks/useExchangeSocket.ts new file mode 100644 index 0000000..448de7c --- /dev/null +++ b/frontend/src/hooks/useExchangeSocket.ts @@ -0,0 +1,184 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + DEFAULT_WS_URL, + MARKET_CHANNEL, + type AuthStatus, + type ConnectionStatus, + type MarketSnapshot, + type ServerMessage, +} from '../types'; + +function isMarketSnapshot(msg: ServerMessage): msg is MarketSnapshot { + return ( + typeof msg === 'object' && + msg !== null && + 'ask' in msg && + 'bid' in msg && + !('type' in msg) + ); +} + +export function useExchangeSocket(url: string = DEFAULT_WS_URL) { + const wsRef = useRef(null); + const [connectionStatus, setConnectionStatus] = + useState('disconnected'); + const [authStatus, setAuthStatus] = useState('logged_out'); + const [username, setUsername] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [subscribed, setSubscribed] = useState(false); + const [lastError, setLastError] = useState(null); + const [log, setLog] = useState([]); + + const appendLog = useCallback((line: string) => { + setLog((prev) => [line, ...prev].slice(0, 40)); + }, []); + + const send = useCallback((payload: Record) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + setLastError('Socket is not connected'); + return false; + } + ws.send(JSON.stringify(payload)); + return true; + }, []); + + const disconnect = useCallback(() => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + setConnectionStatus('disconnected'); + setAuthStatus('logged_out'); + setUsername(null); + setSnapshot(null); + setSubscribed(false); + }, []); + + const connect = useCallback(() => { + if ( + wsRef.current && + (wsRef.current.readyState === WebSocket.OPEN || + wsRef.current.readyState === WebSocket.CONNECTING) + ) { + return; + } + + setConnectionStatus('connecting'); + setLastError(null); + appendLog(`Connecting to ${url}…`); + + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + setConnectionStatus('connected'); + appendLog('Connected'); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(String(event.data)) as ServerMessage; + + if (isMarketSnapshot(msg)) { + setSnapshot(msg); + return; + } + + if (!('type' in msg)) { + return; + } + + switch (msg.type) { + case 'login_response': + if (msg.status === 'success' || msg.status === 'already_logged_in') { + setAuthStatus('authenticated'); + appendLog(`Login ${msg.status}`); + } else { + setAuthStatus('failed'); + setLastError('Login failed — check credentials'); + appendLog('Login failed'); + } + break; + case 'subscribe_response': + setSubscribed(true); + appendLog(`Subscribed to ${msg.channel}`); + break; + case 'unsubscribe_response': + setSubscribed(false); + setSnapshot(null); + appendLog(`Unsubscribed from ${msg.channel}`); + break; + case 'error': + setLastError(msg.message); + appendLog(`Error: ${msg.message}`); + break; + default: + appendLog(`Message: ${JSON.stringify(msg)}`); + } + } catch { + appendLog(`Non-JSON message: ${String(event.data)}`); + } + }; + + ws.onerror = () => { + setConnectionStatus('error'); + setLastError( + 'WebSocket error. For self-signed certs, trust certs/cert.pem or open the backend URL once in the browser and accept the warning.', + ); + appendLog('WebSocket error'); + }; + + ws.onclose = () => { + setConnectionStatus('disconnected'); + setAuthStatus('logged_out'); + setUsername(null); + setSubscribed(false); + wsRef.current = null; + appendLog('Disconnected'); + }; + }, [appendLog, url]); + + const login = useCallback( + (user: string, password: string) => { + setAuthStatus('logging_in'); + setUsername(user); + setLastError(null); + const ok = send({ type: 'login', username: user, password }); + if (!ok) { + setAuthStatus('logged_out'); + setUsername(null); + } + }, + [send], + ); + + const subscribe = useCallback(() => { + send({ type: 'subscribe', channel: MARKET_CHANNEL }); + }, [send]); + + const unsubscribe = useCallback(() => { + send({ type: 'unsubscribe', channel: MARKET_CHANNEL }); + }, [send]); + + useEffect(() => { + return () => { + wsRef.current?.close(); + }; + }, []); + + return { + connectionStatus, + authStatus, + username, + snapshot, + subscribed, + lastError, + log, + connect, + disconnect, + login, + subscribe, + unsubscribe, + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 4989b25..c069a83 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,64 +1,293 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #0f1115; + --panel: #161a22; + --border: #2a2f3a; + --accent: #7c5cff; + --accent-hover: #9178ff; + --bid: #34d399; + --ask: #f87171; + --error-bg: rgba(248, 113, 113, 0.12); + --error-border: rgba(248, 113, 113, 0.4); + --muted: #6b7280; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; + --mono: ui-monospace, 'Cascadia Code', Consolas, monospace; - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; + color-scheme: dark; + font: 15px/1.45 var(--sans); color: var(--text); background: var(--bg); font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } +* { + box-sizing: border-box; +} +body { + margin: 0; + min-height: 100svh; } #root { - width: 1126px; - max-width: 100%; + max-width: 960px; margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; + padding: 1.5rem 1.25rem 3rem; +} + +h1, +h2, +h3 { + color: var(--text-h); + margin: 0; + font-weight: 600; +} + +h1 { + font-size: 1.5rem; + letter-spacing: -0.02em; +} + +h2 { + font-size: 1.05rem; + margin-bottom: 0.85rem; +} + +h3 { + font-size: 0.95rem; + margin-bottom: 0.5rem; +} + +.subtitle { + margin: 0.2rem 0 0; + color: var(--muted); + font-size: 0.9rem; +} + +.app { display: flex; flex-direction: column; - box-sizing: border-box; + gap: 1rem; } -body { +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.status-pills { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.pill { + border: 1px solid var(--border); + background: var(--panel); + border-radius: 999px; + padding: 0.3rem 0.75rem; + font-size: 0.8rem; + text-transform: capitalize; +} + +.pill--connected, +.pill--authenticated { + border-color: color-mix(in srgb, var(--bid) 50%, transparent); + color: var(--bid); +} + +.pill--connecting, +.pill--logging_in { + border-color: color-mix(in srgb, #fbbf24 50%, transparent); + color: #fbbf24; +} + +.pill--error, +.pill--failed, +.pill--disconnected, +.pill--logged_out { + color: var(--muted); +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem 1.1rem 1.15rem; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.75rem; +} + +.row.between { + justify-content: space-between; + align-items: center; +} + +.row.gap { + gap: 0.5rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 10rem; +} + +.field.grow { + flex: 1; + min-width: 16rem; +} + +.field span { + font-size: 0.78rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +input { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-h); + padding: 0.55rem 0.7rem; + font: inherit; +} + +input:focus { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 1px; +} + +input:disabled { + opacity: 0.55; +} + +.btn { + border: 1px solid var(--border); + background: transparent; + color: var(--text-h); + border-radius: 8px; + padding: 0.55rem 0.95rem; + font: inherit; + cursor: pointer; + white-space: nowrap; +} + +.btn:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--text-h) 30%, var(--border)); +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn--primary { + background: var(--accent); + border-color: transparent; + color: white; +} + +.btn--primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.hint { + margin: 0.75rem 0 0; + color: var(--muted); + font-size: 0.85rem; +} + +code, +.mono { + font-family: var(--mono); + font-size: 0.88em; +} + +.banner { + border-radius: 10px; + padding: 0.75rem 1rem; + font-size: 0.9rem; +} + +.banner--error { + background: var(--error-bg); + border: 1px solid var(--error-border); + color: #fecaca; +} + +.book { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +@media (max-width: 640px) { + .book { + grid-template-columns: 1fr; + } +} + +.book-side table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.book-side th, +.book-side td { + text-align: left; + padding: 0.35rem 0.25rem; + border-bottom: 1px solid var(--border); +} + +.book-side th { + color: var(--muted); + font-weight: 500; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.book-side--bid td.mono { + color: var(--bid); +} + +.book-side--ask td.mono { + color: var(--ask); +} + +.log { + list-style: none; margin: 0; + padding: 0; + max-height: 12rem; + overflow: auto; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.log li { + padding: 0.25rem 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + font-size: 0.82rem; + color: var(--text); +} + +.muted { + color: var(--muted); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..ac8e9d7 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,30 @@ +export type ConnectionStatus = + | 'disconnected' + | 'connecting' + | 'connected' + | 'error'; + +export type AuthStatus = 'logged_out' | 'logging_in' | 'authenticated' | 'failed'; + +export type BookLevels = { + level_1: number; + level_2: number; + level_3: number; + level_4: number; + level_5: number; +}; + +export type MarketSnapshot = { + ask: BookLevels; + bid: BookLevels; +}; + +export type ServerMessage = + | { type: 'login_response'; status: 'success' | 'failed' | 'already_logged_in' } + | { type: 'subscribe_response'; status: string; channel: string } + | { type: 'unsubscribe_response'; status: string; channel: string } + | { type: 'error'; message: string } + | MarketSnapshot; + +export const MARKET_CHANNEL = 'market/snapshot'; +export const DEFAULT_WS_URL = 'wss://localhost:7000';