diff --git a/packages/rn-tester/js/examples/WebSocket/BinanceWebSocketDemo.js b/packages/rn-tester/js/examples/WebSocket/BinanceWebSocketDemo.js new file mode 100644 index 000000000000..b0bdaa819dae --- /dev/null +++ b/packages/rn-tester/js/examples/WebSocket/BinanceWebSocketDemo.js @@ -0,0 +1,340 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import RNTesterText from '../../components/RNTesterText'; +import * as React from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; +import {FlatList, Pressable, StyleSheet, View} from 'react-native'; + +const STREAM_URL = 'wss://stream.binance.com:9443/ws'; +const MAX_LOG_ENTRIES = 30; + +type TradeSymbol = 'btcusdt' | 'ethusdt' | 'solusdt'; +type ConnectionState = 'idle' | 'connecting' | 'open' | 'closed'; +type FrameLogEntry = Readonly<{ + id: string, + direction: 'sent' | 'received', + time: string, + summary: string, +}>; + +const SYMBOLS: ReadonlyArray = ['btcusdt', 'ethusdt', 'solusdt']; + +const STATE_COLOR = { + idle: '#999999', + connecting: '#e6a700', + open: '#2e7d32', + closed: '#c62828', +}; + +function nowTime(): string { + const d = new Date(); + const pad = (n: number): string => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +component BinanceWebSocketDemo() { + const [state, setState] = useState('idle'); + const [symbol, setSymbol] = useState('btcusdt'); + const [price, setPrice] = useState(null); + const [log, setLog] = useState>([]); + const wsRef = useRef(null); + const idRef = useRef(0); + + const appendLog = useCallback( + (direction: 'sent' | 'received', summary: string) => { + idRef.current += 1; + const entry: FrameLogEntry = { + id: String(idRef.current), + direction, + time: nowTime(), + summary, + }; + setLog(prev => [entry, ...prev].slice(0, MAX_LOG_ENTRIES)); + }, + [], + ); + + const send = useCallback( + (method: 'SUBSCRIBE' | 'UNSUBSCRIBE', target: TradeSymbol) => { + const ws = wsRef.current; + if (ws == null || ws.readyState !== WebSocket.OPEN) { + return; + } + idRef.current += 1; + ws.send( + JSON.stringify({ + method, + params: [`${target}@trade`], + id: idRef.current, + }), + ); + appendLog('sent', `${method} ${target}@trade`); + }, + [appendLog], + ); + + const connect = useCallback(() => { + if (wsRef.current != null) { + return; + } + setState('connecting'); + setLog([]); + const ws = new WebSocket(STREAM_URL); + wsRef.current = ws; + + ws.onopen = () => { + setState('open'); + send('SUBSCRIBE', symbol); + }; + + ws.onmessage = event => { + try { + const data: {e?: string, s?: string, p?: string} = JSON.parse( + String(event.data), + ); + const tradePrice = data.p; + if (data.e === 'trade' && tradePrice != null) { + setPrice(tradePrice); + appendLog('received', `trade ${data.s ?? ''} @ ${tradePrice}`); + } else { + appendLog('received', String(event.data).slice(0, 80)); + } + } catch (error: unknown) { + appendLog('received', String(event.data).slice(0, 80)); + } + }; + + ws.onerror = () => { + appendLog('received', 'error event'); + }; + + ws.onclose = () => { + setState('closed'); + wsRef.current = null; + }; + }, [appendLog, send, symbol]); + + const disconnect = useCallback(() => { + wsRef.current?.close(); + }, []); + + const changeSymbol = useCallback( + (next: TradeSymbol) => { + if (next === symbol) { + return; + } + send('UNSUBSCRIBE', symbol); + send('SUBSCRIBE', next); + setSymbol(next); + setPrice(null); + }, + [send, symbol], + ); + + useEffect(() => { + return () => { + wsRef.current?.close(); + }; + }, []); + + const busy = state === 'connecting' || state === 'open'; + + return ( + + + + + {state.toUpperCase()} + + + + + {symbol.toUpperCase()} + + + {price != null ? `$${Number(price).toFixed(6)}` : '—'} + + + + {SYMBOLS.map(s => ( + changeSymbol(s)} + style={[ + styles.symbolButton, + s === symbol && styles.symbolButtonActive, + ]}> + + {s.replace('usdt', '').toUpperCase()} + + + ))} + + + + + Connect + + + + Disconnect + + + + + + Frame log ({log.length}) + + item.id} + renderItem={({item}) => ( + + + {item.direction === 'sent' ? '↑ ' : '↓ '} + + {item.time} — {item.summary} + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 16, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + statusDot: { + width: 10, + height: 10, + borderRadius: 5, + marginRight: 6, + }, + statusText: { + fontSize: 12, + fontWeight: '600', + letterSpacing: 0.5, + }, + symbolLabel: { + fontSize: 14, + }, + price: { + fontSize: 40, + fontWeight: '700', + marginBottom: 16, + }, + symbolRow: { + flexDirection: 'row', + marginBottom: 16, + }, + symbolButton: { + paddingVertical: 6, + paddingHorizontal: 14, + borderRadius: 16, + borderWidth: 1, + borderColor: '#cccccc', + marginRight: 8, + }, + symbolButtonActive: { + backgroundColor: '#1e88e5', + borderColor: '#1e88e5', + }, + symbolButtonText: { + fontSize: 13, + }, + symbolButtonTextActive: { + color: '#ffffff', + fontWeight: '600', + }, + controlsRow: { + flexDirection: 'row', + marginBottom: 16, + }, + controlButton: { + paddingVertical: 8, + paddingHorizontal: 12, + backgroundColor: '#f0f0f0', + borderRadius: 6, + marginRight: 8, + }, + controlButtonDisabled: { + opacity: 0.5, + }, + controlButtonText: { + fontSize: 13, + fontWeight: '600', + color: '#333333', + }, + logHeader: { + fontSize: 13, + fontWeight: '600', + marginBottom: 4, + }, + log: { + flex: 1, + backgroundColor: '#fafafa', + borderRadius: 6, + padding: 8, + }, + logLine: { + fontSize: 12, + fontFamily: 'Menlo', + marginBottom: 4, + color: '#333333', + }, + sent: { + color: '#1e88e5', + fontWeight: '700', + }, + received: { + color: '#2e7d32', + fontWeight: '700', + }, +}); + +exports.title = 'WebSocket (Binance demo)'; +exports.category = 'Basic'; +exports.description = + 'Live Binance trade ticker over a public WebSocket stream.'; +exports.examples = [ + { + title: 'Binance live trade ticker', + render(): React.Node { + return ; + }, + }, +] as Array; diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index e3c5005ea002..c58e328c6dc2 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -356,6 +356,11 @@ const APIs: Array = ( category: 'Basic', module: require('../examples/Vibration/VibrationExample'), }, + { + key: 'BinanceWebSocketDemo', + category: 'Basic', + module: require('../examples/WebSocket/BinanceWebSocketDemo'), + }, { key: 'WebSocketExample', category: 'Basic', diff --git a/packages/rn-tester/js/utils/RNTesterList.ios.js b/packages/rn-tester/js/utils/RNTesterList.ios.js index e3260a576dc3..9d6fac392fc1 100644 --- a/packages/rn-tester/js/utils/RNTesterList.ios.js +++ b/packages/rn-tester/js/utils/RNTesterList.ios.js @@ -344,6 +344,10 @@ const APIs: Array = ( key: 'VibrationExample', module: require('../examples/Vibration/VibrationExample'), }, + { + key: 'BinanceWebSocketDemo', + module: require('../examples/WebSocket/BinanceWebSocketDemo'), + }, { key: 'WebSocketExample', module: require('../examples/WebSocket/WebSocketExample'),