diff --git a/README.md b/README.md index 471f2b0..29d1396 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # React Native API Debugger -A network request debugging tool for React Native applications. Monitor, inspect, and debug HTTP requests with a draggable overlay interface. +A comprehensive network debugging tool for React Native applications. Monitor, inspect, and debug HTTP requests and WebSocket connections with a unified tabbed overlay interface. [![npm version](https://img.shields.io/npm/v/react-native-api-debugger)](https://www.npmjs.com/package/react-native-api-debugger) [![downloads](https://img.shields.io/npm/dm/react-native-api-debugger)](https://www.npmjs.com/package/react-native-api-debugger) @@ -10,16 +10,29 @@ A network request debugging tool for React Native applications. Monitor, inspect ## Features +### HTTP Debugging - **Network Interception** - Automatically captures all `fetch()` and `XMLHttpRequest` calls -- **Draggable Overlay** - Floating button that can be positioned anywhere on screen - **Request Details** - View headers, body, response, timing, and status codes - **cURL Export** - Copy requests as cURL commands - **Advanced Filtering** - Filter by status code (2xx, 3xx, 4xx, 5xx), search by URL/method/body - **Export Logs** - Export to HAR, Postman Collection, or JSON formats - **Request Replay** - Re-execute captured requests with optional modifications -- **Dark/Light Theme** - Toggle between themes - **Slow Request Detection** - Visual indicator for requests exceeding threshold - **Sensitive Data Redaction** - Detect and mask sensitive headers and body fields + +### WebSocket Debugging +- **WebSocket Interception** - Automatically captures all WebSocket connections +- **Connection Monitoring** - Track connection state (connecting, open, closing, closed) +- **Message Logging** - View sent and received messages with timestamps +- **Message Filtering** - Filter by direction (sent/received) or search by content +- **Binary Support** - Handles text and binary message types +- **Connection Metrics** - Track message counts, bytes transferred, and connection duration +- **Live Indicators** - Visual indicators for active connections + +### General +- **Unified Interface** - Tabbed modal with HTTP and WebSocket views +- **Draggable Overlay** - Floating button that can be positioned anywhere on screen +- **Dark/Light Theme** - Toggle between themes - **Individual Log Deletion** - Remove specific entries without clearing all logs - **Device Shake Support** - Shake to show/hide the debugger - **TypeScript Support** - Full type definitions included @@ -67,7 +80,7 @@ npm install react-native-svg ## Quick Setup -### 1. Basic Usage (No Dependencies) +### 1. Basic Usage (HTTP Only) ```tsx import React, { useEffect } from 'react'; @@ -94,17 +107,59 @@ export default function App() { } ``` -### 2. With Draggable Button +### 2. Full Setup (HTTP + WebSocket) + +Use `NetworkLoggerOverlay` with `enableWebSocket={true}` for the unified tabbed interface. + +```tsx +import React, { useEffect } from 'react'; +import { View } from 'react-native'; +import { + networkLogger, + webSocketLogger, + NetworkLoggerOverlay +} from 'react-native-api-debugger'; + +export default function App() { + useEffect(() => { + // Setup HTTP interceptor + networkLogger.setupInterceptor(); + + // Setup WebSocket interceptor + webSocketLogger.setupInterceptor(); + }, []); + + return ( + + {/* Your app content */} + + + + ); +} +``` + +### 3. With Draggable Button Requires `react-native-gesture-handler` and `react-native-reanimated`. ```tsx import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { networkLogger, NetworkLoggerOverlay } from 'react-native-api-debugger'; +import { + networkLogger, + webSocketLogger, + NetworkLoggerOverlay +} from 'react-native-api-debugger'; export default function App() { useEffect(() => { networkLogger.setupInterceptor(); + webSocketLogger.setupInterceptor(); }, []); return ( @@ -113,6 +168,8 @@ export default function App() { @@ -120,17 +177,19 @@ export default function App() { } ``` -### 3. Full Featured Setup +### 4. Full Featured Setup ```tsx console.log('Theme:', theme)} /> ``` @@ -197,11 +256,18 @@ const config = networkLogger.getConfig(); ```tsx useEffect(() => { - const unsubscribe = networkLogger.subscribe((logs) => { - console.log('Logs updated:', logs.length); + const unsubscribeHttp = networkLogger.subscribe((logs) => { + console.log('HTTP logs updated:', logs.length); + }); + + const unsubscribeWs = webSocketLogger.subscribe((logs) => { + console.log('WebSocket logs updated:', logs.length); }); - return () => unsubscribe(); + return () => { + unsubscribeHttp(); + unsubscribeWs(); + }; }, []); ``` @@ -273,16 +339,62 @@ const curlCommand = generateCurl(log); // curl -X POST 'https://api.example.com/users' -H 'Content-Type: application/json' -d '{"name":"John"}' ``` +### WebSocket Logger Methods + +```tsx +import { webSocketLogger } from 'react-native-api-debugger'; + +// Initialize interception +webSocketLogger.setupInterceptor(); + +// Configure options +webSocketLogger.configure({ + maxConnections: 50, // Maximum connections to store + captureMessages: true, // Log individual messages + maxMessagesPerConnection: 100, // Max messages per connection + ignoredUrls: ['*/health'], // URL patterns to ignore +}); + +// Get all logs +const logs = webSocketLogger.getLogs(); + +// Get log count +const count = webSocketLogger.getLogCount(); + +// Clear all logs +webSocketLogger.clearLogs(); + +// Delete a specific log +webSocketLogger.deleteLog(logId); + +// Close an active connection +webSocketLogger.closeConnection(logId); + +// Enable/disable logging +webSocketLogger.enable(); +webSocketLogger.disable(); + +// Get active connections count +const activeCount = webSocketLogger.getActiveConnectionsCount(); + +// Restore original WebSocket (remove interception) +webSocketLogger.restoreInterceptor(); +``` + --- ## API Reference ### NetworkLoggerOverlay Props +The unified overlay component with HTTP and optional WebSocket tabs. + | Prop | Type | Default | Description | |------|------|---------|-------------| -| `networkLogger` | `NetworkLogger` | Required | The logger instance | +| `networkLogger` | `NetworkLogger` | Required | HTTP logger instance | +| `webSocketLogger` | `WebSocketLogger` | - | WebSocket logger instance (required if `enableWebSocket` is true) | | `enabled` | `boolean` | `__DEV__` | Enable/disable the overlay | +| `enableWebSocket` | `boolean` | `false` | Enable WebSocket debugging tab | | `draggable` | `boolean` | `false` | Enable draggable button | | `enableDeviceShake` | `boolean` | `false` | Show on device shake | | `useCopyToClipboard` | `boolean` | `false` | Enable clipboard copy | @@ -328,6 +440,46 @@ interface NetworkLog { } ``` +### WebSocketLog Type + +```typescript +interface WebSocketLog { + id: number; + url: string; + state: 'connecting' | 'open' | 'closing' | 'closed'; + protocols?: string[]; + connectTime: string; + openTime?: string; + closeTime?: string; + closeCode?: number; + closeReason?: string; + error?: string; + handshakeDuration?: number; + messages: WebSocketMessage[]; + messageCount: { sent: number; received: number }; + bytesReceived: number; + bytesSent: number; +} + +interface WebSocketMessage { + id: number; + direction: 'sent' | 'received'; + data: string; + dataType: 'text' | 'binary'; + timestamp: string; + size: number; +} +``` + +### WebSocketLoggerConfig + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxConnections` | `number` | `100` | Maximum connections to store | +| `captureMessages` | `boolean` | `true` | Log individual messages | +| `maxMessagesPerConnection` | `number` | `100` | Max messages per connection | +| `ignoredUrls` | `string[]` | `[]` | URL patterns to ignore | + --- ## Production Safety @@ -383,9 +535,35 @@ Make sure `setupInterceptor()` is called before any network requests: ```tsx useEffect(() => { networkLogger.setupInterceptor(); + webSocketLogger.setupInterceptor(); }, []); // Empty dependency array - runs once on mount ``` +### WebSocket connections not appearing + +Ensure you have: +1. Called `webSocketLogger.setupInterceptor()` before any WebSocket connections +2. Set `enableWebSocket={true}` on the NetworkLoggerOverlay +3. Passed the `webSocketLogger` prop + +```tsx + +``` + +### Setup error messages + +The debugger will throw helpful error messages if interceptors aren't initialized: + +- **HTTP**: If `networkLogger.setupInterceptor()` wasn't called before rendering +- **WebSocket**: If `enableWebSocket={true}` but `webSocketLogger.setupInterceptor()` wasn't called + +These errors include code examples to help you fix the issue quickly. + --- ## Contributing diff --git a/example/src/App.tsx b/example/src/App.tsx index eee236c..de1b266 100755 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,78 +1,22 @@ import React, { useEffect, useState } from 'react'; +import { StyleSheet, SafeAreaView, View } from 'react-native'; import { - View, - StyleSheet, - Text, - TouchableOpacity, - SafeAreaView, - ScrollView, - Alert, - ActivityIndicator, - Switch, -} from 'react-native'; -import { networkLogger, NetworkLoggerOverlay } from 'react-native-api-debugger'; + NetworkLoggerOverlay, + networkLogger, + webSocketLogger, +} from 'react-native-api-debugger'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { NetworkScreen } from './screens/NetworkScreen'; +import { WebSocketScreen } from './screens/WebSocketScreen'; +import { BottomTabs, type Tab } from './components/BottomTabs'; -interface LoadingState { - [key: string]: boolean; -} - -interface Post { - id: number; - title: string; - body: string; - userId: number; -} - -interface User { - id: number; - name: string; - username: string; - email: string; - phone: string; - website: string; - company: { - name: string; - catchPhrase: string; - }; -} - -interface ButtonComponentProps { - title: string; - onPress: () => void; - buttonId: string; - color?: string; - loading?: boolean; -} - -interface APIResponse { - data?: T; - error?: string; - success: boolean; -} - -const ButtonComponent: React.FC = ({ - title, - onPress, - color = '#007AFF', - loading = false, -}) => ( - - {loading ? ( - - ) : ( - {title} - )} - -); +const TABS: Tab[] = [ + { key: 'network', label: 'Network', icon: 'H' }, + { key: 'websocket', label: 'WebSocket', icon: 'WS' }, +]; const App: React.FC = () => { - const [loading, setLoading] = useState({}); + const [activeTab, setActiveTab] = useState('network'); const [showHeaders, setShowHeaders] = useState(false); useEffect(() => { @@ -83,332 +27,33 @@ const App: React.FC = () => { slowRequestThreshold: 2000, }); networkLogger.setupInterceptor(); + webSocketLogger.setupInterceptor(); }, []); - const setButtonLoading = (buttonId: string, isLoading: boolean): void => { - setLoading((prev) => ({ ...prev, [buttonId]: isLoading })); - }; - - const showResult = (title: string, success: boolean, data: any): void => { - Alert.alert( - title, - success ? `Success: ${JSON.stringify(data, null, 2)}` : `Error: ${data}`, - [{ text: 'OK' }] - ); - }; - - const makeAPIRequest = async ( - url: string, - options: RequestInit = {}, - buttonId: string, - successMessage: (data: T) => string - ): Promise> => { - setButtonLoading(buttonId, true); - - try { - const response: Response = await fetch(url, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - const data: T = await response.json(); - - if (response.ok) { - showResult( - `${options.method || 'GET'} Success`, - true, - successMessage(data) + const renderScreen = () => { + switch (activeTab) { + case 'websocket': + return ; + case 'network': + default: + return ( + ); - return { data, success: true }; - } else { - showResult(`${options.method || 'GET'} Error`, false, data); - return { error: `HTTP ${response.status}`, success: false }; - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Unknown error'; - showResult(`${options.method || 'GET'} Error`, false, errorMessage); - return { error: errorMessage, success: false }; - } finally { - setButtonLoading(buttonId, false); - } - }; - - // GET Request 1 - JSONPlaceholder Posts - const handleGetPosts = async (): Promise => { - await makeAPIRequest( - 'https://jsonplaceholder.typicode.com/posts?_limit=5', - { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Cache-Control': 'no-cache', - }, - }, - 'getPosts', - (data) => `Fetched ${data.length} posts` - ); - }; - - // GET Request 2 - JSONPlaceholder Users - const handleGetUsers = async (): Promise => { - await makeAPIRequest( - 'https://jsonplaceholder.typicode.com/users', - { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'User-Agent': 'ReactNative-TestApp/1.0', - }, - }, - 'getUsers', - (data) => `Fetched ${data.length} users` - ); - }; - - // POST Request 1 - Create Post - const handleCreatePost = async (): Promise => { - const postData = { - title: 'Test Post from React Native', - body: 'This is a test post created from the mobile app', - userId: 1, - }; - - await makeAPIRequest( - 'https://jsonplaceholder.typicode.com/posts', - { - method: 'POST', - headers: { - 'Authorization': 'Bearer test-token-123', - 'X-Client-Version': '1.0.0', - }, - body: JSON.stringify(postData), - }, - 'createPost', - (data) => `Created post with ID: ${data.id}` - ); - }; - - // POST Request 2 - Create User - const handleCreateUser = async (): Promise => { - const userData = { - name: 'John Doe', - username: 'johndoe', - email: 'john@example.com', - phone: '1-770-736-8031', - website: 'johndoe.org', - company: { - name: 'Doe Enterprises', - catchPhrase: 'Testing API endpoints', - }, - }; - - await makeAPIRequest( - 'https://jsonplaceholder.typicode.com/users', - { - method: 'POST', - headers: { - 'X-API-Key': 'mobile-app-key', - 'User-Agent': 'ReactNative/TestApp', - 'X-Request-ID': `req-${Date.now()}`, - }, - body: JSON.stringify(userData), - }, - 'createUser', - (data) => `Created user with ID: ${data.id}` - ); - }; - - // Test Error Request (404) - const handleErrorRequest = async (): Promise => { - await makeAPIRequest( - 'https://jsonplaceholder.typicode.com/posts/999999', - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }, - 'errorRequest', - () => 'Unexpected success' - ); - }; - - // Test Server Error (500) - const handleServerError = async (): Promise => { - await makeAPIRequest( - 'https://httpstat.us/500', - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }, - 'serverError', - () => 'Unexpected success' - ); - }; - - // Test Slow Request - const handleSlowRequest = async (): Promise => { - await makeAPIRequest( - 'https://httpstat.us/200?sleep=3000', - { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }, - 'slowRequest', - () => 'Slow request completed' - ); - }; - - // Test Ignored URL (should not appear in logs) - const handleIgnoredRequest = async (): Promise => { - setButtonLoading('ignoredRequest', true); - try { - await fetch('https://jsonplaceholder.typicode.com/health'); - Alert.alert( - 'Ignored Request', - 'This request should NOT appear in the network logger because "/health" is in the ignored URLs list.' - ); - } catch (error) { - Alert.alert('Error', 'Request failed'); - } finally { - setButtonLoading('ignoredRequest', false); } }; return ( - - - API Logger Test App - - Test network requests and view them in the logger - - - - - GET Requests - - - - - - POST Requests - - - - - - Error & Edge Cases - - - - - - - - - - Display Settings - - Show Headers - - - - - 📊 How to use: - - 1. Tap any button to make an API request{'\n'} - 2. Check the floating network logger button{'\n'} - 3. Tap the logger to view request details{'\n'} - 4. Use filter chips (2xx, 4xx, 5xx, Errors) to filter logs{'\n'} - 5. Search by URL, method, or response body{'\n'} - 6. Expand requests to see headers & responses{'\n'} - 7. Use cURL generation for debugging - - - - - ⚙️ Current Config: - - • Max Logs: 50{'\n'}• Ignored URLs: /health, /ping{'\n'}• Slow - Request Threshold: 2000ms{'\n'}• Dev Mode Only: enabled - (default) - - - - - 🔒 Production Safety: - - The network logger only shows in development builds by default - (__DEV__). This prevents accidental exposure of sensitive API - data in production apps. - - - - - {/* enabled defaults to __DEV__, so it only shows in development builds */} + {renderScreen()} + { showRequestHeader={showHeaders} showResponseHeader={showHeaders} networkLogger={networkLogger} - // enabled={true} // Uncomment to force enable in production (not recommended) + webSocketLogger={webSocketLogger} + enableWebSocket={true} /> @@ -432,138 +78,8 @@ const styles = StyleSheet.create({ backgroundColor: '#f5f5f5', paddingTop: '5%', }, - scrollContent: { - flexGrow: 1, - }, - header: { - backgroundColor: '#fff', - padding: 20, - alignItems: 'center', - borderBottomWidth: 1, - borderBottomColor: '#e0e0e0', - }, - headerTitle: { - fontSize: 24, - fontWeight: 'bold', - color: '#333', - marginBottom: 8, - }, - headerSubtitle: { - fontSize: 16, - color: '#666', - textAlign: 'center', - }, - body: { - flex: 1, - padding: 20, - }, - sectionTitle: { - fontSize: 18, - fontWeight: '600', - color: '#333', - marginTop: 20, - marginBottom: 15, - }, - buttonRow: { - flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 15, - }, - button: { + content: { flex: 1, - backgroundColor: '#007AFF', - paddingVertical: 15, - paddingHorizontal: 20, - borderRadius: 8, - marginHorizontal: 5, - alignItems: 'center', - justifyContent: 'center', - minHeight: 50, - elevation: 2, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, - }, - buttonText: { - color: '#fff', - fontSize: 16, - fontWeight: '600', - }, - instructions: { - backgroundColor: '#fff', - padding: 20, - borderRadius: 12, - marginTop: 30, - elevation: 1, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - shadowRadius: 2, - }, - instructionsTitle: { - fontSize: 18, - fontWeight: '600', - color: '#333', - marginBottom: 10, - }, - instructionsText: { - fontSize: 14, - color: '#666', - lineHeight: 22, - }, - configInfo: { - backgroundColor: '#E3F2FD', - padding: 20, - borderRadius: 12, - marginTop: 16, - borderWidth: 1, - borderColor: '#BBDEFB', - }, - configTitle: { - fontSize: 16, - fontWeight: '600', - color: '#1976D2', - marginBottom: 10, - }, - configText: { - fontSize: 13, - color: '#1565C0', - lineHeight: 20, - }, - settingRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - backgroundColor: '#fff', - padding: 16, - borderRadius: 8, - marginBottom: 8, - }, - settingLabel: { - fontSize: 16, - color: '#333', - fontWeight: '500', - }, - devModeInfo: { - backgroundColor: '#FCE4EC', - padding: 20, - borderRadius: 12, - marginTop: 16, - marginBottom: 40, - borderWidth: 1, - borderColor: '#F8BBD9', - }, - devModeTitle: { - fontSize: 16, - fontWeight: '600', - color: '#C2185B', - marginBottom: 10, - }, - devModeText: { - fontSize: 13, - color: '#AD1457', - lineHeight: 20, }, }); diff --git a/example/src/components/BottomTabs.tsx b/example/src/components/BottomTabs.tsx new file mode 100644 index 0000000..ca646da --- /dev/null +++ b/example/src/components/BottomTabs.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; + +export interface Tab { + key: string; + label: string; + icon: string; +} + +interface BottomTabsProps { + tabs: Tab[]; + activeTab: string; + onTabPress: (key: string) => void; +} + +export const BottomTabs: React.FC = ({ + tabs, + activeTab, + onTabPress, +}) => { + return ( + + {tabs.map((tab) => { + const isActive = tab.key === activeTab; + return ( + onTabPress(tab.key)} + activeOpacity={0.7} + > + + + {tab.icon} + + + + {tab.label} + + + ); + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + backgroundColor: '#fff', + borderTopWidth: 1, + borderTopColor: '#e0e0e0', + paddingVertical: 8, + paddingHorizontal: 16, + }, + tab: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 4, + }, + iconContainer: { + width: 32, + height: 32, + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#f0f0f0', + marginBottom: 4, + }, + iconContainerActive: { + backgroundColor: '#E3F2FD', + }, + icon: { + fontSize: 14, + fontWeight: '700', + color: '#8E8E93', + }, + iconActive: { + color: '#007AFF', + }, + label: { + fontSize: 12, + fontWeight: '500', + color: '#8E8E93', + }, + labelActive: { + color: '#007AFF', + fontWeight: '600', + }, +}); diff --git a/example/src/screens/NetworkScreen.tsx b/example/src/screens/NetworkScreen.tsx new file mode 100644 index 0000000..8af4e52 --- /dev/null +++ b/example/src/screens/NetworkScreen.tsx @@ -0,0 +1,503 @@ +import React, { useState } from 'react'; +import { + View, + StyleSheet, + Text, + TouchableOpacity, + ScrollView, + Alert, + ActivityIndicator, + Switch, +} from 'react-native'; + +interface LoadingState { + [key: string]: boolean; +} + +interface Post { + id: number; + title: string; + body: string; + userId: number; +} + +interface User { + id: number; + name: string; + username: string; + email: string; + phone: string; + website: string; + company: { + name: string; + catchPhrase: string; + }; +} + +interface ButtonComponentProps { + title: string; + onPress: () => void; + buttonId: string; + color?: string; + loading?: boolean; +} + +interface APIResponse { + data?: T; + error?: string; + success: boolean; +} + +interface NetworkScreenProps { + showHeaders: boolean; + setShowHeaders: (value: boolean) => void; +} + +const ButtonComponent: React.FC = ({ + title, + onPress, + color = '#007AFF', + loading = false, +}) => ( + + {loading ? ( + + ) : ( + {title} + )} + +); + +export const NetworkScreen: React.FC = ({ + showHeaders, + setShowHeaders, +}) => { + const [loading, setLoading] = useState({}); + + const setButtonLoading = (buttonId: string, isLoading: boolean): void => { + setLoading((prev) => ({ ...prev, [buttonId]: isLoading })); + }; + + const showResult = (title: string, success: boolean, data: any): void => { + Alert.alert( + title, + success ? `Success: ${JSON.stringify(data, null, 2)}` : `Error: ${data}`, + [{ text: 'OK' }] + ); + }; + + const makeAPIRequest = async ( + url: string, + options: RequestInit = {}, + buttonId: string, + successMessage: (data: T) => string + ): Promise> => { + setButtonLoading(buttonId, true); + + try { + const response: Response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + const data: T = await response.json(); + + if (response.ok) { + showResult( + `${options.method || 'GET'} Success`, + true, + successMessage(data) + ); + return { data, success: true }; + } else { + showResult(`${options.method || 'GET'} Error`, false, data); + return { error: `HTTP ${response.status}`, success: false }; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + showResult(`${options.method || 'GET'} Error`, false, errorMessage); + return { error: errorMessage, success: false }; + } finally { + setButtonLoading(buttonId, false); + } + }; + + const handleGetPosts = async (): Promise => { + await makeAPIRequest( + 'https://jsonplaceholder.typicode.com/posts?_limit=5', + { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Cache-Control': 'no-cache', + }, + }, + 'getPosts', + (data) => `Fetched ${data.length} posts` + ); + }; + + const handleGetUsers = async (): Promise => { + await makeAPIRequest( + 'https://jsonplaceholder.typicode.com/users', + { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'ReactNative-TestApp/1.0', + }, + }, + 'getUsers', + (data) => `Fetched ${data.length} users` + ); + }; + + const handleCreatePost = async (): Promise => { + const postData = { + title: 'Test Post from React Native', + body: 'This is a test post created from the mobile app', + userId: 1, + }; + + await makeAPIRequest( + 'https://jsonplaceholder.typicode.com/posts', + { + method: 'POST', + headers: { + 'Authorization': 'Bearer test-token-123', + 'X-Client-Version': '1.0.0', + }, + body: JSON.stringify(postData), + }, + 'createPost', + (data) => `Created post with ID: ${data.id}` + ); + }; + + const handleCreateUser = async (): Promise => { + const userData = { + name: 'John Doe', + username: 'johndoe', + email: 'john@example.com', + phone: '1-770-736-8031', + website: 'johndoe.org', + company: { + name: 'Doe Enterprises', + catchPhrase: 'Testing API endpoints', + }, + }; + + await makeAPIRequest( + 'https://jsonplaceholder.typicode.com/users', + { + method: 'POST', + headers: { + 'X-API-Key': 'mobile-app-key', + 'User-Agent': 'ReactNative/TestApp', + 'X-Request-ID': `req-${Date.now()}`, + }, + body: JSON.stringify(userData), + }, + 'createUser', + (data) => `Created user with ID: ${data.id}` + ); + }; + + const handleErrorRequest = async (): Promise => { + await makeAPIRequest( + 'https://jsonplaceholder.typicode.com/posts/999999', + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + 'errorRequest', + () => 'Unexpected success' + ); + }; + + const handleServerError = async (): Promise => { + await makeAPIRequest( + 'https://httpstat.us/500', + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + 'serverError', + () => 'Unexpected success' + ); + }; + + const handleSlowRequest = async (): Promise => { + await makeAPIRequest( + 'https://httpstat.us/200?sleep=3000', + { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }, + 'slowRequest', + () => 'Slow request completed' + ); + }; + + const handleIgnoredRequest = async (): Promise => { + setButtonLoading('ignoredRequest', true); + try { + await fetch('https://jsonplaceholder.typicode.com/health'); + Alert.alert( + 'Ignored Request', + 'This request should NOT appear in the network logger because "/health" is in the ignored URLs list.' + ); + } catch (error) { + Alert.alert('Error', 'Request failed'); + } finally { + setButtonLoading('ignoredRequest', false); + } + }; + + return ( + + + HTTP Requests + + Test network requests and view them in the logger + + + + + GET Requests + + + + + + POST Requests + + + + + + Error & Edge Cases + + + + + + + + + + Display Settings + + Show Headers + + + + + How to use: + + 1. Tap any button to make an API request{'\n'} + 2. Check the floating network logger button{'\n'} + 3. Tap the logger to view request details{'\n'} + 4. Use filter chips (2xx, 4xx, 5xx, Errors) to filter logs{'\n'} + 5. Search by URL, method, or response body{'\n'} + 6. Expand requests to see headers & responses{'\n'} + 7. Use cURL generation for debugging + + + + + Current Config: + + • Max Logs: 50{'\n'}• Ignored URLs: /health, /ping{'\n'}• Slow + Request Threshold: 2000ms{'\n'}• Dev Mode Only: enabled (default) + + + + + ); +}; + +const styles = StyleSheet.create({ + scrollContent: { + flexGrow: 1, + paddingBottom: 100, + }, + header: { + backgroundColor: '#fff', + padding: 20, + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + headerTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + headerSubtitle: { + fontSize: 16, + color: '#666', + textAlign: 'center', + }, + body: { + flex: 1, + padding: 20, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginTop: 20, + marginBottom: 15, + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 15, + }, + button: { + flex: 1, + backgroundColor: '#007AFF', + paddingVertical: 15, + paddingHorizontal: 20, + borderRadius: 8, + marginHorizontal: 5, + alignItems: 'center', + justifyContent: 'center', + minHeight: 50, + elevation: 2, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + instructions: { + backgroundColor: '#fff', + padding: 20, + borderRadius: 12, + marginTop: 30, + elevation: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 2, + }, + instructionsTitle: { + fontSize: 18, + fontWeight: '600', + color: '#333', + marginBottom: 10, + }, + instructionsText: { + fontSize: 14, + color: '#666', + lineHeight: 22, + }, + configInfo: { + backgroundColor: '#E3F2FD', + padding: 20, + borderRadius: 12, + marginTop: 16, + borderWidth: 1, + borderColor: '#BBDEFB', + }, + configTitle: { + fontSize: 16, + fontWeight: '600', + color: '#1976D2', + marginBottom: 10, + }, + configText: { + fontSize: 13, + color: '#1565C0', + lineHeight: 20, + }, + settingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#fff', + padding: 16, + borderRadius: 8, + marginBottom: 8, + }, + settingLabel: { + fontSize: 16, + color: '#333', + fontWeight: '500', + }, +}); diff --git a/example/src/screens/WebSocketScreen.tsx b/example/src/screens/WebSocketScreen.tsx new file mode 100644 index 0000000..131d2e6 --- /dev/null +++ b/example/src/screens/WebSocketScreen.tsx @@ -0,0 +1,539 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import { + View, + StyleSheet, + Text, + TouchableOpacity, + TextInput, + ActivityIndicator, + FlatList, +} from 'react-native'; + +type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; + +interface WebSocketMessage { + id: string; + type: 'sent' | 'received'; + data: string; + timestamp: Date; +} + +interface ButtonComponentProps { + title: string; + onPress: () => void; + color?: string; + loading?: boolean; + disabled?: boolean; +} + +const ButtonComponent: React.FC = ({ + title, + onPress, + color = '#007AFF', + loading = false, + disabled = false, +}) => ( + + {loading ? ( + + ) : ( + {title} + )} + +); + +const getStatusColor = (status: ConnectionStatus): string => { + switch (status) { + case 'connected': + return '#4CAF50'; + case 'connecting': + return '#FF9800'; + case 'error': + return '#F44336'; + default: + return '#9E9E9E'; + } +}; + +const getStatusText = (status: ConnectionStatus): string => { + switch (status) { + case 'connected': + return 'Connected'; + case 'connecting': + return 'Connecting...'; + case 'error': + return 'Error'; + default: + return 'Disconnected'; + } +}; + +export const WebSocketScreen: React.FC = () => { + const [status, setStatus] = useState('disconnected'); + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [serverUrl, setServerUrl] = useState('wss://echo.websocket.org'); + const wsRef = useRef(null); + const messageIdRef = useRef(0); + + const addMessage = useCallback((type: 'sent' | 'received', data: string) => { + const newMessage: WebSocketMessage = { + id: `msg-${messageIdRef.current++}`, + type, + data, + timestamp: new Date(), + }; + setMessages((prev) => [newMessage, ...prev]); + }, []); + + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + return; + } + + setStatus('connecting'); + + try { + const ws = new WebSocket(serverUrl); + + ws.onopen = () => { + setStatus('connected'); + addMessage('received', '[System] Connection established'); + }; + + ws.onmessage = (event) => { + addMessage('received', event.data); + }; + + ws.onerror = () => { + setStatus('error'); + addMessage('received', '[System] Connection error occurred'); + }; + + ws.onclose = (event) => { + setStatus('disconnected'); + addMessage( + 'received', + `[System] Connection closed (code: ${event.code})` + ); + wsRef.current = null; + }; + + wsRef.current = ws; + } catch (error) { + setStatus('error'); + addMessage( + 'received', + `[System] Failed to connect: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }, [serverUrl, addMessage]); + + const disconnect = useCallback(() => { + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }, []); + + const sendMessage = useCallback(() => { + if (!inputMessage.trim() || wsRef.current?.readyState !== WebSocket.OPEN) { + return; + } + + wsRef.current.send(inputMessage); + addMessage('sent', inputMessage); + setInputMessage(''); + }, [inputMessage, addMessage]); + + const sendPing = useCallback(() => { + if (wsRef.current?.readyState !== WebSocket.OPEN) { + return; + } + + const pingMessage = JSON.stringify({ type: 'ping', timestamp: Date.now() }); + wsRef.current.send(pingMessage); + addMessage('sent', pingMessage); + }, [addMessage]); + + const sendJsonMessage = useCallback(() => { + if (wsRef.current?.readyState !== WebSocket.OPEN) { + return; + } + + const jsonMessage = JSON.stringify({ + action: 'subscribe', + channel: 'updates', + data: { + userId: 123, + preferences: ['notifications', 'alerts'], + }, + }); + wsRef.current.send(jsonMessage); + addMessage('sent', jsonMessage); + }, [addMessage]); + + const clearMessages = useCallback(() => { + setMessages([]); + }, []); + + useEffect(() => { + return () => { + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + + const renderMessage = ({ item }: { item: WebSocketMessage }) => ( + + + + {item.type === 'sent' ? 'SENT' : 'RECEIVED'} + + + {item.timestamp.toLocaleTimeString()} + + + {item.data} + + ); + + const isConnected = status === 'connected'; + + return ( + + + WebSocket Testing + + Connect, send messages, and view WebSocket traffic + + + + + + + + {getStatusText(status)} + + + + Server URL + + + + + + + + Send Message + + + + Send + + + + Quick Actions + + + + + + + Messages + + Clear + + + + + {messages.length === 0 ? ( + + + No messages yet. Connect and send a message to see it here. + + + ) : ( + item.id} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.messagesList} + /> + )} + + + + How to use: + + 1. Enter a WebSocket server URL (default is echo server){'\n'} + 2. Tap "Connect" to establish connection{'\n'} + 3. Type messages and tap "Send" to transmit{'\n'} + 4. Use "Send Ping" or "Send JSON" for quick tests{'\n'} + 5. View sent/received messages in real-time{'\n'} + 6. Check the network logger for WebSocket frames + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + header: { + backgroundColor: '#fff', + padding: 20, + alignItems: 'center', + borderBottomWidth: 1, + borderBottomColor: '#e0e0e0', + }, + headerTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + headerSubtitle: { + fontSize: 16, + color: '#666', + textAlign: 'center', + }, + body: { + flex: 1, + padding: 20, + }, + statusBar: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + padding: 12, + borderRadius: 8, + marginBottom: 16, + }, + statusIndicator: { + flexDirection: 'row', + alignItems: 'center', + }, + statusDot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: 8, + }, + statusText: { + fontSize: 16, + fontWeight: '600', + color: '#333', + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginTop: 16, + marginBottom: 8, + }, + urlInput: { + backgroundColor: '#fff', + padding: 12, + borderRadius: 8, + fontSize: 14, + borderWidth: 1, + borderColor: '#e0e0e0', + }, + buttonRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 12, + }, + button: { + flex: 1, + backgroundColor: '#007AFF', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + marginHorizontal: 4, + alignItems: 'center', + justifyContent: 'center', + minHeight: 44, + }, + buttonDisabled: { + opacity: 0.5, + }, + buttonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + }, + messageInput: { + flex: 1, + backgroundColor: '#fff', + padding: 12, + borderRadius: 8, + fontSize: 14, + borderWidth: 1, + borderColor: '#e0e0e0', + marginRight: 8, + }, + sendButton: { + backgroundColor: '#007AFF', + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + }, + sendButtonText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + messagesHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + clearButton: { + color: '#007AFF', + fontSize: 14, + fontWeight: '600', + }, + messagesContainer: { + flex: 1, + backgroundColor: '#fff', + borderRadius: 8, + minHeight: 150, + maxHeight: 250, + }, + messagesList: { + padding: 8, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + emptyStateText: { + color: '#999', + textAlign: 'center', + fontSize: 14, + }, + messageContainer: { + padding: 10, + borderRadius: 8, + marginBottom: 8, + }, + sentMessage: { + backgroundColor: '#E3F2FD', + marginLeft: 20, + }, + receivedMessage: { + backgroundColor: '#F5F5F5', + marginRight: 20, + }, + messageHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 4, + }, + messageType: { + fontSize: 10, + fontWeight: '700', + color: '#666', + }, + messageTime: { + fontSize: 10, + color: '#999', + }, + messageData: { + fontSize: 13, + color: '#333', + }, + instructions: { + backgroundColor: '#FFF3E0', + padding: 16, + borderRadius: 12, + marginTop: 16, + marginBottom: 100, + borderWidth: 1, + borderColor: '#FFE0B2', + }, + instructionsTitle: { + fontSize: 16, + fontWeight: '600', + color: '#E65100', + marginBottom: 8, + }, + instructionsText: { + fontSize: 13, + color: '#F57C00', + lineHeight: 20, + }, +}); diff --git a/src/NetworkLoggerOverlay.tsx b/src/NetworkLoggerOverlay.tsx deleted file mode 100644 index d770fa3..0000000 --- a/src/NetworkLoggerOverlay.tsx +++ /dev/null @@ -1,585 +0,0 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Alert, - FlatList, - Modal, - Platform, - StatusBar, - Text, - TextInput, - TouchableOpacity, - View, - type ListRenderItem, - type ViewStyle, - type TextStyle, -} from 'react-native'; - -import NetworkLogItem from './NetworkLogItem'; -import type { NetworkLog, NetworkLogger } from './types'; -import type { StatusFilterKey } from './utils/filters'; -import FloatingButton from './FloatingButton'; -import NonFloatingButton from './NonFloatingButton'; -import { - colors, - getThemeColors, - type ThemeMode, - type ThemeColors, -} from './constants/colors'; -import { filterLogs, getLogStats, defaultFilterState } from './utils/filters'; -import type { FilterState } from './utils/filters'; -import { ExportModal } from './components/ExportModal'; -import { SvgIcon } from './SvgIcon'; - -let RNShake: any = null; - -try { - RNShake = require('react-native-shake').default; -} catch (error) { - RNShake = null; -} - -interface NetworkLoggerOverlayProps { - networkLogger: NetworkLogger; - enabled?: boolean; - enableDeviceShake?: boolean; - showRequestHeader?: boolean; - showResponseHeader?: boolean; - draggable?: boolean; - useCopyToClipboard?: boolean; - theme?: ThemeMode; - onThemeChange?: (theme: ThemeMode) => void; -} - -const STATUS_FILTERS: { key: StatusFilterKey; label: string }[] = [ - { key: 'all', label: 'All' }, - { key: 'success', label: '2xx' }, - { key: 'redirect', label: '3xx' }, - { key: 'clientError', label: '4xx' }, - { key: 'serverError', label: '5xx' }, -]; - -export const NetworkLoggerOverlay: React.FC = ({ - draggable, - networkLogger, - enabled = __DEV__, - enableDeviceShake, - showRequestHeader, - showResponseHeader, - useCopyToClipboard, - theme: themeProp, - onThemeChange, -}) => { - const [visible, setVisible] = useState(false); - const [logs, setLogs] = useState([]); - const [filters, setFilters] = useState(defaultFilterState); - const [showButton, setShowButton] = useState(!enableDeviceShake); - const [internalTheme, setInternalTheme] = useState( - themeProp ?? 'dark' - ); - const [exportModalVisible, setExportModalVisible] = useState(false); - - const theme = themeProp ?? internalTheme; - const themeColors = useMemo(() => getThemeColors(theme), [theme]); - - const toggleTheme = useCallback(() => { - const newTheme = internalTheme === 'light' ? 'dark' : 'light'; - setInternalTheme(newTheme); - onThemeChange?.(newTheme); - }, [internalTheme, onThemeChange]); - - const themedStyles = useMemo( - () => createThemedStyles(themeColors), - [themeColors] - ); - - useEffect(() => { - if (enabled && !__DEV__) { - console.warn( - '[NetworkLoggerOverlay] Warning: Network logger is enabled in production mode. ' + - 'This may expose sensitive data. Set enabled={false} or remove the prop to disable in production.' - ); - } - }, [enabled]); - - useEffect(() => { - if (!enabled) return; - - const unsubscribe = networkLogger.subscribe(setLogs); - - if (enableDeviceShake && !RNShake) { - throw new Error( - 'react-native-shake is required to enableDeviceShake but module is not installed. Please install it with: npm install react-native-shake' - ); - } - - if (!enableDeviceShake) { - return () => { - unsubscribe(); - }; - } - const subscription = RNShake.addListener(() => { - setShowButton(true); - }); - return () => { - unsubscribe(); - subscription?.remove(); - }; - }, [networkLogger, enableDeviceShake, enabled]); - - const handleCloseIcon = useCallback(() => { - setShowButton(false); - }, []); - - const filteredLogs = useMemo( - () => filterLogs(logs, filters), - [logs, filters] - ); - - const logStats = useMemo(() => getLogStats(logs), [logs]); - - const updateFilter = useCallback( - (key: K, value: FilterState[K]) => { - setFilters((prev) => ({ ...prev, [key]: value })); - }, - [] - ); - - const clearFilters = useCallback(() => { - setFilters(defaultFilterState); - }, []); - - const hasActiveFilters = useMemo(() => { - return ( - filters.searchTerm !== '' || - filters.statusFilter !== 'all' || - filters.methodFilter !== null - ); - }, [filters]); - - const handleClearLogs = useCallback((): void => { - Alert.alert( - 'Clear Logs', - 'Are you sure you want to clear all network logs?', - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Clear', onPress: () => networkLogger.clearLogs() }, - ] - ); - }, [networkLogger]); - - const handleDeleteLog = useCallback( - (logId: number): void => { - networkLogger.deleteLog(logId); - }, - [networkLogger] - ); - - const handleModalOpen = useCallback((): void => { - setVisible(true); - }, []); - - const handleModalClose = useCallback((): void => { - setVisible(false); - }, []); - - const handleSearchChange = useCallback( - (text: string): void => { - updateFilter('searchTerm', text); - }, - [updateFilter] - ); - - const handleStatusFilterChange = useCallback( - (key: StatusFilterKey): void => { - updateFilter('statusFilter', key); - }, - [updateFilter] - ); - - const renderLogItem: ListRenderItem = ({ item, index }) => ( - - ); - - const renderEmptyList = (): React.ReactElement => ( - - - - - Listening for traffic... - - No network requests logged yet. Trigger an API call in your app to see - it here. - - - - Interceptor Active - - - ); - - const keyExtractor = (item: NetworkLog): string => item.id.toString(); - - const buttonProps = { - draggable, - enableDeviceShake, - hideIcon: handleCloseIcon, - openModal: handleModalOpen, - logsLength: logs.length, - errorCount: logStats.errors, - theme, - }; - - if (!enabled) { - return null; - } - - return ( - <> - {showButton && - (draggable ? ( - - ) : ( - - ))} - - - - {/* Header */} - - - - NETWORK LOGS - - - - - - { - setVisible(false); - setExportModalVisible(true); - }} - activeOpacity={0.7} - > - - - - - - - - - - - - {/* Search & Filters */} - - - - - - - - {STATUS_FILTERS.map((filter) => ( - handleStatusFilterChange(filter.key)} - activeOpacity={0.7} - > - - {filter.label} - - - ))} - - {hasActiveFilters && ( - - Clear - - )} - - - - {/* Stats Bar */} - {logs.length > 0 && ( - - - {filteredLogs.length} of {logs.length} requests - - {logStats.errors > 0 && ( - - {logStats.errors} errors - - )} - - )} - - {/* Log List */} - - - - setExportModalVisible(false)} - logs={filteredLogs} - theme={theme} - /> - - ); -}; - -const createThemedStyles = (themeColors: ThemeColors) => ({ - modalContainer: { - flex: 1, - backgroundColor: themeColors.background, - paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 44, - } as ViewStyle, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 12, - backgroundColor: themeColors.surfaceContainer, - borderBottomWidth: 1, - borderBottomColor: themeColors.border, - } as ViewStyle, - title: { - fontSize: 14, - fontWeight: '800', - color: themeColors.text, - letterSpacing: 1, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - } as TextStyle, - searchContainer: { - paddingHorizontal: 12, - paddingVertical: 12, - backgroundColor: themeColors.surfaceContainer, - borderBottomWidth: 1, - borderBottomColor: themeColors.border, - gap: 10, - } as ViewStyle, - searchInputContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: themeColors.surfaceContainerLow, - borderWidth: 1, - borderColor: themeColors.border, - paddingHorizontal: 12, - gap: 8, - } as ViewStyle, - searchInput: { - flex: 1, - paddingVertical: 10, - fontSize: 12, - color: themeColors.text, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - } as TextStyle, - filterChip: { - paddingHorizontal: 12, - paddingVertical: 6, - borderWidth: 1, - borderColor: themeColors.border, - backgroundColor: 'transparent', - } as ViewStyle, - filterChipActive: { - backgroundColor: themeColors.primaryContainer, - borderColor: themeColors.primaryContainer, - } as ViewStyle, - filterChipText: { - fontSize: 11, - fontWeight: '700', - color: themeColors.textMuted, - letterSpacing: 0.5, - } as TextStyle, - clearFiltersText: { - fontSize: 11, - color: themeColors.primary, - fontWeight: '600', - } as TextStyle, - statsBar: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 12, - paddingVertical: 8, - backgroundColor: themeColors.surfaceContainerLow, - borderBottomWidth: 1, - borderBottomColor: themeColors.borderSubtle, - } as ViewStyle, - statsText: { - fontSize: 11, - color: themeColors.textMuted, - fontWeight: '500', - } as TextStyle, - emptyContainer: { - // flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 40, - marginTop: '25%', - } as ViewStyle, - emptyIconContainer: { - width: 80, - height: 80, - borderWidth: 2, - borderStyle: 'dashed', - borderColor: themeColors.border, - borderRadius: 40, - justifyContent: 'center', - alignItems: 'center', - marginBottom: 24, - backgroundColor: themeColors.surfaceContainerLow, - } as ViewStyle, - emptyTitle: { - fontSize: 18, - fontWeight: '600', - color: themeColors.text, - marginBottom: 8, - } as TextStyle, - emptyText: { - textAlign: 'center', - color: themeColors.textMuted, - fontSize: 14, - lineHeight: 20, - marginBottom: 24, - } as TextStyle, - statusPill: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: themeColors.surfaceContainer, - borderWidth: 1, - borderColor: themeColors.border, - paddingHorizontal: 12, - paddingVertical: 8, - gap: 8, - } as ViewStyle, - statusPillText: { - fontSize: 12, - color: themeColors.textSecondary, - fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', - } as TextStyle, -}); - -const staticStyles = { - headerLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - } as ViewStyle, - headerRight: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - } as ViewStyle, - iconButton: { - padding: 8, - } as ViewStyle, - filterRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - } as ViewStyle, - filterChips: { - flexDirection: 'row', - gap: 6, - } as ViewStyle, - filterChipTextActive: { - color: '#FFFFFF', - } as TextStyle, - errorStats: { - fontSize: 11, - color: colors.error, - fontWeight: '600', - } as TextStyle, - listContainer: { - paddingBottom: 34, - } as ViewStyle, - emptyListContainer: { - flexGrow: 1, - } as ViewStyle, - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.success, - } as ViewStyle, -}; - -export type { NetworkLoggerOverlayProps, NetworkLogger }; diff --git a/src/components/Badge.tsx b/src/components/common/Badge.tsx similarity index 98% rename from src/components/Badge.tsx rename to src/components/common/Badge.tsx index 277d8ee..30d49c8 100644 --- a/src/components/Badge.tsx +++ b/src/components/common/Badge.tsx @@ -6,7 +6,7 @@ import { type StyleProp, type ViewStyle, } from 'react-native'; -import { colors } from '../constants/colors'; +import { colors } from '../../constants/colors'; export type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'pending'; diff --git a/src/CopyItem.tsx b/src/components/common/CopyItem.tsx similarity index 96% rename from src/CopyItem.tsx rename to src/components/common/CopyItem.tsx index 3906489..8940b29 100644 --- a/src/CopyItem.tsx +++ b/src/components/common/CopyItem.tsx @@ -8,8 +8,8 @@ import { type StyleProp, type ViewStyle, } from 'react-native'; -import { SvgIcon } from './SvgIcon'; -import { colors } from './constants/colors'; +import { SvgIcon } from '../../icons'; +import { colors } from '../../constants/colors'; let Clipboard: any = null; try { diff --git a/src/components/ExportModal.tsx b/src/components/common/ExportModal.tsx similarity index 96% rename from src/components/ExportModal.tsx rename to src/components/common/ExportModal.tsx index ff93688..567ae2e 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/common/ExportModal.tsx @@ -11,15 +11,15 @@ import { type ViewStyle, type TextStyle, } from 'react-native'; -import type { NetworkLog } from '../types'; -import type { ThemeMode, ThemeColors } from '../constants/colors'; -import { getThemeColors } from '../constants/colors'; +import type { NetworkLog } from '../../types'; +import type { ThemeMode, ThemeColors } from '../../constants/colors'; +import { getThemeColors } from '../../constants/colors'; import { exportLogs, getExportFileName, type ExportFormat, -} from '../utils/export'; -import { SvgIcon, type IconName } from '../SvgIcon'; +} from '../../utils/export'; +import { SvgIcon, type IconName } from '../../icons'; interface ExportModalProps { visible: boolean; diff --git a/src/components/FilterChip.tsx b/src/components/common/FilterChip.tsx similarity index 94% rename from src/components/FilterChip.tsx rename to src/components/common/FilterChip.tsx index 9a3eccb..dc7c384 100644 --- a/src/components/FilterChip.tsx +++ b/src/components/common/FilterChip.tsx @@ -6,8 +6,8 @@ import { type StyleProp, type ViewStyle, } from 'react-native'; -import type { ThemeMode } from '../constants/colors'; -import { colors, getThemeColors } from '../constants/colors'; +import type { ThemeMode } from '../../constants/colors'; +import { colors, getThemeColors } from '../../constants/colors'; interface FilterChipProps { label: string; diff --git a/src/FloatingButton.tsx b/src/components/common/FloatingButton.tsx similarity index 98% rename from src/FloatingButton.tsx rename to src/components/common/FloatingButton.tsx index ff4b339..072cf6b 100644 --- a/src/FloatingButton.tsx +++ b/src/components/common/FloatingButton.tsx @@ -6,9 +6,9 @@ import { Dimensions, } from 'react-native'; import React, { useEffect } from 'react'; -import { SvgIcon } from './SvgIcon'; +import { SvgIcon } from '../../icons'; import NonFloatingButton from './NonFloatingButton'; -import { colors, getThemeColors, type ThemeMode } from './constants/colors'; +import { colors, getThemeColors, type ThemeMode } from '../../constants/colors'; const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); diff --git a/src/components/JsonViewer.tsx b/src/components/common/JsonViewer.tsx similarity index 98% rename from src/components/JsonViewer.tsx rename to src/components/common/JsonViewer.tsx index 8aa3220..9bbca37 100644 --- a/src/components/JsonViewer.tsx +++ b/src/components/common/JsonViewer.tsx @@ -7,8 +7,8 @@ import { type StyleProp, type ViewStyle, } from 'react-native'; -import type { ThemeMode } from '../constants/colors'; -import { getThemeColors } from '../constants/colors'; +import type { ThemeMode } from '../../constants/colors'; +import { getThemeColors } from '../../constants/colors'; interface JsonViewerProps { data: string | object | null; diff --git a/src/NonFloatingButton.tsx b/src/components/common/NonFloatingButton.tsx similarity index 96% rename from src/NonFloatingButton.tsx rename to src/components/common/NonFloatingButton.tsx index 0c65cb0..5408fd1 100644 --- a/src/NonFloatingButton.tsx +++ b/src/components/common/NonFloatingButton.tsx @@ -1,7 +1,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import React from 'react'; -import { SvgIcon } from './SvgIcon'; -import { colors, getThemeColors, type ThemeMode } from './constants/colors'; +import { SvgIcon } from '../../icons'; +import { colors, getThemeColors, type ThemeMode } from '../../constants/colors'; interface NonFloatingButtonProps { openModal: () => void; diff --git a/src/components/common/index.ts b/src/components/common/index.ts new file mode 100644 index 0000000..b7d8db8 --- /dev/null +++ b/src/components/common/index.ts @@ -0,0 +1,7 @@ +export * from './Badge'; +export * from './FilterChip'; +export * from './JsonViewer'; +export * from './CopyItem'; +export * from './ExportModal'; +export { default as FloatingButton } from './FloatingButton'; +export { default as NonFloatingButton } from './NonFloatingButton'; diff --git a/src/components/index.ts b/src/components/index.ts index 49d0654..d0b9323 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1 @@ -export * from './Badge'; -export * from './FilterChip'; -export * from './JsonViewer'; -export * from './ExportModal'; +export * from './common'; diff --git a/src/constants/colors.ts b/src/constants/colors.ts index cbd465e..3494505 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -65,6 +65,16 @@ export const colors = { clientError: '#EF4444', // 4xx serverError: '#A855F7', // 5xx }, + + // WebSocket colors + websocket: { + connecting: '#F59E0B', + open: '#10B981', + closing: '#F97316', + closed: '#94A3B8', + sent: '#3B82F6', + received: '#10B981', + }, } as const; export type ThemeMode = 'light' | 'dark'; @@ -105,3 +115,15 @@ export const getMethodColor = (method: string): string => { const upperMethod = method.toUpperCase() as keyof typeof colors.methods; return colors.methods[upperMethod] || colors.info; }; + +export const getWebSocketStateColor = ( + state: 'connecting' | 'open' | 'closing' | 'closed' +): string => { + return colors.websocket[state] || colors.pending; +}; + +export const getWebSocketMessageColor = ( + direction: 'sent' | 'received' +): string => { + return colors.websocket[direction]; +}; diff --git a/src/constants/config.ts b/src/constants/config.ts index 4356efd..8270c25 100644 --- a/src/constants/config.ts +++ b/src/constants/config.ts @@ -1,4 +1,4 @@ -import type { NetworkLoggerConfig } from '../types'; +import type { NetworkLoggerConfig, WebSocketLoggerConfig } from '../types'; export const DEFAULT_CONFIG: NetworkLoggerConfig = { maxLogs: 100, @@ -46,6 +46,13 @@ export const FILTER_OPTIONS = { methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], } as const; +export const DEFAULT_WS_CONFIG: WebSocketLoggerConfig = { + maxConnections: 50, + maxMessagesPerConnection: 500, + ignoredUrls: [], + captureMessages: true, +}; + export const UI_CONFIG = { floatingButton: { size: 58, diff --git a/src/NetworkLogItem.tsx b/src/features/http/NetworkLogItem.tsx similarity index 97% rename from src/NetworkLogItem.tsx rename to src/features/http/NetworkLogItem.tsx index 23eed1b..04c88c2 100644 --- a/src/NetworkLogItem.tsx +++ b/src/features/http/NetworkLogItem.tsx @@ -11,26 +11,26 @@ import { type ViewStyle, type TextStyle, } from 'react-native'; -import type { NetworkLog, NetworkRequestHeaders } from './types'; -import type { ThemeMode, ThemeColors } from './constants/colors'; +import type { NetworkLog, NetworkRequestHeaders } from '../../types'; +import type { ThemeMode, ThemeColors } from '../../constants/colors'; import { colors, getThemeColors, getStatusColor, getMethodColor, -} from './constants/colors'; -import { CopyItem } from './CopyItem'; -import { JsonViewer } from './components/JsonViewer'; -import { SvgIcon } from './SvgIcon'; -import { formatTimestamp } from './utils/formatters'; -import { generateCurl } from './utils/curl'; +} from '../../constants/colors'; +import { CopyItem } from '../../components/common'; +import { JsonViewer } from '../../components/common'; +import { SvgIcon } from '../../icons'; +import { formatTimestamp } from '../../utils/formatters'; +import { generateCurl } from '../../utils/curl'; import { replayRequest, canReplayRequest, getReplayWarnings, -} from './utils/replay'; -import { exportSingleRequestAsJSON } from './utils/export'; -import { detectSensitiveData, redactHeaders } from './utils/redact'; +} from '../../utils/replay'; +import { exportSingleRequestAsJSON } from '../../utils/export'; +import { detectSensitiveData, redactHeaders } from '../../utils/redact'; interface NetworkLogItemProps { log: NetworkLog; diff --git a/src/features/http/NetworkLoggerOverlay.tsx b/src/features/http/NetworkLoggerOverlay.tsx new file mode 100644 index 0000000..9b8c3e3 --- /dev/null +++ b/src/features/http/NetworkLoggerOverlay.tsx @@ -0,0 +1,960 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + Alert, + FlatList, + Modal, + Platform, + StatusBar, + Text, + TextInput, + TouchableOpacity, + View, + type ListRenderItem, + type ViewStyle, + type TextStyle, +} from 'react-native'; + +import NetworkLogItem from './NetworkLogItem'; +import { WebSocketLogItem } from '../websocket'; +import type { + NetworkLog, + NetworkLogger, + WebSocketLog, + IWebSocketLogger, +} from '../../types'; +import type { StatusFilterKey } from '../../utils/filters'; +import { + FloatingButton, + NonFloatingButton, + ExportModal, +} from '../../components/common'; +import { + colors, + getThemeColors, + type ThemeMode, + type ThemeColors, +} from '../../constants/colors'; +import { + filterLogs, + getLogStats, + defaultFilterState, +} from '../../utils/filters'; +import type { FilterState } from '../../utils/filters'; +import { SvgIcon } from '../../icons'; + +let RNShake: any = null; + +try { + RNShake = require('react-native-shake').default; +} catch (error) { + RNShake = null; +} + +type ActiveTab = 'http' | 'websocket'; + +interface NetworkLoggerOverlayProps { + networkLogger: NetworkLogger; + webSocketLogger?: IWebSocketLogger; + enabled?: boolean; + enableWebSocket?: boolean; + enableDeviceShake?: boolean; + showRequestHeader?: boolean; + showResponseHeader?: boolean; + draggable?: boolean; + useCopyToClipboard?: boolean; + theme?: ThemeMode; + onThemeChange?: (theme: ThemeMode) => void; +} + +class NetworkLoggerSetupError extends Error { + constructor(message: string) { + super(message); + this.name = 'NetworkLoggerSetupError'; + } +} + +const STATUS_FILTERS: { key: StatusFilterKey; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'success', label: '2xx' }, + { key: 'redirect', label: '3xx' }, + { key: 'clientError', label: '4xx' }, + { key: 'serverError', label: '5xx' }, +]; + +export const NetworkLoggerOverlay: React.FC = ({ + draggable, + networkLogger, + webSocketLogger, + enabled = __DEV__, + enableWebSocket = false, + enableDeviceShake, + showRequestHeader, + showResponseHeader, + useCopyToClipboard, + theme: themeProp, + onThemeChange, +}) => { + const [visible, setVisible] = useState(false); + const [activeTab, setActiveTab] = useState('http'); + const [logs, setLogs] = useState([]); + const [wsLogs, setWsLogs] = useState([]); + const [filters, setFilters] = useState(defaultFilterState); + const [wsSearchTerm, setWsSearchTerm] = useState(''); + const [showButton, setShowButton] = useState(!enableDeviceShake); + const [internalTheme, setInternalTheme] = useState( + themeProp ?? 'dark' + ); + const [exportModalVisible, setExportModalVisible] = useState(false); + + const theme = themeProp ?? internalTheme; + const themeColors = useMemo(() => getThemeColors(theme), [theme]); + + const toggleTheme = useCallback(() => { + const newTheme = internalTheme === 'light' ? 'dark' : 'light'; + setInternalTheme(newTheme); + onThemeChange?.(newTheme); + }, [internalTheme, onThemeChange]); + + const themedStyles = useMemo( + () => createThemedStyles(themeColors), + [themeColors] + ); + + useEffect(() => { + if (enabled && !__DEV__) { + console.warn( + '[NetworkLoggerOverlay] Warning: Debugger is enabled in production mode. ' + + 'This may expose sensitive data. Set enabled={false} or remove the prop to disable in production.' + ); + } + }, [enabled]); + + useEffect(() => { + if (!enabled) return; + + // Validate HTTP interceptor setup + if (!networkLogger.isSetup()) { + throw new NetworkLoggerSetupError( + '[NetworkLoggerOverlay] HTTP interceptor not initialized.\n\n' + + 'Please call networkLogger.setupInterceptor() before rendering DebuggerOverlay.\n\n' + + 'Example:\n' + + ' useEffect(() => {\n' + + ' networkLogger.setupInterceptor();\n' + + ' }, []);\n\n' + + 'Make sure this is called BEFORE any network requests are made.' + ); + } + + // Validate WebSocket interceptor setup if enabled + if (enableWebSocket) { + if (!webSocketLogger) { + throw new NetworkLoggerSetupError( + '[NetworkLoggerOverlay] WebSocket debugging is enabled but webSocketLogger prop is missing.\n\n' + + 'Please pass the webSocketLogger prop when enableWebSocket={true}.\n\n' + + 'Example:\n' + + ' import { webSocketLogger, DebuggerOverlay } from "react-native-api-debugger";\n\n' + + ' ' + ); + } + + if (!webSocketLogger.isSetup()) { + throw new NetworkLoggerSetupError( + '[NetworkLoggerOverlay] WebSocket interceptor not initialized.\n\n' + + 'Please call webSocketLogger.setupInterceptor() before rendering DebuggerOverlay with enableWebSocket={true}.\n\n' + + 'Example:\n' + + ' useEffect(() => {\n' + + ' networkLogger.setupInterceptor();\n' + + ' webSocketLogger.setupInterceptor();\n' + + ' }, []);\n\n' + + 'Make sure this is called BEFORE any WebSocket connections are made.' + ); + } + } + + const unsubscribeNetwork = networkLogger.subscribe(setLogs); + const unsubscribeWs = enableWebSocket + ? webSocketLogger?.subscribe(setWsLogs) + : undefined; + + if (enableDeviceShake && !RNShake) { + throw new NetworkLoggerSetupError( + '[NetworkLoggerOverlay] react-native-shake is required for enableDeviceShake but module is not installed.\n\n' + + 'Please install it with:\n' + + ' npm install react-native-shake\n\n' + + 'Or disable device shake:\n' + + ' ' + ); + } + + if (!enableDeviceShake) { + return () => { + unsubscribeNetwork(); + unsubscribeWs?.(); + }; + } + const subscription = RNShake.addListener(() => { + setShowButton(true); + }); + return () => { + unsubscribeNetwork(); + unsubscribeWs?.(); + subscription?.remove(); + }; + }, [ + networkLogger, + webSocketLogger, + enableWebSocket, + enableDeviceShake, + enabled, + ]); + + const handleCloseIcon = useCallback(() => { + setShowButton(false); + }, []); + + const filteredLogs = useMemo( + () => filterLogs(logs, filters), + [logs, filters] + ); + + const filteredWsLogs = useMemo(() => { + if (!wsSearchTerm) return wsLogs; + const term = wsSearchTerm.toLowerCase(); + return wsLogs.filter( + (log) => + log.url.toLowerCase().includes(term) || + log.state.toLowerCase().includes(term) + ); + }, [wsLogs, wsSearchTerm]); + + const logStats = useMemo(() => getLogStats(logs), [logs]); + + const wsStats = useMemo(() => { + const active = wsLogs.filter( + (l) => l.state === 'open' || l.state === 'connecting' + ).length; + const errors = wsLogs.filter((l) => l.error).length; + return { active, errors, total: wsLogs.length }; + }, [wsLogs]); + + const updateFilter = useCallback( + (key: K, value: FilterState[K]) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, + [] + ); + + const clearFilters = useCallback(() => { + setFilters(defaultFilterState); + setWsSearchTerm(''); + }, []); + + const hasActiveFilters = useMemo(() => { + return ( + filters.searchTerm !== '' || + filters.statusFilter !== 'all' || + filters.methodFilter !== null || + wsSearchTerm !== '' + ); + }, [filters, wsSearchTerm]); + + const handleClearLogs = useCallback((): void => { + const isHttp = activeTab === 'http'; + Alert.alert( + 'Clear Logs', + `Are you sure you want to clear all ${isHttp ? 'HTTP' : 'WebSocket'} logs?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + onPress: () => { + if (isHttp) { + networkLogger.clearLogs(); + } else { + webSocketLogger?.clearLogs(); + } + }, + }, + ] + ); + }, [activeTab, networkLogger, webSocketLogger]); + + const handleDeleteLog = useCallback( + (logId: number): void => { + networkLogger.deleteLog(logId); + }, + [networkLogger] + ); + + const handleDeleteWsLog = useCallback( + (logId: number): void => { + webSocketLogger?.deleteLog(logId); + }, + [webSocketLogger] + ); + + const handleCloseWsConnection = useCallback( + (logId: number): void => { + webSocketLogger?.closeConnection?.(logId); + }, + [webSocketLogger] + ); + + const handleModalOpen = useCallback((): void => { + setVisible(true); + }, []); + + const handleModalClose = useCallback((): void => { + setVisible(false); + }, []); + + const handleSearchChange = useCallback( + (text: string): void => { + if (activeTab === 'http') { + updateFilter('searchTerm', text); + } else { + setWsSearchTerm(text); + } + }, + [activeTab, updateFilter] + ); + + const handleStatusFilterChange = useCallback( + (key: StatusFilterKey): void => { + updateFilter('statusFilter', key); + }, + [updateFilter] + ); + + const renderLogItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [ + useCopyToClipboard, + showResponseHeader, + showRequestHeader, + theme, + handleDeleteLog, + handleModalClose, + ] + ); + + const renderWsLogItem: ListRenderItem = useCallback( + ({ item, index }) => ( + + ), + [useCopyToClipboard, theme, handleDeleteWsLog, handleCloseWsConnection] + ); + + const renderEmptyHttpList = useCallback( + (): React.ReactElement => ( + + + + + Listening for traffic... + + No HTTP requests logged yet. Trigger an API call in your app to see it + here. + + + + Interceptor Active + + + ), + [themedStyles, themeColors] + ); + + const renderEmptyWsList = useCallback( + (): React.ReactElement => ( + + + + + No WebSocket connections + + {webSocketLogger + ? 'Open a WebSocket connection in your app to see it here.' + : 'WebSocket logger not configured. Pass webSocketLogger prop to enable.'} + + {webSocketLogger && ( + + + Interceptor Active + + )} + + ), + [themedStyles, themeColors, webSocketLogger] + ); + + const totalLogs = enableWebSocket ? logs.length + wsLogs.length : logs.length; + const totalErrors = enableWebSocket + ? logStats.errors + wsStats.errors + : logStats.errors; + + const buttonProps = { + draggable, + enableDeviceShake, + hideIcon: handleCloseIcon, + openModal: handleModalOpen, + logsLength: totalLogs, + errorCount: totalErrors, + theme, + }; + + if (!enabled) { + return null; + } + + return ( + <> + {showButton && + (draggable ? ( + + ) : ( + + ))} + + + + {/* Header */} + + + + DEBUGGER + + + + + + {activeTab === 'http' && ( + { + setVisible(false); + setExportModalVisible(true); + }} + activeOpacity={0.7} + > + + + )} + + + + + + + + + + {/* Tab Bar - Only show if WebSocket is enabled */} + {enableWebSocket && ( + + setActiveTab('http')} + activeOpacity={0.7} + > + + HTTP + + + + {logs.length} + + + + + setActiveTab('websocket')} + activeOpacity={0.7} + > + + WS + + 0 && themedStyles.tabBadgeLive, + ]} + > + 0 && themedStyles.tabBadgeTextLive, + ]} + > + {wsLogs.length} + + + {wsStats.active > 0 && } + + + )} + + {/* Search & Filters */} + + + + + + {activeTab === 'http' && ( + + + {STATUS_FILTERS.map((filter) => ( + handleStatusFilterChange(filter.key)} + activeOpacity={0.7} + > + + {filter.label} + + + ))} + + {hasActiveFilters && ( + + Clear + + )} + + )} + + + {/* Stats Bar */} + {activeTab === 'http' && logs.length > 0 && ( + + + {filteredLogs.length} of {logs.length} requests + + {logStats.errors > 0 && ( + + {logStats.errors} errors + + )} + + )} + + {enableWebSocket && + activeTab === 'websocket' && + wsLogs.length > 0 && ( + + + {filteredWsLogs.length} of {wsLogs.length} connections + + {wsStats.active > 0 && ( + + {wsStats.active} active + + )} + {wsStats.errors > 0 && ( + + {wsStats.errors} errors + + )} + + )} + + {/* Log Lists */} + {activeTab === 'http' || !enableWebSocket ? ( + item.id.toString()} + renderItem={renderLogItem} + contentContainerStyle={ + logs.length === 0 + ? staticStyles.emptyListContainer + : staticStyles.listContainer + } + ListEmptyComponent={renderEmptyHttpList} + showsVerticalScrollIndicator={true} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + windowSize={10} + initialNumToRender={10} + /> + ) : ( + item.id.toString()} + renderItem={renderWsLogItem} + contentContainerStyle={ + wsLogs.length === 0 + ? staticStyles.emptyListContainer + : staticStyles.listContainer + } + ListEmptyComponent={renderEmptyWsList} + showsVerticalScrollIndicator={true} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + windowSize={10} + initialNumToRender={10} + /> + )} + + + setExportModalVisible(false)} + logs={filteredLogs} + theme={theme} + /> + + ); +}; + +const createThemedStyles = (themeColors: ThemeColors) => ({ + modalContainer: { + flex: 1, + backgroundColor: themeColors.background, + paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight : 44, + } as ViewStyle, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 12, + backgroundColor: themeColors.surfaceContainer, + borderBottomWidth: 1, + borderBottomColor: themeColors.border, + } as ViewStyle, + title: { + fontSize: 14, + fontWeight: '800', + color: themeColors.text, + letterSpacing: 1, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + } as TextStyle, + tabBar: { + flexDirection: 'row', + backgroundColor: themeColors.surfaceContainer, + borderBottomWidth: 1, + borderBottomColor: themeColors.border, + paddingHorizontal: 12, + } as ViewStyle, + tab: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + marginRight: 8, + borderBottomWidth: 2, + borderBottomColor: 'transparent', + } as ViewStyle, + tabActive: { + borderBottomColor: themeColors.primaryContainer, + } as ViewStyle, + tabText: { + fontSize: 13, + fontWeight: '600', + color: themeColors.textMuted, + letterSpacing: 0.5, + } as TextStyle, + tabTextActive: { + color: themeColors.text, + } as TextStyle, + tabBadge: { + marginLeft: 8, + paddingHorizontal: 6, + paddingVertical: 2, + backgroundColor: themeColors.surfaceContainerHigh, + borderRadius: 10, + minWidth: 24, + alignItems: 'center', + } as ViewStyle, + tabBadgeActive: { + backgroundColor: themeColors.primaryContainer, + } as ViewStyle, + tabBadgeLive: { + backgroundColor: colors.websocket.open, + } as ViewStyle, + tabBadgeText: { + fontSize: 10, + fontWeight: '700', + color: themeColors.textMuted, + } as TextStyle, + tabBadgeTextActive: { + color: '#FFFFFF', + } as TextStyle, + tabBadgeTextLive: { + color: '#FFFFFF', + } as TextStyle, + searchContainer: { + paddingHorizontal: 12, + paddingVertical: 12, + backgroundColor: themeColors.surfaceContainer, + borderBottomWidth: 1, + borderBottomColor: themeColors.border, + gap: 10, + } as ViewStyle, + searchInputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: themeColors.surfaceContainerLow, + borderWidth: 1, + borderColor: themeColors.border, + paddingHorizontal: 12, + gap: 8, + } as ViewStyle, + searchInput: { + flex: 1, + paddingVertical: 10, + fontSize: 12, + color: themeColors.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + } as TextStyle, + filterChip: { + paddingHorizontal: 12, + paddingVertical: 6, + borderWidth: 1, + borderColor: themeColors.border, + backgroundColor: 'transparent', + } as ViewStyle, + filterChipActive: { + backgroundColor: themeColors.primaryContainer, + borderColor: themeColors.primaryContainer, + } as ViewStyle, + filterChipText: { + fontSize: 11, + fontWeight: '700', + color: themeColors.textMuted, + letterSpacing: 0.5, + } as TextStyle, + clearFiltersText: { + fontSize: 11, + color: themeColors.primary, + fontWeight: '600', + } as TextStyle, + statsBar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: themeColors.surfaceContainerLow, + borderBottomWidth: 1, + borderBottomColor: themeColors.borderSubtle, + } as ViewStyle, + statsText: { + fontSize: 11, + color: themeColors.textMuted, + fontWeight: '500', + } as TextStyle, + emptyContainer: { + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + marginTop: '25%', + } as ViewStyle, + emptyIconContainer: { + width: 80, + height: 80, + borderWidth: 2, + borderStyle: 'dashed', + borderColor: themeColors.border, + borderRadius: 40, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + backgroundColor: themeColors.surfaceContainerLow, + } as ViewStyle, + emptyTitle: { + fontSize: 18, + fontWeight: '600', + color: themeColors.text, + marginBottom: 8, + } as TextStyle, + emptyText: { + textAlign: 'center', + color: themeColors.textMuted, + fontSize: 14, + lineHeight: 20, + marginBottom: 24, + } as TextStyle, + statusPill: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: themeColors.surfaceContainer, + borderWidth: 1, + borderColor: themeColors.border, + paddingHorizontal: 12, + paddingVertical: 8, + gap: 8, + } as ViewStyle, + statusPillText: { + fontSize: 12, + color: themeColors.textSecondary, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + } as TextStyle, +}); + +const staticStyles = { + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + } as ViewStyle, + headerRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + } as ViewStyle, + iconButton: { + padding: 8, + } as ViewStyle, + filterRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + } as ViewStyle, + filterChips: { + flexDirection: 'row', + gap: 6, + } as ViewStyle, + filterChipTextActive: { + color: '#FFFFFF', + } as TextStyle, + errorStats: { + fontSize: 11, + color: colors.error, + fontWeight: '600', + } as TextStyle, + activeStats: { + fontSize: 11, + color: colors.websocket.open, + fontWeight: '600', + marginRight: 8, + } as TextStyle, + listContainer: { + paddingBottom: 34, + } as ViewStyle, + emptyListContainer: { + flexGrow: 1, + } as ViewStyle, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.success, + } as ViewStyle, + liveDot: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: colors.websocket.open, + marginLeft: 4, + } as ViewStyle, +}; + +export type { NetworkLoggerOverlayProps }; diff --git a/src/features/http/index.ts b/src/features/http/index.ts new file mode 100644 index 0000000..2d31818 --- /dev/null +++ b/src/features/http/index.ts @@ -0,0 +1,3 @@ +export * from './NetworkLoggerOverlay'; +export { default as NetworkLogItem } from './NetworkLogItem'; +export type { NetworkLogItemProps } from './NetworkLogItem'; diff --git a/src/features/websocket/WebSocketLogItem.tsx b/src/features/websocket/WebSocketLogItem.tsx new file mode 100644 index 0000000..456488f --- /dev/null +++ b/src/features/websocket/WebSocketLogItem.tsx @@ -0,0 +1,673 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { + FlatList, + Text, + TouchableOpacity, + View, + Platform, + type ViewStyle, + type TextStyle, + type ListRenderItem, +} from 'react-native'; +import type { WebSocketLog, WebSocketMessage } from '../../types'; +import type { ThemeMode, ThemeColors } from '../../constants/colors'; +import { + colors, + getThemeColors, + getWebSocketStateColor, + getWebSocketMessageColor, +} from '../../constants/colors'; +import { CopyItem, JsonViewer } from '../../components/common'; +import { SvgIcon } from '../../icons'; + +interface WebSocketLogItemProps { + log: WebSocketLog; + useCopyToClipboard?: boolean; + theme?: ThemeMode; + onDelete?: (logId: number) => void; + onClose?: (logId: number) => void; + isAlternate?: boolean; +} + +type MessageFilter = 'all' | 'sent' | 'received'; + +const WebSocketLogItem: React.FC = ({ + log, + useCopyToClipboard, + theme = 'dark', + onDelete, + onClose, + isAlternate = false, +}) => { + const [expanded, setExpanded] = useState(false); + const [messageFilter, setMessageFilter] = useState('all'); + const [expandedMessageId, setExpandedMessageId] = useState( + null + ); + + const themeColors = useMemo(() => getThemeColors(theme), [theme]); + const themedStyles = useMemo( + () => createThemedStyles(themeColors), + [themeColors] + ); + + const stateColor = getWebSocketStateColor(log.state); + const isActive = log.state === 'open' || log.state === 'connecting'; + + const filteredMessages = useMemo(() => { + if (messageFilter === 'all') return log.messages; + return log.messages.filter((m) => m.direction === messageFilter); + }, [log.messages, messageFilter]); + + const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + }; + + const formatDuration = (startTime: string, endTime?: string): string => { + const start = new Date(startTime).getTime(); + const end = endTime ? new Date(endTime).getTime() : Date.now(); + const duration = end - start; + + if (duration < 1000) return `${duration}ms`; + if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`; + const minutes = Math.floor(duration / 60000); + const seconds = Math.floor((duration % 60000) / 1000); + return `${minutes}m ${seconds}s`; + }; + + const formatTime = (timestamp: string): string => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + }; + + const getUrlPath = (): string => { + try { + const url = new URL(log.url); + return url.pathname; + } catch { + return log.url; + } + }; + + const getUrlHost = (): string => { + try { + const url = new URL(log.url); + return url.hostname; + } catch { + return ''; + } + }; + + const handleHeaderPress = useCallback(() => { + setExpanded(!expanded); + }, [expanded]); + + const handleDelete = useCallback(() => { + onDelete?.(log.id); + }, [log.id, onDelete]); + + const handleCloseConnection = useCallback(() => { + onClose?.(log.id); + }, [log.id, onClose]); + + const rowBackgroundColor = isAlternate + ? themeColors.surfaceContainerLow + : themeColors.surface; + + const borderColor = expanded ? stateColor : 'transparent'; + + const renderMessageItem: ListRenderItem = useCallback( + ({ item: message }) => { + const messageColor = getWebSocketMessageColor(message.direction); + const isExpanded = expandedMessageId === message.id; + const isJson = + message.data.trim().startsWith('{') || + message.data.trim().startsWith('['); + + return ( + setExpandedMessageId(isExpanded ? null : message.id)} + activeOpacity={0.7} + > + + + + + {formatTime(message.timestamp)} + + + + {formatBytes(message.size)} + + + + {isExpanded && isJson ? ( + + + + + + + ) : ( + + {message.data} + + )} + + ); + }, + [expandedMessageId, theme, themeColors, themedStyles, useCopyToClipboard] + ); + + return ( + + + {/* State indicator */} + + + + + {/* WS Badge */} + + + + WS + + + + + {/* URL */} + + + {getUrlPath()} + + + {getUrlHost()} + + + + {/* Messages count & data */} + + + + {log.messageCount.sent} + + + {log.messageCount.received} + + + + {formatBytes(log.bytesSent + log.bytesReceived)} + + + + + {expanded && ( + + {/* Connection Info */} + + + URL + + {log.url} + + + + + State + + {log.state.toUpperCase()} + + + + Duration + + {formatDuration(log.connectTime, log.closeTime)} + + + {log.handshakeDuration !== undefined && ( + + Handshake + + {log.handshakeDuration}ms + + + )} + {log.closeCode !== undefined && ( + + Close Code + + {log.closeCode} {log.closeReason && `(${log.closeReason})`} + + + )} + + + + {/* Action Buttons */} + + {isActive && onClose && ( + + + + CLOSE + + + )} + + + + {onDelete && ( + + + + )} + + + {log.error && ( + + + {log.error} + + )} + + {/* Messages Section */} + + + + MESSAGES ({log.messages.length}) + + + {(['all', 'sent', 'received'] as MessageFilter[]).map( + (filter) => ( + setMessageFilter(filter)} + activeOpacity={0.7} + > + + {filter === 'all' + ? 'All' + : filter === 'sent' + ? `↑ ${log.messageCount.sent}` + : `↓ ${log.messageCount.received}`} + + + ) + )} + + + + {filteredMessages.length > 0 ? ( + item.id.toString()} + renderItem={renderMessageItem} + style={staticStyles.messagesList} + showsVerticalScrollIndicator={true} + nestedScrollEnabled={true} + /> + ) : ( + + + No messages{' '} + {messageFilter !== 'all' ? `(${messageFilter})` : ''} + + + )} + + + )} + + ); +}; + +const createThemedStyles = (themeColors: ThemeColors) => ({ + logItem: { + backgroundColor: themeColors.surface, + borderLeftWidth: 2, + borderLeftColor: 'transparent', + borderBottomWidth: 1, + borderBottomColor: themeColors.borderSubtle, + } as ViewStyle, + logHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 12, + } as ViewStyle, + urlPath: { + fontSize: 12, + color: themeColors.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + } as TextStyle, + urlHost: { + fontSize: 10, + color: themeColors.textMuted, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + marginTop: 2, + } as TextStyle, + statText: { + fontSize: 11, + color: themeColors.text, + fontWeight: '600', + marginLeft: 2, + marginRight: 6, + } as TextStyle, + dataSize: { + fontSize: 10, + color: themeColors.textMuted, + marginTop: 2, + } as TextStyle, + logDetails: { + borderTopWidth: 1, + borderTopColor: themeColors.borderSubtle, + backgroundColor: themeColors.surfaceContainerLow, + } as ViewStyle, + infoSection: { + padding: 12, + borderBottomWidth: 1, + borderBottomColor: themeColors.borderSubtle, + } as ViewStyle, + infoLabel: { + fontSize: 10, + color: themeColors.textMuted, + fontWeight: '600', + letterSpacing: 0.5, + marginBottom: 2, + } as TextStyle, + infoValue: { + fontSize: 12, + color: themeColors.text, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + } as TextStyle, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 8, + borderWidth: 1, + borderColor: themeColors.border, + backgroundColor: 'transparent', + } as ViewStyle, + actionButtonText: { + fontSize: 11, + fontWeight: '600', + color: themeColors.text, + letterSpacing: 0.3, + } as TextStyle, + messagesSection: { + padding: 12, + } as ViewStyle, + messagesHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + } as ViewStyle, + messagesTitle: { + fontSize: 11, + fontWeight: '700', + color: themeColors.textMuted, + letterSpacing: 0.5, + } as TextStyle, + filterButton: { + paddingHorizontal: 8, + paddingVertical: 4, + borderWidth: 1, + borderColor: themeColors.border, + marginLeft: 4, + } as ViewStyle, + filterButtonActive: { + backgroundColor: themeColors.primaryContainer, + borderColor: themeColors.primaryContainer, + } as ViewStyle, + filterButtonText: { + fontSize: 10, + fontWeight: '600', + color: themeColors.textMuted, + } as TextStyle, + messageItem: { + padding: 10, + marginBottom: 6, + borderWidth: 1, + borderColor: themeColors.border, + backgroundColor: themeColors.surfaceContainer, + } as ViewStyle, + messageSent: { + borderLeftWidth: 2, + borderLeftColor: colors.websocket.sent, + } as ViewStyle, + messageReceived: { + borderLeftWidth: 2, + borderLeftColor: colors.websocket.received, + } as ViewStyle, + messageContent: { + position: 'relative', + marginTop: 8, + } as ViewStyle, + messageData: { + fontSize: 11, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + lineHeight: 16, + marginTop: 6, + } as TextStyle, + emptyMessages: { + padding: 20, + alignItems: 'center', + } as ViewStyle, + emptyMessagesText: { + fontSize: 12, + color: themeColors.textMuted, + } as TextStyle, +}); + +const staticStyles = { + stateColumn: { + width: 24, + } as ViewStyle, + stateIndicator: { + width: 10, + height: 10, + borderRadius: 5, + } as ViewStyle, + stateActive: { + shadowColor: '#10B981', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 4, + } as ViewStyle, + typeColumn: { + width: 40, + } as ViewStyle, + typeBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + alignSelf: 'flex-start', + } as ViewStyle, + typeText: { + fontSize: 10, + fontWeight: '700', + } as TextStyle, + urlColumn: { + flex: 1, + paddingHorizontal: 8, + } as ViewStyle, + statsColumn: { + alignItems: 'flex-end', + } as ViewStyle, + messageStats: { + flexDirection: 'row', + alignItems: 'center', + } as ViewStyle, + infoRow: { + marginBottom: 12, + } as ViewStyle, + infoGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, + } as ViewStyle, + infoCell: { + minWidth: 80, + } as ViewStyle, + actionBar: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + gap: 8, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.1)', + } as ViewStyle, + deleteButton: { + marginLeft: 'auto', + borderColor: colors.error, + } as ViewStyle, + errorBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: `${colors.error}15`, + } as ViewStyle, + errorText: { + fontSize: 11, + color: colors.error, + fontWeight: '500', + } as TextStyle, + filterButtons: { + flexDirection: 'row', + } as ViewStyle, + filterButtonTextActive: { + color: '#FFFFFF', + } as TextStyle, + messageHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + } as ViewStyle, + messageDirection: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + } as ViewStyle, + messageTime: { + fontSize: 10, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + } as TextStyle, + messageSize: { + fontSize: 10, + } as TextStyle, + messagesList: { + maxHeight: 300, + } as ViewStyle, + copyButtonFloat: { + position: 'absolute', + right: 0, + top: 0, + zIndex: 10, + } as ViewStyle, +}; + +export default WebSocketLogItem; +export type { WebSocketLogItemProps }; diff --git a/src/features/websocket/index.ts b/src/features/websocket/index.ts new file mode 100644 index 0000000..b31b696 --- /dev/null +++ b/src/features/websocket/index.ts @@ -0,0 +1,2 @@ +export { default as WebSocketLogItem } from './WebSocketLogItem'; +export type { WebSocketLogItemProps } from './WebSocketLogItem'; diff --git a/src/NetworkInterceptor.ts b/src/helpers/NetworkInterceptor.ts similarity index 96% rename from src/NetworkInterceptor.ts rename to src/helpers/NetworkInterceptor.ts index e56f1b6..9309bef 100644 --- a/src/NetworkInterceptor.ts +++ b/src/helpers/NetworkInterceptor.ts @@ -3,9 +3,9 @@ import type { NetworkLog, NetworkLoggerConfig, NetworkRequestHeaders, -} from './types'; -import { DEFAULT_CONFIG } from './constants/config'; -import { shouldIgnoreUrl, shouldIgnoreDomain } from './utils/filters'; +} from '../types'; +import { DEFAULT_CONFIG } from '../constants/config'; +import { shouldIgnoreUrl, shouldIgnoreDomain } from '../utils/filters'; interface CustomXMLHttpRequest extends XMLHttpRequest { open: (method: string, url: string) => void; @@ -18,11 +18,13 @@ class NetworkLogger { private listeners: LogListener[] = []; private isEnabled: boolean = true; private config: NetworkLoggerConfig = { ...DEFAULT_CONFIG }; + private isInterceptorSetup: boolean = false; constructor(config?: Partial) { this.logs = []; this.listeners = []; this.isEnabled = true; + this.isInterceptorSetup = false; if (config) { this.config = { ...DEFAULT_CONFIG, ...config }; } @@ -56,6 +58,7 @@ class NetworkLogger { public setupInterceptor(): void { if (!this.isEnabled) return; + this.isInterceptorSetup = true; const originalFetch = global.fetch; // eslint-disable-next-line consistent-this @@ -338,8 +341,12 @@ class NetworkLogger { public isLoggerEnabled(): boolean { return this.isEnabled; } + + public isSetup(): boolean { + return this.isInterceptorSetup; + } } -export const networkLogger: NetworkLogger = new NetworkLogger(); +export const networkLogger = new NetworkLogger(); export type { NetworkLog, NetworkRequestHeaders, LogListener }; diff --git a/src/helpers/WebSocketInterceptor.ts b/src/helpers/WebSocketInterceptor.ts new file mode 100644 index 0000000..5bbcdbb --- /dev/null +++ b/src/helpers/WebSocketInterceptor.ts @@ -0,0 +1,402 @@ +import type { + WebSocketLog, + WebSocketLogListener, + WebSocketLoggerConfig, + WebSocketMessage, +} from '../types'; +import { DEFAULT_WS_CONFIG } from '../constants/config'; + +class WebSocketLogger { + private logs: WebSocketLog[] = []; + private listeners: WebSocketLogListener[] = []; + private isEnabled: boolean = true; + private config: WebSocketLoggerConfig = { ...DEFAULT_WS_CONFIG }; + private connectionMap: Map = new Map(); + private originalWebSocket: typeof WebSocket | null = null; + private isInterceptorSetup: boolean = false; + + constructor(config?: Partial) { + this.logs = []; + this.listeners = []; + this.isEnabled = true; + this.isInterceptorSetup = false; + if (config) { + this.config = { ...DEFAULT_WS_CONFIG, ...config }; + } + } + + public configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + public getConfig(): WebSocketLoggerConfig { + return { ...this.config }; + } + + private shouldIgnoreUrl(url: string): boolean { + return this.config.ignoredUrls.some((pattern) => { + if (pattern.includes('*')) { + const regex = new RegExp(pattern.replace(/\*/g, '.*')); + return regex.test(url); + } + return url.includes(pattern); + }); + } + + private getMessageSize(data: unknown): number { + if (typeof data === 'string') { + return data.length * 2; // Rough estimate for UTF-16 + } + if (data instanceof ArrayBuffer) { + return data.byteLength; + } + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return data.size; + } + return 0; + } + + private stringifyData(data: unknown): string { + if (typeof data === 'string') { + return data; + } + if (data instanceof ArrayBuffer) { + return `[ArrayBuffer: ${data.byteLength} bytes]`; + } + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return `[Blob: ${data.size} bytes]`; + } + return String(data); + } + + public setupInterceptor(): void { + if (!this.isEnabled) return; + if (typeof global.WebSocket === 'undefined') return; + this.isInterceptorSetup = true; + + this.originalWebSocket = global.WebSocket; + // eslint-disable-next-line consistent-this + const self = this; + const OriginalWebSocket = this.originalWebSocket; + + // Create wrapper class + function InterceptedWebSocket( + url: string, + protocols?: string | string[] + ): WebSocket { + const urlString = String(url); + + // Create the actual WebSocket + const ws: WebSocket = protocols + ? new OriginalWebSocket(urlString, protocols) + : new OriginalWebSocket(urlString); + + // Check if should ignore + if (self.shouldIgnoreUrl(urlString)) { + return ws; + } + + const connectionId = Date.now() + Math.random(); + + const log: WebSocketLog = { + id: connectionId, + url: urlString, + state: 'connecting', + protocols: Array.isArray(protocols) + ? protocols + : protocols + ? [protocols] + : undefined, + connectTime: new Date().toISOString(), + messages: [], + messageCount: { sent: 0, received: 0 }, + bytesReceived: 0, + bytesSent: 0, + }; + + self.connectionMap.set(ws, log); + self.addLog(log); + + // Store original event handlers + let userOnOpen: WebSocket['onopen'] = null; + let userOnMessage: WebSocket['onmessage'] = null; + let userOnClose: WebSocket['onclose'] = null; + let userOnError: WebSocket['onerror'] = null; + + // Override onopen property + Object.defineProperty(ws, 'onopen', { + configurable: true, + get: () => userOnOpen, + set: (handler: WebSocket['onopen']) => { + userOnOpen = handler; + }, + }); + + // Override onmessage property + Object.defineProperty(ws, 'onmessage', { + configurable: true, + get: () => userOnMessage, + set: (handler: WebSocket['onmessage']) => { + userOnMessage = handler; + }, + }); + + // Override onclose property + Object.defineProperty(ws, 'onclose', { + configurable: true, + get: () => userOnClose, + set: (handler: WebSocket['onclose']) => { + userOnClose = handler; + }, + }); + + // Override onerror property + Object.defineProperty(ws, 'onerror', { + configurable: true, + get: () => userOnError, + set: (handler: WebSocket['onerror']) => { + userOnError = handler; + }, + }); + + // Add internal event listeners + const originalAddEventListener = ws.addEventListener.bind(ws); + + // Track events internally + originalAddEventListener('open', function (this: WebSocket) { + log.state = 'open'; + log.openTime = new Date().toISOString(); + log.handshakeDuration = + Date.now() - new Date(log.connectTime).getTime(); + self.updateLog(log); + if (userOnOpen) { + userOnOpen.call(this); + } + }); + + originalAddEventListener('message', function (this: WebSocket, event) { + if (self.config.captureMessages) { + const data = (event as MessageEvent).data; + const size = self.getMessageSize(data); + const message: WebSocketMessage = { + id: Date.now() + Math.random(), + direction: 'received', + data: self.stringifyData(data), + dataType: typeof data === 'string' ? 'text' : 'binary', + timestamp: new Date().toISOString(), + size, + }; + + // Enforce max messages limit + if (log.messages.length >= self.config.maxMessagesPerConnection) { + log.messages.shift(); + } + + log.messages.push(message); + log.messageCount.received++; + log.bytesReceived += size; + self.updateLog(log); + } + if (userOnMessage) { + userOnMessage.call(this, event as MessageEvent); + } + }); + + originalAddEventListener('close', function (this: WebSocket, event) { + const closeEvent = event as CloseEvent; + log.state = 'closed'; + log.closeTime = new Date().toISOString(); + log.closeCode = closeEvent.code; + log.closeReason = + closeEvent.reason || self.getCloseCodeDescription(closeEvent.code); + self.updateLog(log); + self.connectionMap.delete(ws); + if (userOnClose) { + userOnClose.call(this, closeEvent); + } + }); + + originalAddEventListener('error', function (this: WebSocket, event) { + log.error = 'WebSocket connection error'; + self.updateLog(log); + if (userOnError) { + userOnError.call(this, event); + } + }); + + // Override send method + const originalSend = ws.send.bind(ws); + (ws as { send: typeof ws.send }).send = function (data) { + if (self.config.captureMessages) { + const size = self.getMessageSize(data); + const message: WebSocketMessage = { + id: Date.now() + Math.random(), + direction: 'sent', + data: self.stringifyData(data), + dataType: typeof data === 'string' ? 'text' : 'binary', + timestamp: new Date().toISOString(), + size, + }; + + // Enforce max messages limit + if (log.messages.length >= self.config.maxMessagesPerConnection) { + log.messages.shift(); + } + + log.messages.push(message); + log.messageCount.sent++; + log.bytesSent += size; + self.updateLog(log); + } + return originalSend(data); + }; + + // Override close method + const originalClose = ws.close.bind(ws); + (ws as { close: typeof ws.close }).close = function (code, reason) { + log.state = 'closing'; + self.updateLog(log); + return originalClose(code, reason); + }; + + return ws; + } + + // Copy static properties and prototype + InterceptedWebSocket.prototype = OriginalWebSocket.prototype; + Object.defineProperty(InterceptedWebSocket, 'CONNECTING', { + value: OriginalWebSocket.CONNECTING, + writable: false, + }); + Object.defineProperty(InterceptedWebSocket, 'OPEN', { + value: OriginalWebSocket.OPEN, + writable: false, + }); + Object.defineProperty(InterceptedWebSocket, 'CLOSING', { + value: OriginalWebSocket.CLOSING, + writable: false, + }); + Object.defineProperty(InterceptedWebSocket, 'CLOSED', { + value: OriginalWebSocket.CLOSED, + writable: false, + }); + + // Replace global WebSocket + (global as { WebSocket: typeof WebSocket }).WebSocket = + InterceptedWebSocket as unknown as typeof WebSocket; + } + + private getCloseCodeDescription(code: number): string { + const descriptions: Record = { + 1000: 'Normal closure', + 1001: 'Going away', + 1002: 'Protocol error', + 1003: 'Unsupported data', + 1005: 'No status received', + 1006: 'Abnormal closure', + 1007: 'Invalid frame payload data', + 1008: 'Policy violation', + 1009: 'Message too big', + 1010: 'Mandatory extension', + 1011: 'Internal server error', + 1015: 'TLS handshake failure', + }; + return descriptions[code] || `Unknown (${code})`; + } + + public restoreInterceptor(): void { + if (this.originalWebSocket) { + (global as { WebSocket: typeof WebSocket }).WebSocket = + this.originalWebSocket; + this.originalWebSocket = null; + } + } + + private addLog(log: WebSocketLog): void { + this.logs.unshift(log); + + if (this.logs.length > this.config.maxConnections) { + this.logs = this.logs.slice(0, this.config.maxConnections); + } + + this.notifyListeners(); + } + + private updateLog(log: WebSocketLog): void { + const index = this.logs.findIndex((l) => l.id === log.id); + if (index !== -1) { + this.logs[index] = { ...log }; + this.notifyListeners(); + } + } + + private notifyListeners(): void { + this.listeners.forEach((listener) => listener([...this.logs])); + } + + public subscribe(listener: WebSocketLogListener): () => void { + this.listeners.push(listener); + // Immediately call with current logs + listener([...this.logs]); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + public clearLogs(): void { + this.logs = []; + this.notifyListeners(); + } + + public deleteLog(logId: number): void { + this.logs = this.logs.filter((log) => log.id !== logId); + this.notifyListeners(); + } + + public closeConnection(logId: number): void { + for (const [ws, log] of this.connectionMap.entries()) { + if (log.id === logId && ws.readyState === WebSocket.OPEN) { + ws.close(1000, 'Closed by debugger'); + break; + } + } + } + + public enable(): void { + this.isEnabled = true; + } + + public disable(): void { + this.isEnabled = false; + } + + public getLogs(): WebSocketLog[] { + return [...this.logs]; + } + + public getLogCount(): number { + return this.logs.length; + } + + public isLoggerEnabled(): boolean { + return this.isEnabled; + } + + public getActiveConnectionsCount(): number { + let count = 0; + for (const log of this.logs) { + if (log.state === 'open' || log.state === 'connecting') { + count++; + } + } + return count; + } + + public isSetup(): boolean { + return this.isInterceptorSetup; + } +} + +export const webSocketLogger = new WebSocketLogger(); + +export { WebSocketLogger }; diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..27bcfbb --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './NetworkInterceptor'; +export * from './WebSocketInterceptor'; diff --git a/src/Icon.tsx b/src/icons/Icon.tsx similarity index 100% rename from src/Icon.tsx rename to src/icons/Icon.tsx diff --git a/src/SvgIcon.tsx b/src/icons/SvgIcon.tsx similarity index 100% rename from src/SvgIcon.tsx rename to src/icons/SvgIcon.tsx diff --git a/src/icons/index.ts b/src/icons/index.ts new file mode 100644 index 0000000..7bb3987 --- /dev/null +++ b/src/icons/index.ts @@ -0,0 +1,2 @@ +export * from './SvgIcon'; +export { default as SvgIcon } from './SvgIcon'; diff --git a/src/index.tsx b/src/index.tsx index 22ae901..00dd30f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,21 @@ -export * from './NetworkInterceptor'; -export * from './NetworkLoggerOverlay'; +// Core helpers +export * from './helpers'; + +// Features +export * from './features/http'; +export * from './features/websocket'; + +// Types export * from './types'; + +// Constants export * from './constants'; + +// Utils export * from './utils'; + +// Components export * from './components'; -export { SvgIcon, isSvgAvailable, type IconName } from './SvgIcon'; + +// Icons +export { SvgIcon, isSvgAvailable, type IconName } from './icons'; diff --git a/src/types.ts b/src/types.ts index 340d9ab..820d023 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,76 @@ export interface NetworkLogger { getLogs?: () => NetworkLog[]; getLogCount?: () => number; isLoggerEnabled?: () => boolean; + isSetup: () => boolean; configure?: (config: Partial) => void; getConfig?: () => NetworkLoggerConfig; } + +// ============================================ +// WebSocket Types +// ============================================ + +export type WebSocketState = 'connecting' | 'open' | 'closing' | 'closed'; + +export type WebSocketMessageDirection = 'sent' | 'received'; + +export type WebSocketDataType = 'text' | 'binary'; + +export interface WebSocketMessage { + id: number; + direction: WebSocketMessageDirection; + data: string; + dataType: WebSocketDataType; + timestamp: string; + size: number; +} + +export interface WebSocketLog { + id: number; + url: string; + state: WebSocketState; + protocols?: string[]; + + // Connection lifecycle + connectTime: string; + openTime?: string; + closeTime?: string; + closeCode?: number; + closeReason?: string; + + // Messages + messages: WebSocketMessage[]; + messageCount: { sent: number; received: number }; + + // Errors + error?: string; + + // Metadata + handshakeDuration?: number; + bytesReceived: number; + bytesSent: number; +} + +export interface WebSocketLoggerConfig { + maxConnections: number; + maxMessagesPerConnection: number; + ignoredUrls: string[]; + captureMessages: boolean; +} + +export type WebSocketLogListener = (logs: WebSocketLog[]) => void; + +export interface IWebSocketLogger { + subscribe: (listener: WebSocketLogListener) => () => void; + clearLogs: () => void; + deleteLog: (logId: number) => void; + enable: () => void; + disable: () => void; + getLogs?: () => WebSocketLog[]; + getLogCount?: () => number; + isLoggerEnabled?: () => boolean; + isSetup: () => boolean; + configure?: (config: Partial) => void; + getConfig?: () => WebSocketLoggerConfig; + closeConnection?: (logId: number) => void; +}