diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..710c267 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,198 @@ +# React Native API Debugger - Development Guide + +## Project Overview + +A comprehensive network request debugging SDK for React Native applications. Intercepts and visualizes HTTP requests with a draggable overlay interface. + +## Architecture + +### Directory Structure + +``` +src/ +├── index.tsx # Public API exports +├── types.ts # TypeScript interfaces and types +├── NetworkInterceptor.ts # Core request interception engine +├── NetworkLoggerOverlay.tsx # Main overlay container component +├── NetworkLogItem.tsx # Individual log entry component +├── FloatingButton.tsx # Draggable trigger button +├── NonFloatingButton.tsx # Static trigger button fallback +├── CopyItem.tsx # Clipboard copy component +├── Icon.tsx # Emoji-based icon component +├── components/ # Reusable UI components (new) +│ ├── index.ts # Component exports +│ ├── Badge.tsx # Status badge component +│ ├── FilterChip.tsx # Filter tag component +│ ├── SearchInput.tsx # Search input component +│ └── JsonViewer.tsx # JSON tree viewer component +├── hooks/ # Custom React hooks (new) +│ ├── index.ts # Hook exports +│ ├── useNetworkLogs.ts # Log subscription hook +│ └── useFilteredLogs.ts # Log filtering hook +├── utils/ # Utility functions (new) +│ ├── index.ts # Utility exports +│ ├── formatters.ts # Data formatting utilities +│ ├── curl.ts # cURL generation +│ └── filters.ts # Filter logic +└── constants/ # Constants and config (new) + ├── index.ts # Constants exports + ├── colors.ts # Color palette + └── config.ts # Default configuration +``` + +### Component Design System + +#### Design Principles + +1. **Single Responsibility**: Each component handles one concern +2. **Composition over Inheritance**: Build complex UIs from simple pieces +3. **Graceful Degradation**: Features work without optional dependencies +4. **Zero Config Defaults**: Works out of the box with sensible defaults +5. **TypeScript First**: Full type safety with exported interfaces + +#### Component Categories + +| Category | Purpose | Examples | +|----------|---------|----------| +| **Core** | Business logic & interception | `NetworkInterceptor` | +| **Container** | State management & data flow | `NetworkLoggerOverlay` | +| **Presentational** | Pure UI rendering | `NetworkLogItem`, `Badge`, `FilterChip` | +| **Interactive** | User interaction handling | `FloatingButton`, `CopyItem` | +| **Utility** | Shared functionality | `Icon`, `JsonViewer` | + +#### Reusable Component Guidelines + +```typescript +// Component Template +interface ComponentProps { + // Required props first + requiredProp: string; + // Optional props with defaults + optionalProp?: boolean; + // Style overrides last + style?: StyleProp; +} + +const Component: React.FC = ({ + requiredProp, + optionalProp = false, + style, +}) => { + // Implementation +}; +``` + +### State Management + +- **Local State**: React `useState` for component-specific state +- **Shared State**: Pub/sub pattern via `NetworkLogger.subscribe()` +- **No External Dependencies**: No Redux/MobX required + +### Styling Conventions + +- Use `StyleSheet.create()` for all styles +- Define styles at bottom of component file +- Use semantic color names from `constants/colors.ts` +- Support both light and dark themes + +## Configuration + +### NetworkLogger Config Interface + +```typescript +interface NetworkLoggerConfig { + maxLogs: number; // Default: 100 + ignoredUrls: string[]; // URL patterns to ignore + ignoredDomains: string[]; // Domains to ignore + redactHeaders: string[]; // Headers to mask (e.g., 'Authorization') + enablePersistence: boolean; // Persist logs across sessions + slowRequestThreshold: number; // ms threshold for slow request warning +} +``` + +### Overlay Config Interface + +```typescript +interface NetworkLoggerOverlayProps { + networkLogger: NetworkLogger; + // Feature flags + draggable?: boolean; + enableDeviceShake?: boolean; + useCopyToClipboard?: boolean; + // Display options + showRequestHeader?: boolean; + showResponseHeader?: boolean; + theme?: 'light' | 'dark' | 'auto'; + // Filtering + defaultFilters?: FilterConfig; +} +``` + +## Development Workflow + +### Commands + +```bash +# Install dependencies +yarn install + +# Run example app +yarn example start + +# Type checking +yarn typecheck + +# Linting +yarn lint + +# Run tests +yarn test + +# Build library +yarn prepare + +# Release +yarn release +``` + +### Adding New Features + +1. Create feature branch from `main` +2. Add types to `types.ts` +3. Implement in appropriate directory (components/hooks/utils) +4. Export from index files +5. Add example usage in `example/src/` +6. Update README if public API changes +7. Write tests for new functionality + +### Testing Strategy + +- Unit tests for utility functions +- Component tests for UI components +- Integration tests for interceptor logic +- Manual testing via example app + +## Optional Peer Dependencies + +| Package | Required For | +|---------|--------------| +| `react-native-gesture-handler` | Draggable floating button | +| `react-native-reanimated` | Smooth animations | +| `react-native-shake` | Device shake detection | +| `@react-native-clipboard/clipboard` | Copy to clipboard | + +## Code Style + +- Use TypeScript strict mode +- Prefer functional components with hooks +- Use explicit return types on functions +- Document public APIs with JSDoc +- Follow React Native community conventions + +## Performance Guidelines + +- Limit stored logs (default: 100) +- Use `FlatList` with optimization props +- Lazy load optional dependencies +- Avoid re-renders with `React.memo` where appropriate +- Use `useCallback` for event handlers passed as props diff --git a/README.md b/README.md index 93a399f..2904645 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,68 @@ # React Native API Debugger -A comprehensive network request debugging tool for React Native applications. Monitor, inspect, and debug all your app's network requests with an intuitive draggable overlay interface. +A network request debugging tool for React Native applications. Monitor, inspect, and debug HTTP requests with a draggable overlay interface. -[![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) -[![License](https://img.shields.io/npm/l/react-native-api-debugger)](https://github.com/cmcWebCode40/react-native-api-debugger/blob/main/LICENSE) -[![Platform](https://img.shields.io/badge/platform-react--native-blue)](https://reactnative.dev/) -[![TypeScript](https://img.shields.io/badge/typescript-supported-blue)](https://www.typescriptlang.org/) +[![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) +[![license](https://img.shields.io/npm/l/react-native-api-debugger)](https://github.com/cmcWebCode40/react-native-api-debugger/blob/main/LICENSE) -React Native API Debugger +React Native API Debugger Demo -## ✨ Features +## Features -- 🔍 **Real-time Network Monitoring** - Automatically intercepts all `fetch()` and `XMLHttpRequest` calls -- 🎯 **Draggable Overlay Interface** - Non-intrusive floating button that can be moved anywhere on screen -- 📊 **Comprehensive Request Details** - View headers, body, response, timing, and status codes -- 📋 **cURL Generation** - Copy requests as cURL commands for easy debugging -- 🔎 **Advanced Filtering** - Search and filter by URL, method, status, or API endpoints -- ❌ **Error Tracking** - Easily identify failed requests and network errors -- 📱 **Device Shake Support** - Optional shake-to-show/hide functionality -- 📋 **Copy to Clipboard** - Optionally copy request/response/cURL details -- 🎨 **Customizable UI** - Configure header visibility and other display options -- 🚀 **TypeScript Support** - Full TypeScript definitions included -- 💾 **Memory Efficient** - Automatic cleanup with configurable request limits +- **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 +- **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 -## 📦 Installation +## Installation ```bash npm install react-native-api-debugger +# or +yarn add react-native-api-debugger ``` -> **Note:** Features like draggable overlay, device shake, and clipboard copy require additional peer dependencies. -> The debugger will throw a clear error if you enable a feature and the relevant library is not installed. - ### Optional Peer Dependencies -Install these only if you need advanced features: +Install only what you need: -```bash -npm install @react-native-clipboard/clipboard # For copy to clipboard -npm install react-native-shake # For device shake to show/hide -npm install react-native-gesture-handler # For draggable floating button -npm install react-native-reanimated # (Required for gesture handler) -``` +| Package | Required For | +|---------|--------------| +| `react-native-gesture-handler` | Draggable floating button | +| `react-native-reanimated` | Smooth drag animations | +| `react-native-shake` | Device shake detection | +| `@react-native-clipboard/clipboard` | Copy to clipboard | -> **Note:** For Expo SDK ≤ 53, use `react-native-reanimated` version 3.x.x +```bash +# For draggable button +npm install react-native-gesture-handler react-native-reanimated -### Platform Setup +# For device shake +npm install react-native-shake -If you use **draggable overlay** or **device shake**, also follow their respective setup guides: +# For clipboard +npm install @react-native-clipboard/clipboard +``` -- **react-native-gesture-handler**: [Installation Guide](https://docs.swmansion.com/react-native-gesture-handler/docs/installation) -- **react-native-reanimated**: [Installation Guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started) +> For Expo SDK ≤ 53, use `react-native-reanimated` version 3.x.x --- -## 🚀 Quick Start +## Quick Setup -### Minimal Example +### 1. Basic Usage (No Dependencies) -```typescript +```tsx import React, { useEffect } from 'react'; import { View } from 'react-native'; import { networkLogger, NetworkLoggerOverlay } from 'react-native-api-debugger'; @@ -72,262 +75,341 @@ export default function App() { return ( {/* Your app content */} - - {/* Minimal usage: All advanced features turned off */} + ); } ``` ---- +### 2. With Draggable Button -### Full Features Example +Requires `react-native-gesture-handler` and `react-native-reanimated`. -```typescript +```tsx +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { networkLogger, NetworkLoggerOverlay } from 'react-native-api-debugger'; + +export default function App() { + useEffect(() => { + networkLogger.setupInterceptor(); + }, []); + + return ( + + {/* Your app content */} + + + + ); +} +``` + +### 3. Full Featured Setup + +```tsx console.log('Theme:', theme)} /> ``` -> ⚠️ **Important:** -> If you enable `draggable`, `enableDeviceShake`, or `useCopyToClipboard` **without installing the corresponding peer dependency**, the debugger will throw a descriptive error at runtime, guiding you to install the required package. - --- -## 📚 API Reference +## Advanced Usage -### NetworkLoggerOverlay +### Configuration Options -The main UI component that displays the network logs with a draggable floating button interface. +Configure the interceptor with custom settings: -#### Props +```tsx +import { networkLogger } from 'react-native-api-debugger'; -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `networkLogger` | `NetworkLogger` | **Required** | The network logger instance | -| `enableDeviceShake` | `boolean` | `false` | Enable shake-to-show/hide functionality *(requires `react-native-shake`)* | -| `draggable` | `boolean` | `false` | Enable draggable floating button *(requires `react-native-gesture-handler` and `react-native-reanimated`)* | -| `useCopyToClipboard` | `boolean` | `false` | Enable copy to clipboard *(requires `@react-native-clipboard/clipboard`)* | -| `showRequestHeader` | `boolean` | `false` | Display request headers in log details | -| `showResponseHeader` | `boolean` | `false` | Display response headers in log details | +useEffect(() => { + networkLogger.configure({ + maxLogs: 50, // Maximum logs to store (default: 100) + ignoredUrls: ['/health', '/ping'], // URL patterns to ignore + ignoredDomains: ['analytics.example.com'], + ignoredMethods: ['OPTIONS'], // HTTP methods to ignore + slowRequestThreshold: 2000, // Mark requests slower than 2s + }); + + networkLogger.setupInterceptor(); +}, []); +``` -#### Usage Examples +### NetworkLogger Methods -```typescript -// Minimal setup - +```tsx +import { networkLogger } from 'react-native-api-debugger'; -// With device shake support (if installed) - +// Initialize interception +networkLogger.setupInterceptor(); -// Full-featured setup (requires peer dependencies) - +// Get all logs +const logs = networkLogger.getLogs(); + +// Get log count +const count = networkLogger.getLogCount(); + +// Clear all logs +networkLogger.clearLogs(); + +// Delete a specific log +networkLogger.deleteLog(logId); + +// Enable/disable logging +networkLogger.enable(); +networkLogger.disable(); + +// Check if enabled +const isEnabled = networkLogger.isLoggerEnabled(); + +// Update configuration +networkLogger.configure({ maxLogs: 200 }); + +// Get current configuration +const config = networkLogger.getConfig(); ``` -### NetworkLogger +### Subscribe to Log Changes -The core logger instance that handles request interception and storage. +```tsx +useEffect(() => { + const unsubscribe = networkLogger.subscribe((logs) => { + console.log('Logs updated:', logs.length); + }); + + return () => unsubscribe(); +}, []); +``` -#### Methods +### Export Utilities -| Method | Description | -|--------|-------------| -| `setupInterceptor()` | Initialize network request interception | -| `clearLogs()` | Clear all stored network logs | -| `getLogs()` | Get array of all stored network logs | +```tsx +import { exportToHAR, exportToPostman, exportLogs } from 'react-native-api-debugger'; -## 🛠️ Usage Patterns +const logs = networkLogger.getLogs(); -### Development vs Production +// Export to HAR format (for browser DevTools) +const harContent = exportToHAR(logs); -```typescript -import { networkLogger, NetworkLoggerOverlay } from 'react-native-api-debugger'; +// Export to Postman Collection +const postmanContent = exportToPostman(logs, 'My API Collection'); -export default function App() { - useEffect(() => { - // Only enable in development - if (__DEV__) { - networkLogger.setupInterceptor(); - } - }, []); +// Export to JSON +const jsonContent = exportLogs(logs, 'json'); +``` - return ( - - {/* Your app content */} - - {/* Only show in development */} - {__DEV__ && ( - - )} - - ); +### Request Replay + +```tsx +import { replayRequest, canReplayRequest, getReplayWarnings } from 'react-native-api-debugger'; + +const log = networkLogger.getLogs()[0]; + +// Check if request can be replayed +if (canReplayRequest(log)) { + // Get warnings (e.g., "This request may modify data") + const warnings = getReplayWarnings(log); + + // Replay with optional modifications + const result = await replayRequest(log, { + modifyHeaders: { 'Authorization': 'Bearer new-token' }, + timeout: 5000, + }); + + if (result.success) { + console.log('Response:', result.response); + } } ``` +### Sensitive Data Detection + +```tsx +import { detectSensitiveData, redactNetworkLog } from 'react-native-api-debugger'; + +const log = networkLogger.getLogs()[0]; + +// Check for sensitive data +const info = detectSensitiveData(log); +if (info.hasSensitiveHeaders) { + console.log('Contains sensitive headers'); +} + +// Redact sensitive data before sharing +const redactedLog = redactNetworkLog(log, { enabled: true }); +``` + +### cURL Generation + +```tsx +import { generateCurl } from 'react-native-api-debugger'; + +const log = networkLogger.getLogs()[0]; +const curlCommand = generateCurl(log); +// curl -X POST 'https://api.example.com/users' -H 'Content-Type: application/json' -d '{"name":"John"}' +``` + --- -## 🎯 TypeScript Support +## API Reference + +### NetworkLoggerOverlay Props -The package includes comprehensive TypeScript definitions: +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `networkLogger` | `NetworkLogger` | Required | The logger instance | +| `enabled` | `boolean` | `__DEV__` | Enable/disable the overlay | +| `draggable` | `boolean` | `false` | Enable draggable button | +| `enableDeviceShake` | `boolean` | `false` | Show on device shake | +| `useCopyToClipboard` | `boolean` | `false` | Enable clipboard copy | +| `showRequestHeader` | `boolean` | `false` | Show request headers | +| `showResponseHeader` | `boolean` | `false` | Show response headers | +| `theme` | `'light' \| 'dark'` | `'light'` | Color theme | +| `onThemeChange` | `(theme) => void` | - | Theme change callback | + +### NetworkLoggerConfig + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxLogs` | `number` | `100` | Maximum logs to store | +| `ignoredUrls` | `string[]` | `[]` | URL patterns to ignore | +| `ignoredDomains` | `string[]` | `[]` | Domains to ignore | +| `ignoredMethods` | `string[]` | `[]` | HTTP methods to ignore | +| `redactHeaders` | `string[]` | `['Authorization', ...]` | Headers to redact | +| `enableRedaction` | `boolean` | `false` | Enable auto-redaction | +| `slowRequestThreshold` | `number` | `3000` | Slow request threshold (ms) | + +### NetworkLog Type ```typescript -import type { - NetworkLog, - NetworkResponse, - NetworkRequestHeaders, - LogListener, - NetworkLoggerConfig -} from 'react-native-api-debugger'; - -// Example: Custom log processing -const processNetworkLogs = (logs: NetworkLog[]) => { - const failedRequests = logs.filter(log => - log.response && log.response.status >= 400 - ); - - console.log(`Found ${failedRequests.length} failed requests`); -}; +interface NetworkLog { + id: number; + method: string; + url: string; + headers: Record; + body: string | null; + timestamp: string; + startTime: number; + response?: { + status: number; + statusText: string; + headers: Record; + body: string; + duration: number; + }; + error?: string; + duration?: number; + isSlow?: boolean; + bookmarked?: boolean; +} ``` -## 🔧 Troubleshooting +--- -### Common Issues +## Production Safety -#### TypeScript Errors -```typescript -// Ensure correct imports for types -import type { NetworkLog } from 'react-native-api-debugger'; +The overlay is disabled by default in production (`enabled` defaults to `__DEV__`). To explicitly control: + +```tsx + ``` -#### Gesture Handler Setup -```typescript -// Make sure to wrap your app with GestureHandlerRootView for draggable functionality +For staging environments: + +```tsx +const showDebugger = __DEV__ || process.env.STAGING === 'true'; + + +``` + +--- + +## Troubleshooting + +### Draggable button not working + +Ensure your app is wrapped with `GestureHandlerRootView`: + +```tsx import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function App() { return ( - {/* Your app content */} + {/* App content */} ); } ``` -### Performance Considerations - -- **Memory Management**: The logger automatically limits stored requests to prevent memory issues (default: 100 requests) -- **Production Builds**: Always disable in production to avoid performance impact -- **Large Responses**: Consider filtering or truncating large response bodies - +### Share sheet not appearing -### Production Safety +The share sheet requires the main modal to close first. This is handled automatically - ensure you're using the latest version. -```typescript -// Environment-based configuration -const isDev = __DEV__; -const isStaging = process.env.NODE_ENV === 'staging'; - -const showLogger = isDev || isStaging; +### Requests not being captured -export default function App() { - useEffect(() => { - if (showLogger) { - networkLogger.setupInterceptor(); - } - }, []); +Make sure `setupInterceptor()` is called before any network requests: - return ( - - {/* Your app content */} - {showLogger && ( - - )} - - ); -} +```tsx +useEffect(() => { + networkLogger.setupInterceptor(); +}, []); // Empty dependency array - runs once on mount ``` -## 🤝 Contributing +--- -We welcome contributions! Here's how to get started: +## Contributing -1. **Fork the repository** -2. **Clone your fork**: `git clone https://github.com/cmcWebCode40/react-native-api-debugger.git` -3. **Install dependencies**: `npm install` -4. **Create a feature branch**: `git checkout -b feature/amazing-feature` -5. **Make your changes** and add tests -6. **Run tests**: `npm test` -7. **Commit changes**: `git commit -m 'Add amazing feature'` -8. **Push to branch**: `git push origin feature/amazing-feature` -9. **Open a Pull Request** +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make changes and run tests: `yarn test` +4. Commit: `git commit -m 'Add my feature'` +5. Push: `git push origin feature/my-feature` +6. Open a Pull Request -### Development Setup +### Development ```bash -# Clone the repository git clone https://github.com/cmcWebCode40/react-native-api-debugger.git - -# Install dependencies cd react-native-api-debugger -npm install +yarn install -# Run tests -npm test - -# Start the example app +# Run example app cd example -npm install -npx react-native run-ios # or run-android +yarn install +yarn ios # or yarn android ``` -## 📄 License - -MIT License - see the [LICENSE](LICENSE) file for details. - -## 🆘 Support & Community - -- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/cmcWebCode40/react-native-api-debugger/issues) -- 💡 **Feature Requests**: [GitHub Discussions](https://github.com/cmcWebCode40/react-native-api-debugger/discussions) -- 📖 **Documentation**: [Wiki](https://github.com/cmcWebCode40/react-native-api-debugger/wiki) -- 💬 **Community**: [Discord Server](https://discord.gg/your-discord) -- 🐦 **Updates**: [@YourTwitter](https://twitter.com/your-twitter) - -## 📊 Stats & Recognition - -
+--- -**Made with ❤️ for the React Native community** +## License -
+MIT License - see [LICENSE](LICENSE) for details. ---- +## Support -*Built with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)* \ No newline at end of file +- Bug Reports: [GitHub Issues](https://github.com/cmcWebCode40/react-native-api-debugger/issues) +- Feature Requests: [GitHub Discussions](https://github.com/cmcWebCode40/react-native-api-debugger/discussions) diff --git a/demo/demo_RN_logger.png b/demo/demo_RN_logger.png index b2e26df..090dfd1 100644 Binary files a/demo/demo_RN_logger.png and b/demo/demo_RN_logger.png differ diff --git a/demo/lib-demo.gif b/demo/lib-demo.gif index 08848bf..357c50e 100644 Binary files a/demo/lib-demo.gif and b/demo/lib-demo.gif differ diff --git a/example/src/App.tsx b/example/src/App.tsx index f7f1127..eee236c 100755 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -8,6 +8,7 @@ import { ScrollView, Alert, ActivityIndicator, + Switch, } from 'react-native'; import { networkLogger, NetworkLoggerOverlay } from 'react-native-api-debugger'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -72,8 +73,15 @@ const ButtonComponent: React.FC = ({ const App: React.FC = () => { const [loading, setLoading] = useState({}); + const [showHeaders, setShowHeaders] = useState(false); useEffect(() => { + networkLogger.configure({ + maxLogs: 50, + ignoredUrls: ['/health', '/ping'], + ignoredDomains: ['analytics.example.com'], + slowRequestThreshold: 2000, + }); networkLogger.setupInterceptor(); }, []); @@ -214,7 +222,7 @@ const App: React.FC = () => { ); }; - // Test Error Request + // Test Error Request (404) const handleErrorRequest = async (): Promise => { await makeAPIRequest( 'https://jsonplaceholder.typicode.com/posts/999999', @@ -229,6 +237,52 @@ const App: React.FC = () => { ); }; + // 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 ( @@ -280,12 +334,46 @@ const App: React.FC = () => { Error & Edge Cases + + + + + + + + Display Settings + + Show Headers + @@ -294,19 +382,41 @@ const App: React.FC = () => { 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. Expand requests to see headers & responses{'\n'} - 5. Use cURL generation for debugging + 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 */} @@ -400,6 +510,59 @@ const styles = StyleSheet.create({ 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/package.json b/package.json index 34897f1..01f87e3 100644 --- a/package.json +++ b/package.json @@ -87,12 +87,12 @@ "typescript": "^5.8.3" }, "peerDependencies": { + "@react-native-clipboard/clipboard": "*", "react": "*", "react-native": "*", "react-native-gesture-handler": "2.24.0", "react-native-reanimated": "3.19.1", - "react-native-shake": "*", - "@react-native-clipboard/clipboard": "*" + "react-native-shake": "*" }, "workspaces": [ "example" diff --git a/src/FloatingButton.tsx b/src/FloatingButton.tsx index caeb672..e2ce13e 100644 --- a/src/FloatingButton.tsx +++ b/src/FloatingButton.tsx @@ -8,6 +8,7 @@ import { import React, { useEffect } from 'react'; import Icon from './Icon'; import NonFloatingButton from './NonFloatingButton'; +import { colors } from './constants/colors'; const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); let Animated: any = null; @@ -51,6 +52,7 @@ interface FloatingButtonProps { openModal: () => void; hideIcon: () => void; logsLength: number; + errorCount?: number; draggable?: boolean; enableDeviceShake?: boolean; } @@ -59,6 +61,7 @@ const AnimatedFloatingButton: React.FC = ({ hideIcon, openModal, logsLength, + errorCount = 0, enableDeviceShake, }) => { const positionX = useSharedValue(screenWidth - BUTTON_SIZE - PADDING); @@ -130,6 +133,13 @@ const AnimatedFloatingButton: React.FC = ({ style={styles.buttonTouchable} > 📊 {logsLength} + {errorCount > 0 && ( + + + {errorCount > 99 ? '99+' : errorCount} + + + )} @@ -215,6 +225,25 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: 'bold', }, + errorBadge: { + position: 'absolute', + top: -4, + right: -4, + backgroundColor: colors.error, + borderRadius: 10, + minWidth: 20, + height: 20, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + borderWidth: 2, + borderColor: '#fff', + }, + errorBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', + }, }); export default FloatingButton; diff --git a/src/NetworkInterceptor.ts b/src/NetworkInterceptor.ts index 5cb01a9..e56f1b6 100644 --- a/src/NetworkInterceptor.ts +++ b/src/NetworkInterceptor.ts @@ -1,4 +1,11 @@ -import type { LogListener, NetworkLog, NetworkRequestHeaders } from './types'; +import type { + LogListener, + NetworkLog, + NetworkLoggerConfig, + NetworkRequestHeaders, +} from './types'; +import { DEFAULT_CONFIG } from './constants/config'; +import { shouldIgnoreUrl, shouldIgnoreDomain } from './utils/filters'; interface CustomXMLHttpRequest extends XMLHttpRequest { open: (method: string, url: string) => void; @@ -10,17 +17,49 @@ class NetworkLogger { private logs: NetworkLog[] = []; private listeners: LogListener[] = []; private isEnabled: boolean = true; + private config: NetworkLoggerConfig = { ...DEFAULT_CONFIG }; - constructor() { + constructor(config?: Partial) { this.logs = []; this.listeners = []; this.isEnabled = true; + if (config) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + } + + public configure(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + public getConfig(): NetworkLoggerConfig { + return { ...this.config }; + } + + private shouldIgnoreRequest(url: string, method: string): boolean { + if (shouldIgnoreUrl(url, this.config.ignoredUrls)) { + return true; + } + if (shouldIgnoreDomain(url, this.config.ignoredDomains)) { + return true; + } + if ( + this.config.ignoredMethods.length > 0 && + this.config.ignoredMethods + .map((m) => m.toUpperCase()) + .includes(method.toUpperCase()) + ) { + return true; + } + return false; } public setupInterceptor(): void { if (!this.isEnabled) return; const originalFetch = global.fetch; + // eslint-disable-next-line consistent-this + const self = this; global.fetch = async ( input: RequestInfo | URL, @@ -30,13 +69,18 @@ class NetworkLogger { const requestId: number = Date.now() + Math.random(); const url: string = input.toString(); const options: RequestInit = init || {}; + const method = options.method || 'GET'; + + if (self.shouldIgnoreRequest(url, method)) { + return originalFetch(input, init); + } const requestLog: NetworkLog = { id: requestId, - method: options.method || 'GET', + method, url, - headers: this.processRequestHeaders(options.headers), - body: options.body ? this.stringifyBody(options.body) : null, + headers: self.processRequestHeaders(options.headers), + body: options.body ? self.stringifyBody(options.body) : null, timestamp: new Date().toISOString(), startTime, }; @@ -52,22 +96,24 @@ class NetworkLogger { responseBody = 'Unable to read response body'; } + const duration = Date.now() - startTime; requestLog.response = { status: response.status, statusText: response.statusText, - headers: this.headersToObject(response.headers), + headers: self.headersToObject(response.headers), body: responseBody, - duration: Date.now() - startTime, + duration, }; + requestLog.isSlow = duration > self.config.slowRequestThreshold; - this.addLog(requestLog); + self.addLog(requestLog); return response; } catch (error) { const errorMessage: string = error instanceof Error ? error.message : 'Unknown error'; requestLog.error = errorMessage; requestLog.duration = Date.now() - startTime; - this.addLog(requestLog); + self.addLog(requestLog); throw error; } }; @@ -83,6 +129,7 @@ class NetworkLogger { global.XMLHttpRequest = function (): CustomXMLHttpRequest { const xhr = new originalXHR() as CustomXMLHttpRequest; const requestId: number = Date.now() + Math.random(); + let shouldIgnore = false; let requestLog: NetworkLog = { id: requestId, method: '', @@ -100,22 +147,29 @@ class NetworkLogger { xhr.open = function (method: string, url: string): void { requestLog.method = method; requestLog.url = url; + shouldIgnore = self.shouldIgnoreRequest(url, method); return originalOpen.call(this, method, url); }; xhr.setRequestHeader = function (header: string, value: string): void { - requestLog.headers[header] = value; + if (!shouldIgnore) { + requestLog.headers[header] = value; + } return originalSetRequestHeader.call(this, header, value); }; xhr.send = function (body?: Document | any | null): void { + if (shouldIgnore) { + return originalSend.call(this, body); + } + requestLog.body = body ? self.stringifyBody(body) : null; requestLog.startTime = Date.now(); const originalOnReadyStateChange = xhr.onreadystatechange; xhr.onreadystatechange = function (): void { - if (xhr.readyState === 4) { + if (xhr.readyState === 4 && !shouldIgnore) { let responseBody: string = ''; try { @@ -146,13 +200,15 @@ class NetworkLogger { } catch (error) { responseBody = `[Error reading response: ${error instanceof Error ? error.message : 'Unknown error'}]`; } + const duration = Date.now() - requestLog.startTime; requestLog.response = { status: xhr.status, statusText: xhr.statusText, headers: self.parseXHRHeaders(xhr.getAllResponseHeaders()), body: responseBody, - duration: Date.now() - requestLog.startTime, + duration, }; + requestLog.isSlow = duration > self.config.slowRequestThreshold; self.addLog(requestLog); } @@ -237,8 +293,8 @@ class NetworkLogger { private addLog(log: NetworkLog): void { this.logs.unshift(log); - if (this.logs.length > 100) { - this.logs = this.logs.slice(0, 100); + if (this.logs.length > this.config.maxLogs) { + this.logs = this.logs.slice(0, this.config.maxLogs); } this.listeners.forEach((listener: LogListener) => listener([...this.logs])); @@ -258,6 +314,11 @@ class NetworkLogger { this.listeners.forEach((listener: LogListener) => listener([])); } + public deleteLog(logId: number): void { + this.logs = this.logs.filter((log) => log.id !== logId); + this.listeners.forEach((listener: LogListener) => listener([...this.logs])); + } + public enable(): void { this.isEnabled = true; } diff --git a/src/NetworkLogItem.tsx b/src/NetworkLogItem.tsx index 0ee73c3..4b6f921 100644 --- a/src/NetworkLogItem.tsx +++ b/src/NetworkLogItem.tsx @@ -1,20 +1,39 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { ScrollView, - StyleSheet, Text, TouchableOpacity, View, + Alert, + ActivityIndicator, + Share, + type ViewStyle, + type TextStyle, } from 'react-native'; import type { NetworkLog, NetworkRequestHeaders } from './types'; +import type { ThemeMode } from './constants/colors'; +import { colors, getThemeColors, getStatusColor } from './constants/colors'; import { CopyItem } from './CopyItem'; +import { JsonViewer } from './components/JsonViewer'; import Icon from './Icon'; +import { formatTimestamp } from './utils/formatters'; +import { generateCurl } from './utils/curl'; +import { + replayRequest, + canReplayRequest, + getReplayWarnings, +} from './utils/replay'; +import { exportSingleRequestAsJSON } from './utils/export'; +import { detectSensitiveData } from './utils/redact'; interface NetworkLogItemProps { log: NetworkLog; showRequestHeader?: boolean; showResponseHeader?: boolean; useCopyToClipboard?: boolean; + theme?: ThemeMode; + onDelete?: (logId: number) => void; + onCloseModal?: () => void; } const NetworkLogItem: React.FC = ({ @@ -22,55 +41,96 @@ const NetworkLogItem: React.FC = ({ useCopyToClipboard, showResponseHeader = false, showRequestHeader = false, + theme = 'light', + onDelete, + onCloseModal, }) => { const [expanded, setExpanded] = useState(false); + const [isReplaying, setIsReplaying] = useState(false); + const themeColors = useMemo(() => getThemeColors(theme), [theme]); + const themedStyles = useMemo( + () => createThemedStyles(themeColors), + [themeColors] + ); - const getStatusColor = (status?: number): string => { - if (!status) return '#9E9E9E'; - if (status >= 200 && status < 300) return '#4CAF50'; - if (status >= 300 && status < 400) return '#FF9800'; - if (status >= 400) return '#F44336'; - return '#9E9E9E'; - }; + const sensitiveDataInfo = useMemo(() => detectSensitiveData(log), [log]); + const canReplay = useMemo(() => canReplayRequest(log), [log]); + const replayWarnings = useMemo(() => getReplayWarnings(log), [log]); - const formatHeaders = (headers?: NetworkRequestHeaders): string => { - if (!headers || typeof headers !== 'object') return 'No headers'; - return Object.entries(headers) - .map(([key, value]: [string, string]) => `${key}: ${value}`) - .join('\n'); - }; + const handleReplay = useCallback(async () => { + if (replayWarnings.length > 0) { + Alert.alert( + 'Replay Request', + `Warning:\n${replayWarnings.join('\n')}\n\nDo you want to continue?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Replay', + onPress: async () => { + setIsReplaying(true); + try { + const result = await replayRequest(log); + if (result.success) { + Alert.alert( + 'Replay Successful', + `Status: ${result.response?.status}\nDuration: ${result.response?.duration}ms` + ); + } else { + Alert.alert('Replay Failed', result.error || 'Unknown error'); + } + } finally { + setIsReplaying(false); + } + }, + }, + ] + ); + } else { + setIsReplaying(true); + try { + const result = await replayRequest(log); + if (result.success) { + Alert.alert( + 'Replay Successful', + `Status: ${result.response?.status}\nDuration: ${result.response?.duration}ms` + ); + } else { + Alert.alert('Replay Failed', result.error || 'Unknown error'); + } + } finally { + setIsReplaying(false); + } + } + }, [log, replayWarnings]); - const formatBody = (body?: string | null): string => { - if (!body) return 'No body'; + const handleShare = useCallback(async () => { try { - const parsed = JSON.parse(body); - return JSON.stringify(parsed, null, 2); - } catch { - return body; + const content = exportSingleRequestAsJSON(log); + onCloseModal?.(); + setTimeout(async () => { + await Share.share({ + message: content, + title: `${log.method} ${log.url}`, + }); + }, 300); + } catch (error) { + // User cancelled or error } - }; + }, [log, onCloseModal]); - const generateCurl = (apiLog: NetworkLog): string => { - let curl: string = `curl -X ${apiLog.method}`; - - if (apiLog.headers && typeof apiLog.headers === 'object') { - Object.entries(apiLog.headers).forEach( - ([key, value]: [string, string]) => { - curl += ` \\\n -H "${key}: ${value}"`; - } - ); - } + const handleDelete = useCallback(() => { + onDelete?.(log.id); + }, [log.id, onDelete]); - if (apiLog.body) { - curl += ` \\\n -d '${apiLog.body}'`; - } - curl += ` \\\n "${apiLog.url}"`; - return curl; + const formatHeaders = (headers?: NetworkRequestHeaders): string => { + if (!headers || typeof headers !== 'object') return 'No headers'; + return Object.entries(headers) + .map(([key, value]: [string, string]) => `${key}: ${value}`) + .join('\n'); }; const handleHeaderPress = (): void => { setExpanded(!expanded); - generateCurl(log); }; const getStatusText = (): string => { @@ -87,98 +147,180 @@ const NetworkLogItem: React.FC = ({ return log.response?.duration || log.duration || 0; }; + const statusColor = getStatusColor(log.response?.status); + const hasError = + !!log.error || (log.response?.status && log.response.status >= 400); + const isSlow = log.isSlow; + return ( - + - - {log.method} - + + + + {log.method} + + + {formatTimestamp(log.timestamp)} + + + {log.url} - + - {getStatusText()} + {getStatusText()} - {getDuration()}ms + + {getDuration()}ms + {isSlow && ' 🐢'} + + {hasError && ( + + ! + + )} - {expanded ? : } + + {onDelete && ( + + + + )} + + {expanded && ( - + + + + {isReplaying ? ( + + ) : ( + ▶ Replay + )} + + + ↗ Share + + {(sensitiveDataInfo.hasSensitiveHeaders || + sensitiveDataInfo.hasSensitiveBody || + sensitiveDataInfo.hasSensitiveUrl) && ( + + + ⚠️ Contains sensitive data + + + )} + - - Copy Curl - + + cURL Command + + {generateCurl(log)} + + + {showRequestHeader && ( - - Request Headers: - - {formatHeaders(log.headers)} - + + Request Headers + + + {formatHeaders(log.headers)} + + + + )} + + {log.body && ( + + Request Body + )} - - {log.body && ( - <> - Request Body: - {formatBody(log.body)} - - )} - {log.response && ( <> {showResponseHeader && ( - - Response Headers: - - {formatHeaders(log.response.headers)} + + + Response Headers + + + {formatHeaders(log.response.headers)} + + )} - - Response Body: - - {formatBody(log.response.body)} - - + + Response Body + + + )} {log.error && ( - <> - Error: - - {log.error} - - + + Error + + {log.error} + + )} @@ -187,11 +329,21 @@ const NetworkLogItem: React.FC = ({ ); }; -const styles = StyleSheet.create({ - errorColor: { color: '#F44336' }, - copyItemPosition: { top: '30%' }, +interface ThemeColors { + background: string; + surface: string; + surfaceSecondary: string; + border: string; + borderLight: string; + text: string; + textSecondary: string; + textMuted: string; + codeBackground: string; +} + +const createThemedStyles = (themeColors: ThemeColors) => ({ logItem: { - backgroundColor: '#fff', + backgroundColor: themeColors.surface, marginBottom: 8, borderRadius: 8, elevation: 2, @@ -199,88 +351,187 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.2, shadowRadius: 2, - }, - clipboardContainer: { position: 'relative' }, - copyToClipboard: { - position: 'absolute', - right: '5%', - top: '15%', - }, + } as ViewStyle, logHeader: { flexDirection: 'row', alignItems: 'center', padding: 12, - }, - logSummary: { - flex: 1, - }, - method: { + } as ViewStyle, + url: { fontSize: 14, + color: themeColors.text, + marginBottom: 6, fontWeight: '600', - color: '#2196F3', + } as TextStyle, + timestamp: { + fontSize: 11, + color: themeColors.textMuted, + } as TextStyle, + duration: { + fontSize: 12, + color: themeColors.textSecondary, + } as TextStyle, + logDetails: { + borderTopWidth: 1, + borderTopColor: themeColors.borderLight, + backgroundColor: themeColors.surfaceSecondary, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + } as ViewStyle, + sectionTitle: { + fontSize: 14, + fontWeight: '600', + color: themeColors.text, + marginBottom: 8, + } as TextStyle, + codeBlock: { + backgroundColor: themeColors.codeBackground, + padding: 12, + borderRadius: 6, + position: 'relative', + } as ViewStyle, + codeText: { + fontSize: 12, + fontFamily: 'monospace', + color: themeColors.textSecondary, + lineHeight: 18, + } as TextStyle, +}); + +const staticStyles = { + logSummary: { + flex: 1, + } as ViewStyle, + methodRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', marginBottom: 4, - }, - url: { + } as ViewStyle, + method: { fontSize: 14, - color: '#333', - marginBottom: 6, fontWeight: '600', - }, + } as TextStyle, + headerActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + } as ViewStyle, + deleteButton: { + width: 20, + height: 20, + borderRadius: 12, + backgroundColor: colors.error, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: '#f6f6f6', + } as ViewStyle, + deleteButtonText: { + color: '#fff', + fontSize: 12, + fontWeight: 'bold', + } as TextStyle, statusContainer: { flexDirection: 'row', alignItems: 'center', - }, + } as ViewStyle, statusBadge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 12, marginRight: 8, - }, + } as ViewStyle, statusText: { fontSize: 12, color: '#fff', fontWeight: 'bold', - }, - duration: { - fontSize: 12, - color: '#666', - }, - expandIcon: { - fontSize: 16, - color: '#666', - marginLeft: 8, + } as TextStyle, + slowDuration: { + color: colors.warning, fontWeight: '600', - }, - logDetails: { - borderTopWidth: 1, - borderTopColor: '#eee', - backgroundColor: '#f9f9f9', - borderRadius: 16, - }, + } as TextStyle, + errorIndicator: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: colors.error, + justifyContent: 'center', + alignItems: 'center', + marginLeft: 8, + } as ViewStyle, + errorIndicatorText: { + color: '#fff', + fontSize: 12, + fontWeight: 'bold', + } as TextStyle, detailsScroll: { - maxHeight: 400, + maxHeight: 450, padding: 12, - marginBottom: 24, - }, - sectionTitle: { - fontSize: 16, - fontWeight: 'bold', - color: '#333', - marginTop: 8, - marginBottom: 4, - }, - codeText: { - fontSize: 14, - fontFamily: 'monospace', - color: '#666', - backgroundColor: '#f0f0f0', - padding: 8, - borderRadius: 4, + } as ViewStyle, + section: { + marginBottom: 16, + } as ViewStyle, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', marginBottom: 8, - fontWeight: '500', + } as ViewStyle, + curlSection: { + marginBottom: 16, + } as ViewStyle, + copyButton: { + position: 'absolute', + right: 8, + top: 8, + } as ViewStyle, + errorBlock: { + borderLeftWidth: 3, + borderLeftColor: colors.error, + } as ViewStyle, + errorText: { + fontSize: 12, + fontFamily: 'monospace', + color: colors.error, lineHeight: 18, - }, -}); + } as TextStyle, + actionBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingTop: 12, + paddingBottom: 4, + gap: 8, + } as ViewStyle, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.info, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + } as ViewStyle, + actionButtonDisabled: { + backgroundColor: '#999', + opacity: 0.6, + } as ViewStyle, + actionButtonText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + } as TextStyle, + sensitiveWarning: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 'auto', + } as ViewStyle, + sensitiveWarningText: { + fontSize: 11, + color: colors.warning, + fontWeight: '500', + } as TextStyle, +}; export default NetworkLogItem; export type { NetworkLogItemProps }; diff --git a/src/NetworkLoggerOverlay.tsx b/src/NetworkLoggerOverlay.tsx index 5b00562..5b62acb 100644 --- a/src/NetworkLoggerOverlay.tsx +++ b/src/NetworkLoggerOverlay.tsx @@ -1,21 +1,27 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Alert, FlatList, Modal, SafeAreaView, - StyleSheet, 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 } from './constants/colors'; +import { filterLogs, getLogStats, defaultFilterState } from './utils/filters'; +import type { FilterState } from './utils/filters'; +import { ExportModal } from './components/ExportModal'; let RNShake: any = null; @@ -27,28 +33,76 @@ try { interface NetworkLoggerOverlayProps { networkLogger: NetworkLogger; + /** + * Controls whether the overlay is enabled. Defaults to __DEV__ (development mode only). + * Set to true to force enable in production (not recommended). + * Set to false to disable even in development. + */ + 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' }, + { key: 'error', label: 'Errors' }, +]; + 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 [searchTerm, setSearchTerm] = useState(''); - const [apiFilterActive, setApiFilterActive] = useState(false); + const [filters, setFilters] = useState(defaultFilterState); const [showButton, setShowButton] = useState(!enableDeviceShake); + const [internalTheme, setInternalTheme] = useState( + themeProp ?? 'light' + ); + 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) { @@ -58,7 +112,9 @@ export const NetworkLoggerOverlay: React.FC = ({ } if (!enableDeviceShake) { - return; + return () => { + unsubscribe(); + }; } const subscription = RNShake.addListener(() => { setShowButton(true); @@ -67,25 +123,40 @@ export const NetworkLoggerOverlay: React.FC = ({ unsubscribe(); subscription?.remove(); }; - }, [networkLogger, enableDeviceShake]); + }, [networkLogger, enableDeviceShake, enabled]); - const handleCloseIcon = () => { + const handleCloseIcon = useCallback(() => { setShowButton(false); - }; - const filteredLogs: NetworkLog[] = logs.filter((log: NetworkLog) => { - const matchesSearch: boolean = - log.url.toLowerCase().includes(searchTerm.toLowerCase()) || - log.method.toLowerCase().includes(searchTerm.toLowerCase()); - - if (apiFilterActive) { - const hasApi: boolean = log.url.toLowerCase().includes('api'); - return matchesSearch && hasApi; - } + }, []); + + 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 })); + }, + [] + ); - return matchesSearch; - }); + const clearFilters = useCallback(() => { + setFilters(defaultFilterState); + }, []); - const handleClearLogs = (): void => { + const hasActiveFilters = useMemo(() => { + return ( + filters.searchTerm !== '' || + filters.statusFilter !== 'all' || + filters.methodFilter !== null || + filters.apiOnly + ); + }, [filters]); + + const handleClearLogs = useCallback((): void => { Alert.alert( 'Clear Logs', 'Are you sure you want to clear all network logs?', @@ -94,23 +165,40 @@ export const NetworkLoggerOverlay: React.FC = ({ { text: 'Clear', onPress: () => networkLogger.clearLogs() }, ] ); - }; + }, [networkLogger]); + + const handleDeleteLog = useCallback( + (logId: number): void => { + networkLogger.deleteLog(logId); + }, + [networkLogger] + ); - const handleModalOpen = (): void => { + const handleModalOpen = useCallback((): void => { setVisible(true); - }; + }, []); - const handleModalClose = (): void => { + const handleModalClose = useCallback((): void => { setVisible(false); - }; + }, []); - const handleSearchChange = (text: string): void => { - setSearchTerm(text); - }; + const handleSearchChange = useCallback( + (text: string): void => { + updateFilter('searchTerm', text); + }, + [updateFilter] + ); - const toggleApiFilter = (): void => { - setApiFilterActive(!apiFilterActive); - }; + const toggleApiFilter = useCallback((): void => { + updateFilter('apiOnly', !filters.apiOnly); + }, [filters.apiOnly, updateFilter]); + + const handleStatusFilterChange = useCallback( + (key: StatusFilterKey): void => { + updateFilter('statusFilter', key); + }, + [updateFilter] + ); const renderLogItem: ListRenderItem = ({ item }) => ( = ({ useCopyToClipboard={useCopyToClipboard} showResponseHeader={showResponseHeader} showRequestHeader={showRequestHeader} + theme={theme} + onDelete={handleDeleteLog} + onCloseModal={handleModalClose} /> ); const renderEmptyList = (): React.ReactElement => ( - No network requests logged yet + No network requests logged yet ); const keyExtractor = (item: NetworkLog): string => item.id.toString(); @@ -133,8 +224,13 @@ export const NetworkLoggerOverlay: React.FC = ({ hideIcon: handleCloseIcon, openModal: handleModalOpen, logsLength: logs.length, + errorCount: logStats.errors, }; + if (!enabled) { + return null; + } + return ( <> {showButton && @@ -149,64 +245,125 @@ export const NetworkLoggerOverlay: React.FC = ({ presentationStyle="fullScreen" onRequestClose={handleModalClose} > - - - Network Logs - + + + Network Logs + + + {theme === 'light' ? '🌙' : '☀️'} + + + { + setVisible(false); + setExportModalVisible(true); + }} + activeOpacity={0.8} + > + 📤 + + - Clear + Clear - Close + Close - + - + + + {filteredLogs.length}/{logs.length} requests + {logStats.errors > 0 && ( + + {' '} + ({logStats.errors} errors) + + )} + + {hasActiveFilters && ( + + + Clear filters + + + )} + + + {STATUS_FILTERS.map((filter) => ( + handleStatusFilterChange(filter.key)} + activeOpacity={0.8} + > + + {filter.label} + + + ))} API - = ({ /> + setExportModalVisible(false)} + logs={filteredLogs} + theme={theme} + /> ); }; -const styles = StyleSheet.create({ - floatingButton: { - position: 'absolute', - bottom: 100, - right: 20, - backgroundColor: '#007AFF', - padding: 16, - borderRadius: 25, - elevation: 1, - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, - zIndex: 1000, - }, - floatingCloseIcon: { - position: 'absolute', - bottom: 150, - right: 16, - padding: 16, - borderRadius: 25, - zIndex: 1000, - }, - floatingButtonText: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - }, +interface ThemeColors { + background: string; + surface: string; + border: string; + text: string; + textSecondary: string; + textMuted: string; + primary: string; + danger: string; +} + +const createThemedStyles = (themeColors: ThemeColors) => ({ modalContainer: { flex: 1, - backgroundColor: '#f5f5f5', - }, + backgroundColor: themeColors.background, + } as ViewStyle, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, - backgroundColor: '#fff', + backgroundColor: themeColors.surface, elevation: 2, - }, + } as ViewStyle, title: { fontSize: 20, fontWeight: 'bold', - color: '#333', - }, - headerButtons: { - flexDirection: 'row', - }, + color: themeColors.text, + } as TextStyle, clearButton: { - backgroundColor: '#E14434', + backgroundColor: themeColors.danger, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, marginRight: 8, - }, - clearButtonText: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - }, + } as ViewStyle, closeButton: { - backgroundColor: '#007AFF', + backgroundColor: themeColors.primary, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, - }, - closeButtonText: { - color: 'white', - fontSize: 14, - fontWeight: 'bold', - }, + } as ViewStyle, searchContainer: { paddingTop: 16, paddingHorizontal: 16, paddingBottom: 10, - backgroundColor: '#fff', - }, + backgroundColor: themeColors.surface, + } as ViewStyle, searchInput: { borderWidth: 1, - borderColor: '#ddd', + borderColor: themeColors.border, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8, fontSize: 16, - }, - filterContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - listContainer: { - padding: 16, - }, + color: themeColors.text, + backgroundColor: themeColors.background, + } as TextStyle, + statsText: { + fontSize: 12, + color: themeColors.textSecondary, + } as TextStyle, + clearFiltersText: { + fontSize: 12, + color: themeColors.primary, + fontWeight: '600', + } as TextStyle, + filterTag: { + backgroundColor: themeColors.background, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + borderColor: themeColors.border, + } as ViewStyle, + filterTagActive: { + backgroundColor: themeColors.primary, + borderColor: themeColors.primary, + } as ViewStyle, + filterTagText: { + fontSize: 12, + fontWeight: '600', + color: themeColors.textSecondary, + } as TextStyle, emptyText: { textAlign: 'center', - color: '#666', + color: themeColors.textSecondary, fontSize: 16, marginTop: '50%', - }, - apiFilterTag: { - backgroundColor: '#f0f0f0', - paddingHorizontal: 16, - paddingVertical: 4, - borderRadius: 20, - borderWidth: 1, - borderColor: '#ddd', - marginTop: 10, - }, - apiFilterTagActive: { - backgroundColor: '#007AFF', - borderColor: '#007AFF', - }, - apiFilterText: { + } as TextStyle, +}); + +const staticStyles = { + headerButtons: { + flexDirection: 'row', + alignItems: 'center', + } as ViewStyle, + themeButton: { + paddingHorizontal: 8, + paddingVertical: 8, + borderRadius: 16, + marginRight: 4, + } as ViewStyle, + exportButton: { + paddingHorizontal: 8, + paddingVertical: 8, + borderRadius: 16, + marginRight: 8, + } as ViewStyle, + themeButtonText: { + fontSize: 18, + } as TextStyle, + buttonText: { + color: 'white', fontSize: 14, + fontWeight: 'bold', + } as TextStyle, + statsContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 8, + marginBottom: 4, + } as ViewStyle, + errorStats: { + color: colors.error, fontWeight: '600', - color: '#666', - }, - apiFilterTextActive: { + } as TextStyle, + filterContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 8, + } as ViewStyle, + filterTagError: { + backgroundColor: colors.error, + borderColor: colors.error, + } as ViewStyle, + filterTagTextActive: { color: '#fff', - }, -}); + } as TextStyle, + listContainer: { + padding: 16, + } as ViewStyle, +}; export type { NetworkLoggerOverlayProps, NetworkLogger }; diff --git a/src/NonFloatingButton.tsx b/src/NonFloatingButton.tsx index fb82f2a..a2221b6 100644 --- a/src/NonFloatingButton.tsx +++ b/src/NonFloatingButton.tsx @@ -1,6 +1,7 @@ import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; import React from 'react'; import Icon from './Icon'; +import { colors } from './constants/colors'; const CLOSE_BUTTON_SIZE = 58; @@ -8,12 +9,14 @@ interface NonFloatingButtonProps { openModal: () => void; hideIcon: () => void; logsLength: number; + errorCount?: number; enableDeviceShake?: boolean; } const NonFloatingButton: React.FunctionComponent = ({ hideIcon, logsLength, + errorCount = 0, openModal, enableDeviceShake, }) => { @@ -29,6 +32,13 @@ const NonFloatingButton: React.FunctionComponent = ({ 📊 {logsLength} + {errorCount > 0 && ( + + + {errorCount > 99 ? '99+' : errorCount} + + + )} @@ -69,7 +79,25 @@ const styles = StyleSheet.create({ height: CLOSE_BUTTON_SIZE, justifyContent: 'center', alignItems: 'center', - fontWeight: '900', + }, + errorBadge: { + position: 'absolute', + top: -8, + right: -8, + backgroundColor: colors.error, + borderRadius: 10, + minWidth: 20, + height: 20, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + borderWidth: 2, + borderColor: '#fff', + }, + errorBadgeText: { + color: '#fff', + fontSize: 10, + fontWeight: 'bold', }, }); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..277d8ee --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { + StyleSheet, + Text, + View, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import { colors } from '../constants/colors'; + +export type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'pending'; + +interface BadgeProps { + label: string; + variant?: BadgeVariant; + size?: 'small' | 'medium'; + style?: StyleProp; +} + +const variantColors: Record = { + success: colors.success, + warning: colors.warning, + error: colors.error, + info: colors.info, + pending: colors.pending, +}; + +export const Badge: React.FC = ({ + label, + variant = 'info', + size = 'medium', + style, +}) => { + const backgroundColor = variantColors[variant]; + const isSmall = size === 'small'; + + return ( + + {label} + + ); +}; + +interface StatusBadgeProps { + status?: number; + hasError?: boolean; + size?: 'small' | 'medium'; + style?: StyleProp; +} + +export const StatusBadge: React.FC = ({ + status, + hasError, + size = 'medium', + style, +}) => { + const getVariant = (): BadgeVariant => { + if (hasError) return 'error'; + if (!status) return 'pending'; + if (status >= 200 && status < 300) return 'success'; + if (status >= 300 && status < 400) return 'warning'; + if (status >= 400) return 'error'; + return 'pending'; + }; + + const getLabel = (): string => { + if (hasError) return 'ERROR'; + if (!status) return 'PENDING'; + return status.toString(); + }; + + return ( + + ); +}; + +interface MethodBadgeProps { + method: string; + size?: 'small' | 'medium'; + style?: StyleProp; +} + +const methodColors: Record = { + GET: colors.success, + POST: colors.warning, + PUT: colors.info, + PATCH: '#9C27B0', + DELETE: colors.error, + HEAD: '#607D8B', + OPTIONS: '#795548', +}; + +export const MethodBadge: React.FC = ({ + method, + size = 'medium', + style, +}) => { + const upperMethod = method.toUpperCase(); + const backgroundColor = methodColors[upperMethod] || colors.info; + + return ( + + + {upperMethod} + + + ); +}; + +const styles = StyleSheet.create({ + badge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 12, + alignSelf: 'flex-start', + }, + badgeSmall: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 10, + }, + label: { + fontSize: 12, + fontWeight: 'bold', + color: '#fff', + }, + labelSmall: { + fontSize: 10, + }, + methodBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 4, + alignSelf: 'flex-start', + }, +}); diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx new file mode 100644 index 0000000..e5f6c09 --- /dev/null +++ b/src/components/ExportModal.tsx @@ -0,0 +1,261 @@ +import React, { useState, useCallback } from 'react'; +import { + Modal, + View, + Text, + TouchableOpacity, + Alert, + ActivityIndicator, + Share, + type ViewStyle, + type TextStyle, +} from 'react-native'; +import type { NetworkLog } from '../types'; +import type { ThemeMode } from '../constants/colors'; +import { getThemeColors } from '../constants/colors'; +import { + exportLogs, + getExportFileName, + type ExportFormat, +} from '../utils/export'; + +interface ExportModalProps { + visible: boolean; + onClose: () => void; + logs: NetworkLog[]; + theme?: ThemeMode; +} + +interface ExportOption { + format: ExportFormat; + label: string; + description: string; + icon: string; +} + +const EXPORT_OPTIONS: ExportOption[] = [ + { + format: 'har', + label: 'HAR File', + description: 'HTTP Archive format - Import into browser DevTools', + icon: '📦', + }, + { + format: 'postman', + label: 'Postman Collection', + description: 'Import directly into Postman', + icon: '📮', + }, + { + format: 'json', + label: 'JSON', + description: 'Raw JSON data export', + icon: '📄', + }, +]; + +export const ExportModal: React.FC = ({ + visible, + onClose, + logs, + theme = 'light', +}) => { + const [exporting, setExporting] = useState(null); + const themeColors = getThemeColors(theme); + + const handleExport = useCallback( + async (format: ExportFormat) => { + if (logs.length === 0) { + Alert.alert('No Logs', 'There are no logs to export.'); + return; + } + + setExporting(format); + + try { + const content = exportLogs(logs, format); + const fileName = getExportFileName(format); + + await Share.share({ + message: content, + title: fileName, + }); + } catch (error) { + if (error instanceof Error && error.message !== 'User did not share') { + Alert.alert('Export Failed', error.message); + } + } finally { + setExporting(null); + } + }, + [logs] + ); + + const themedStyles = createThemedStyles(themeColors); + + return ( + + + + + Export Logs + + + + + + + {logs.length} request{logs.length !== 1 ? 's' : ''} will be exported + + + + {EXPORT_OPTIONS.map((option) => ( + handleExport(option.format)} + disabled={exporting !== null} + activeOpacity={0.7} + > + + {option.icon} + + {option.label} + + {option.description} + + + {exporting === option.format ? ( + + ) : ( + + )} + + + ))} + + + + Cancel + + + + + ); +}; + +interface ThemeColors { + background: string; + surface: string; + border: string; + text: string; + textSecondary: string; + textMuted: string; + primary: string; +} + +const createThemedStyles = (themeColors: ThemeColors) => ({ + modalContent: { + backgroundColor: themeColors.surface, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 20, + maxHeight: '80%', + } as ViewStyle, + title: { + fontSize: 20, + fontWeight: 'bold', + color: themeColors.text, + } as TextStyle, + closeButtonText: { + fontSize: 20, + color: themeColors.textMuted, + } as TextStyle, + subtitle: { + fontSize: 14, + color: themeColors.textSecondary, + marginBottom: 20, + } as TextStyle, + optionButton: { + backgroundColor: themeColors.background, + borderRadius: 12, + padding: 16, + marginBottom: 12, + borderWidth: 1, + borderColor: themeColors.border, + } as ViewStyle, + optionLabel: { + fontSize: 16, + fontWeight: '600', + color: themeColors.text, + marginBottom: 2, + } as TextStyle, + optionDescription: { + fontSize: 12, + color: themeColors.textSecondary, + } as TextStyle, + chevron: { + fontSize: 24, + color: themeColors.textMuted, + } as TextStyle, + cancelButton: { + backgroundColor: themeColors.background, + borderRadius: 12, + padding: 16, + alignItems: 'center', + marginTop: 8, + borderWidth: 1, + borderColor: themeColors.border, + } as ViewStyle, + cancelButtonText: { + fontSize: 16, + fontWeight: '600', + color: themeColors.primary, + } as TextStyle, +}); + +const staticStyles = { + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'flex-end', + } as ViewStyle, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + } as ViewStyle, + closeButton: { + padding: 8, + } as ViewStyle, + optionsContainer: { + marginBottom: 8, + } as ViewStyle, + optionContent: { + flexDirection: 'row', + alignItems: 'center', + } as ViewStyle, + optionIcon: { + fontSize: 28, + marginRight: 16, + } as TextStyle, + optionText: { + flex: 1, + } as ViewStyle, +}; diff --git a/src/components/FilterChip.tsx b/src/components/FilterChip.tsx new file mode 100644 index 0000000..9a3eccb --- /dev/null +++ b/src/components/FilterChip.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { + StyleSheet, + Text, + TouchableOpacity, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import type { ThemeMode } from '../constants/colors'; +import { colors, getThemeColors } from '../constants/colors'; + +interface FilterChipProps { + label: string; + isActive?: boolean; + onPress: () => void; + variant?: 'default' | 'error'; + theme?: ThemeMode; + style?: StyleProp; +} + +export const FilterChip: React.FC = ({ + label, + isActive = false, + onPress, + variant = 'default', + theme = 'light', + style, +}) => { + const themeColors = getThemeColors(theme); + const isError = variant === 'error' && isActive; + + const chipStyle = [ + styles.chip, + { + backgroundColor: isActive + ? isError + ? colors.error + : themeColors.primary + : themeColors.background, + borderColor: isActive + ? isError + ? colors.error + : themeColors.primary + : themeColors.border, + }, + style, + ]; + + const textStyle = [ + styles.chipText, + { + color: isActive ? '#fff' : themeColors.textSecondary, + }, + ]; + + return ( + + {label} + + ); +}; + +interface FilterChipGroupProps { + options: { key: string; label: string }[]; + selectedKey: string; + onSelect: (key: string) => void; + theme?: ThemeMode; + errorKeys?: string[]; +} + +export const FilterChipGroup: React.FC = ({ + options, + selectedKey, + onSelect, + theme = 'light', + errorKeys = ['error'], +}) => { + return ( + <> + {options.map((option) => ( + onSelect(option.key)} + variant={errorKeys.includes(option.key) ? 'error' : 'default'} + theme={theme} + /> + ))} + + ); +}; + +const styles = StyleSheet.create({ + chip: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 1, + }, + chipText: { + fontSize: 12, + fontWeight: '600', + }, +}); diff --git a/src/components/JsonViewer.tsx b/src/components/JsonViewer.tsx new file mode 100644 index 0000000..8aa3220 --- /dev/null +++ b/src/components/JsonViewer.tsx @@ -0,0 +1,306 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { + StyleSheet, + Text, + TouchableOpacity, + View, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import type { ThemeMode } from '../constants/colors'; +import { getThemeColors } from '../constants/colors'; + +interface JsonViewerProps { + data: string | object | null; + theme?: ThemeMode; + initialExpanded?: boolean; + maxInitialDepth?: number; + style?: StyleProp; +} + +interface JsonNodeProps { + keyName?: string; + value: unknown; + depth: number; + theme: ThemeMode; + maxInitialDepth: number; + isLast: boolean; +} + +const syntaxColors = { + light: { + key: '#881391', + string: '#1A1AA6', + number: '#098658', + boolean: '#0000FF', + null: '#808080', + bracket: '#333333', + punctuation: '#666666', + }, + dark: { + key: '#9CDCFE', + string: '#CE9178', + number: '#B5CEA8', + boolean: '#569CD6', + null: '#808080', + bracket: '#D4D4D4', + punctuation: '#AAAAAA', + }, +}; + +const JsonNode: React.FC = ({ + keyName, + value, + depth, + theme, + maxInitialDepth, + isLast, +}) => { + const [isExpanded, setIsExpanded] = useState(depth < maxInitialDepth); + const themeColors = getThemeColors(theme); + const syntaxTheme = syntaxColors[theme]; + const indent = depth * 16; + + const toggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const renderValue = useCallback( + (val: unknown): React.ReactNode => { + if (val === null) { + return null; + } + + if (val === undefined) { + return undefined; + } + + switch (typeof val) { + case 'string': + return ( + + "{val}" + + ); + case 'number': + return {val}; + case 'boolean': + return ( + + {val ? 'true' : 'false'} + + ); + default: + return {String(val)}; + } + }, + [syntaxTheme, themeColors] + ); + + const comma = isLast ? '' : ','; + + if (value === null || value === undefined) { + return ( + + {keyName !== undefined && ( + <> + "{keyName}" + : + + )} + {renderValue(value)} + {comma} + + ); + } + + if (typeof value !== 'object') { + return ( + + {keyName !== undefined && ( + <> + "{keyName}" + : + + )} + {renderValue(value)} + {comma} + + ); + } + + const isArray = Array.isArray(value); + const entries = isArray + ? value.map((v, i) => [i, v] as [number, unknown]) + : Object.entries(value); + const isEmpty = entries.length === 0; + const openBracket = isArray ? '[' : '{'; + const closeBracket = isArray ? ']' : '}'; + + if (isEmpty) { + return ( + + {keyName !== undefined && ( + <> + "{keyName}" + : + + )} + + {openBracket} + {closeBracket} + + {comma} + + ); + } + + return ( + + + + {isExpanded ? '▼' : '▶'} + + {keyName !== undefined && ( + <> + "{keyName}" + : + + )} + {openBracket} + {!isExpanded && ( + <> + + {' '} + {entries.length} {isArray ? 'items' : 'keys'}{' '} + + {closeBracket} + {comma} + + )} + + + {isExpanded && ( + <> + {entries.map(([key, val], index) => ( + + ))} + + {closeBracket} + {comma} + + + )} + + ); +}; + +export const JsonViewer: React.FC = ({ + data, + theme = 'light', + initialExpanded = true, + maxInitialDepth = 2, + style, +}) => { + const themeColors = getThemeColors(theme); + + const parsedData = useMemo(() => { + if (data === null || data === undefined) { + return null; + } + if (typeof data === 'object') { + return data; + } + if (typeof data === 'string') { + try { + return JSON.parse(data); + } catch { + return data; + } + } + return data; + }, [data]); + + if (parsedData === null || parsedData === undefined) { + return ( + + No data + + ); + } + + if (typeof parsedData === 'string') { + return ( + + + {parsedData} + + + ); + } + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + padding: 12, + borderRadius: 8, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'flex-start', + paddingVertical: 2, + }, + expandIcon: { + width: 16, + fontSize: 10, + marginRight: 4, + }, + rawText: { + fontFamily: 'monospace', + fontSize: 13, + lineHeight: 18, + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..49d0654 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './Badge'; +export * from './FilterChip'; +export * from './JsonViewer'; +export * from './ExportModal'; diff --git a/src/constants/colors.ts b/src/constants/colors.ts new file mode 100644 index 0000000..bc1129f --- /dev/null +++ b/src/constants/colors.ts @@ -0,0 +1,79 @@ +export const colors = { + // Status colors + success: '#4CAF50', + warning: '#FF9800', + error: '#F44336', + info: '#2196F3', + pending: '#9E9E9E', + + // UI colors - Light theme + light: { + background: '#f5f5f5', + surface: '#ffffff', + surfaceSecondary: '#f9f9f9', + border: '#e0e0e0', + borderLight: '#eeeeee', + text: '#333333', + textSecondary: '#666666', + textMuted: '#999999', + primary: '#007AFF', + primaryLight: '#E3F2FD', + danger: '#E14434', + codeBackground: '#f0f0f0', + }, + + // UI colors - Dark theme + dark: { + background: '#121212', + surface: '#1E1E1E', + surfaceSecondary: '#252525', + border: '#333333', + borderLight: '#2a2a2a', + text: '#E0E0E0', + textSecondary: '#AAAAAA', + textMuted: '#777777', + primary: '#0A84FF', + primaryLight: '#1a3a5c', + danger: '#FF453A', + codeBackground: '#2a2a2a', + }, + + // Method colors + methods: { + GET: '#4CAF50', + POST: '#FF9800', + PUT: '#2196F3', + PATCH: '#9C27B0', + DELETE: '#F44336', + HEAD: '#607D8B', + OPTIONS: '#795548', + }, + + // Status code colors + statusCodes: { + success: '#4CAF50', // 2xx + redirect: '#FF9800', // 3xx + clientError: '#F44336', // 4xx + serverError: '#9C27B0', // 5xx + }, +} as const; + +export type ThemeMode = 'light' | 'dark'; + +export const getThemeColors = (mode: ThemeMode) => { + return mode === 'dark' ? colors.dark : colors.light; +}; + +export const getStatusColor = (status?: number): string => { + if (!status) return colors.pending; + if (status >= 200 && status < 300) return colors.statusCodes.success; + if (status >= 300 && status < 400) return colors.statusCodes.redirect; + if (status >= 400 && status < 500) return colors.statusCodes.clientError; + if (status >= 500) return colors.statusCodes.serverError; + return colors.pending; +}; + +export const getMethodColor = (method: string): string => { + const upperMethod = method.toUpperCase() as keyof typeof colors.methods; + return colors.methods[upperMethod] || colors.info; +}; diff --git a/src/constants/config.ts b/src/constants/config.ts new file mode 100644 index 0000000..4356efd --- /dev/null +++ b/src/constants/config.ts @@ -0,0 +1,65 @@ +import type { NetworkLoggerConfig } from '../types'; + +export const DEFAULT_CONFIG: NetworkLoggerConfig = { + maxLogs: 100, + ignoredUrls: [], + ignoredDomains: [], + ignoredMethods: [], + redactHeaders: ['Authorization', 'X-API-Key', 'Cookie', 'Set-Cookie'], + enableRedaction: false, + slowRequestThreshold: 3000, +}; + +export const FILTER_OPTIONS = { + statusCodes: [ + { key: 'all', label: 'All', filter: () => true }, + { + key: 'success', + label: '2xx', + filter: (status?: number) => + status !== undefined && status >= 200 && status < 300, + }, + { + key: 'redirect', + label: '3xx', + filter: (status?: number) => + status !== undefined && status >= 300 && status < 400, + }, + { + key: 'clientError', + label: '4xx', + filter: (status?: number) => + status !== undefined && status >= 400 && status < 500, + }, + { + key: 'serverError', + label: '5xx', + filter: (status?: number) => status !== undefined && status >= 500, + }, + { + key: 'error', + label: 'Errors', + filter: (status?: number, hasError?: boolean) => + hasError || (status !== undefined && status >= 400), + }, + ], + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], +} as const; + +export const UI_CONFIG = { + floatingButton: { + size: 58, + padding: 20, + borderRadius: 25, + }, + logItem: { + maxUrlLines: 4, + maxDetailsHeight: 400, + }, + animation: { + springConfig: { + damping: 15, + stiffness: 150, + }, + }, +} as const; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 0000000..091a8c9 --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1,2 @@ +export * from './colors'; +export * from './config'; diff --git a/src/index.tsx b/src/index.tsx index 95dee5a..969454e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,6 @@ export * from './NetworkInterceptor'; export * from './NetworkLoggerOverlay'; export * from './types'; +export * from './constants'; +export * from './utils'; +export * from './components'; diff --git a/src/types.ts b/src/types.ts index ecb87e2..340d9ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,16 +21,31 @@ export interface NetworkLog { response?: NetworkResponse; error?: string; duration?: number; + isSlow?: boolean; + bookmarked?: boolean; } export type LogListener = (logs: NetworkLog[]) => void; +export interface NetworkLoggerConfig { + maxLogs: number; + ignoredUrls: string[]; + ignoredDomains: string[]; + ignoredMethods: string[]; + redactHeaders: string[]; + enableRedaction: boolean; + slowRequestThreshold: number; +} + export interface NetworkLogger { subscribe: (listener: LogListener) => () => void; clearLogs: () => void; + deleteLog: (logId: number) => void; enable: () => void; disable: () => void; getLogs?: () => NetworkLog[]; getLogCount?: () => number; isLoggerEnabled?: () => boolean; + configure?: (config: Partial) => void; + getConfig?: () => NetworkLoggerConfig; } diff --git a/src/utils/curl.ts b/src/utils/curl.ts new file mode 100644 index 0000000..dfc2499 --- /dev/null +++ b/src/utils/curl.ts @@ -0,0 +1,83 @@ +import type { NetworkLog } from '../types'; + +export const generateCurl = (log: NetworkLog): string => { + let curl = `curl -X ${log.method}`; + + if (log.headers && typeof log.headers === 'object') { + Object.entries(log.headers).forEach(([key, value]: [string, string]) => { + const escapedValue = value.replace(/"/g, '\\"'); + curl += ` \\\n -H "${key}: ${escapedValue}"`; + }); + } + + if (log.body) { + const escapedBody = log.body.replace(/'/g, "'\\''"); + curl += ` \\\n -d '${escapedBody}'`; + } + + curl += ` \\\n "${log.url}"`; + + return curl; +}; + +export const generateCurlOneLine = (log: NetworkLog): string => { + let curl = `curl -X ${log.method}`; + + if (log.headers && typeof log.headers === 'object') { + Object.entries(log.headers).forEach(([key, value]: [string, string]) => { + const escapedValue = value.replace(/"/g, '\\"'); + curl += ` -H "${key}: ${escapedValue}"`; + }); + } + + if (log.body) { + const escapedBody = log.body.replace(/'/g, "'\\''"); + curl += ` -d '${escapedBody}'`; + } + + curl += ` "${log.url}"`; + + return curl; +}; + +export const parseCurlCommand = ( + curlCommand: string +): { + method: string; + url: string; + headers: Record; + body: string | null; +} => { + const result = { + method: 'GET', + url: '', + headers: {} as Record, + body: null as string | null, + }; + + const methodMatch = curlCommand.match(/-X\s+(\w+)/); + if (methodMatch && methodMatch[1]) { + result.method = methodMatch[1]; + } + + const headerMatches = curlCommand.matchAll(/-H\s+"([^:]+):\s*([^"]+)"/g); + for (const match of headerMatches) { + const key = match[1]; + const value = match[2]; + if (key && value) { + result.headers[key] = value; + } + } + + const bodyMatch = curlCommand.match(/-d\s+'([^']+)'/); + if (bodyMatch && bodyMatch[1]) { + result.body = bodyMatch[1]; + } + + const urlMatch = curlCommand.match(/"(https?:\/\/[^"]+)"/); + if (urlMatch && urlMatch[1]) { + result.url = urlMatch[1]; + } + + return result; +}; diff --git a/src/utils/export.ts b/src/utils/export.ts new file mode 100644 index 0000000..20aab29 --- /dev/null +++ b/src/utils/export.ts @@ -0,0 +1,302 @@ +import type { NetworkLog } from '../types'; + +export interface HARLog { + version: string; + creator: { + name: string; + version: string; + }; + entries: HAREntry[]; +} + +export interface HAREntry { + startedDateTime: string; + time: number; + request: { + method: string; + url: string; + httpVersion: string; + headers: { name: string; value: string }[]; + queryString: { name: string; value: string }[]; + postData?: { + mimeType: string; + text: string; + }; + headersSize: number; + bodySize: number; + }; + response: { + status: number; + statusText: string; + httpVersion: string; + headers: { name: string; value: string }[]; + content: { + size: number; + mimeType: string; + text: string; + }; + headersSize: number; + bodySize: number; + }; + cache: Record; + timings: { + send: number; + wait: number; + receive: number; + }; +} + +const parseQueryString = (url: string): { name: string; value: string }[] => { + try { + const urlObj = new URL(url); + const params: { name: string; value: string }[] = []; + urlObj.searchParams.forEach((value, name) => { + params.push({ name, value }); + }); + return params; + } catch { + return []; + } +}; + +const headersToArray = ( + headers: Record +): { name: string; value: string }[] => { + return Object.entries(headers).map(([name, value]) => ({ name, value })); +}; + +const getContentType = (headers: Record): string => { + const contentType = Object.entries(headers).find( + ([key]) => key.toLowerCase() === 'content-type' + ); + return contentType ? contentType[1] : 'application/octet-stream'; +}; + +export const networkLogToHAREntry = (log: NetworkLog): HAREntry => { + const duration = log.response?.duration || log.duration || 0; + const requestHeaders = headersToArray(log.headers || {}); + const responseHeaders = headersToArray(log.response?.headers || {}); + + return { + startedDateTime: log.timestamp, + time: duration, + request: { + method: log.method, + url: log.url, + httpVersion: 'HTTP/1.1', + headers: requestHeaders, + queryString: parseQueryString(log.url), + ...(log.body && { + postData: { + mimeType: getContentType(log.headers || {}), + text: log.body, + }, + }), + headersSize: JSON.stringify(requestHeaders).length, + bodySize: log.body ? log.body.length : 0, + }, + response: { + status: log.response?.status || 0, + statusText: log.response?.statusText || '', + httpVersion: 'HTTP/1.1', + headers: responseHeaders, + content: { + size: log.response?.body?.length || 0, + mimeType: getContentType(log.response?.headers || {}), + text: log.response?.body || '', + }, + headersSize: JSON.stringify(responseHeaders).length, + bodySize: log.response?.body?.length || 0, + }, + cache: {}, + timings: { + send: 0, + wait: duration, + receive: 0, + }, + }; +}; + +export const exportToHAR = (logs: NetworkLog[]): string => { + const har: { log: HARLog } = { + log: { + version: '1.2', + creator: { + name: 'react-native-api-debugger', + version: '1.0.0', + }, + entries: logs.map(networkLogToHAREntry), + }, + }; + + return JSON.stringify(har, null, 2); +}; + +export interface PostmanCollection { + info: { + name: string; + schema: string; + _postman_id?: string; + }; + item: PostmanItem[]; +} + +export interface PostmanItem { + name: string; + request: { + method: string; + header: { key: string; value: string }[]; + url: { + raw: string; + protocol?: string; + host?: string[]; + path?: string[]; + query?: { key: string; value: string }[]; + }; + body?: { + mode: string; + raw: string; + options?: { + raw: { + language: string; + }; + }; + }; + }; + response: never[]; +} + +const parseUrlForPostman = ( + url: string +): { + raw: string; + protocol?: string; + host?: string[]; + path?: string[]; + query?: { key: string; value: string }[]; +} => { + try { + const urlObj = new URL(url); + const query: { key: string; value: string }[] = []; + urlObj.searchParams.forEach((value, key) => { + query.push({ key, value }); + }); + + return { + raw: url, + protocol: urlObj.protocol.replace(':', ''), + host: urlObj.hostname.split('.'), + path: urlObj.pathname.split('/').filter(Boolean), + ...(query.length > 0 && { query }), + }; + } catch { + return { raw: url }; + } +}; + +export const networkLogToPostmanItem = (log: NetworkLog): PostmanItem => { + const headers = Object.entries(log.headers || {}).map(([key, value]) => ({ + key, + value, + })); + + const item: PostmanItem = { + name: `${log.method} ${new URL(log.url).pathname}`, + request: { + method: log.method, + header: headers, + url: parseUrlForPostman(log.url), + }, + response: [], + }; + + if (log.body) { + const contentType = getContentType(log.headers || {}); + const isJson = contentType.includes('application/json'); + + item.request.body = { + mode: 'raw', + raw: log.body, + options: { + raw: { + language: isJson ? 'json' : 'text', + }, + }, + }; + } + + return item; +}; + +export const exportToPostman = ( + logs: NetworkLog[], + collectionName: string = 'API Debugger Export' +): string => { + const collection: PostmanCollection = { + info: { + name: collectionName, + schema: + 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', + }, + item: logs.map(networkLogToPostmanItem), + }; + + return JSON.stringify(collection, null, 2); +}; + +export const exportSingleRequestAsJSON = (log: NetworkLog): string => { + return JSON.stringify( + { + method: log.method, + url: log.url, + headers: log.headers, + body: log.body, + timestamp: log.timestamp, + response: log.response + ? { + status: log.response.status, + statusText: log.response.statusText, + headers: log.response.headers, + body: log.response.body, + duration: log.response.duration, + } + : null, + error: log.error, + }, + null, + 2 + ); +}; + +export type ExportFormat = 'har' | 'postman' | 'json'; + +export const exportLogs = ( + logs: NetworkLog[], + format: ExportFormat, + options?: { collectionName?: string } +): string => { + switch (format) { + case 'har': + return exportToHAR(logs); + case 'postman': + return exportToPostman(logs, options?.collectionName); + case 'json': + return JSON.stringify(logs, null, 2); + default: + return JSON.stringify(logs, null, 2); + } +}; + +export const getExportFileName = (format: ExportFormat): string => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + switch (format) { + case 'har': + return `network-logs-${timestamp}.har`; + case 'postman': + return `postman-collection-${timestamp}.json`; + case 'json': + return `network-logs-${timestamp}.json`; + default: + return `network-logs-${timestamp}.json`; + } +}; diff --git a/src/utils/filters.ts b/src/utils/filters.ts new file mode 100644 index 0000000..259a0cd --- /dev/null +++ b/src/utils/filters.ts @@ -0,0 +1,159 @@ +import type { NetworkLog } from '../types'; + +export type StatusFilterKey = + | 'all' + | 'success' + | 'redirect' + | 'clientError' + | 'serverError' + | 'error'; + +export interface FilterState { + searchTerm: string; + statusFilter: StatusFilterKey; + methodFilter: string | null; + apiOnly: boolean; +} + +export const defaultFilterState: FilterState = { + searchTerm: '', + statusFilter: 'all', + methodFilter: null, + apiOnly: false, +}; + +export const matchesSearchTerm = ( + log: NetworkLog, + searchTerm: string +): boolean => { + if (!searchTerm) return true; + const lowerSearch = searchTerm.toLowerCase(); + return ( + log.url.toLowerCase().includes(lowerSearch) || + log.method.toLowerCase().includes(lowerSearch) || + (log.response?.body?.toLowerCase().includes(lowerSearch) ?? false) || + (log.body?.toLowerCase().includes(lowerSearch) ?? false) + ); +}; + +export const matchesStatusFilter = ( + log: NetworkLog, + statusFilter: StatusFilterKey +): boolean => { + const status = log.response?.status; + const hasError = !!log.error; + + switch (statusFilter) { + case 'all': + return true; + case 'success': + return status !== undefined && status >= 200 && status < 300; + case 'redirect': + return status !== undefined && status >= 300 && status < 400; + case 'clientError': + return status !== undefined && status >= 400 && status < 500; + case 'serverError': + return status !== undefined && status >= 500; + case 'error': + return hasError || (status !== undefined && status >= 400); + default: + return true; + } +}; + +export const matchesMethodFilter = ( + log: NetworkLog, + methodFilter: string | null +): boolean => { + if (!methodFilter) return true; + return log.method.toUpperCase() === methodFilter.toUpperCase(); +}; + +export const matchesApiFilter = ( + log: NetworkLog, + apiOnly: boolean +): boolean => { + if (!apiOnly) return true; + return log.url.toLowerCase().includes('api'); +}; + +export const filterLogs = ( + logs: NetworkLog[], + filters: FilterState +): NetworkLog[] => { + return logs.filter((log) => { + return ( + matchesSearchTerm(log, filters.searchTerm) && + matchesStatusFilter(log, filters.statusFilter) && + matchesMethodFilter(log, filters.methodFilter) && + matchesApiFilter(log, filters.apiOnly) + ); + }); +}; + +export const shouldIgnoreUrl = (url: string, patterns: string[]): boolean => { + if (patterns.length === 0) return false; + return patterns.some((pattern) => { + if (pattern.includes('*')) { + const regex = new RegExp(pattern.replace(/\*/g, '.*'), 'i'); + return regex.test(url); + } + return url.toLowerCase().includes(pattern.toLowerCase()); + }); +}; + +export const shouldIgnoreDomain = (url: string, domains: string[]): boolean => { + if (domains.length === 0) return false; + try { + const urlObj = new URL(url); + return domains.some( + (domain) => urlObj.hostname.toLowerCase() === domain.toLowerCase() + ); + } catch { + return false; + } +}; + +export const getLogStats = ( + logs: NetworkLog[] +): { + total: number; + success: number; + errors: number; + pending: number; + avgDuration: number; +} => { + const stats = { + total: logs.length, + success: 0, + errors: 0, + pending: 0, + avgDuration: 0, + }; + + let totalDuration = 0; + let durationCount = 0; + + logs.forEach((log) => { + const status = log.response?.status; + const duration = log.response?.duration || log.duration; + + if (log.error || (status !== undefined && status >= 400)) { + stats.errors++; + } else if (status !== undefined && status >= 200 && status < 400) { + stats.success++; + } else { + stats.pending++; + } + + if (duration) { + totalDuration += duration; + durationCount++; + } + }); + + stats.avgDuration = + durationCount > 0 ? Math.round(totalDuration / durationCount) : 0; + + return stats; +}; diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..5fced9f --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,65 @@ +import type { NetworkRequestHeaders } from '../types'; + +export const formatHeaders = (headers?: NetworkRequestHeaders): string => { + if (!headers || typeof headers !== 'object') return 'No headers'; + return Object.entries(headers) + .map(([key, value]: [string, string]) => `${key}: ${value}`) + .join('\n'); +}; + +export const formatBody = (body?: string | null): string => { + if (!body) return 'No body'; + try { + const parsed = JSON.parse(body); + return JSON.stringify(parsed, null, 2); + } catch { + return body; + } +}; + +export const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +}; + +export const formatDuration = (ms: number): string => { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; + return `${(ms / 60000).toFixed(2)}m`; +}; + +export const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; + +export const truncateUrl = (url: string, maxLength: number = 50): string => { + if (url.length <= maxLength) return url; + return `${url.substring(0, maxLength)}...`; +}; + +export const extractDomain = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.hostname; + } catch { + return url; + } +}; + +export const extractPath = (url: string): string => { + try { + const urlObj = new URL(url); + return urlObj.pathname + urlObj.search; + } catch { + return url; + } +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..ee1660d --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,6 @@ +export * from './formatters'; +export * from './filters'; +export * from './curl'; +export * from './export'; +export * from './replay'; +export * from './redact'; diff --git a/src/utils/redact.ts b/src/utils/redact.ts new file mode 100644 index 0000000..61933a6 --- /dev/null +++ b/src/utils/redact.ts @@ -0,0 +1,290 @@ +import type { NetworkLog, NetworkRequestHeaders } from '../types'; + +export interface RedactionConfig { + enabled: boolean; + headers: string[]; + bodyKeys: string[]; + urlParams: string[]; + preserveLength: boolean; + maskChar: string; + visibleChars: number; +} + +export const DEFAULT_REDACTION_CONFIG: RedactionConfig = { + enabled: false, + headers: [ + 'authorization', + 'x-api-key', + 'api-key', + 'x-auth-token', + 'cookie', + 'set-cookie', + 'x-csrf-token', + 'x-access-token', + 'x-refresh-token', + ], + bodyKeys: [ + 'password', + 'secret', + 'token', + 'apiKey', + 'api_key', + 'accessToken', + 'access_token', + 'refreshToken', + 'refresh_token', + 'creditCard', + 'credit_card', + 'cardNumber', + 'card_number', + 'cvv', + 'ssn', + 'socialSecurityNumber', + ], + urlParams: ['token', 'key', 'apiKey', 'api_key', 'secret', 'password'], + preserveLength: false, + maskChar: '*', + visibleChars: 4, +}; + +export const redactValue = ( + value: string, + config: Partial = {} +): string => { + const { preserveLength, maskChar = '*', visibleChars = 4 } = config; + + if (!value || value.length === 0) return value; + + if (preserveLength) { + if (value.length <= visibleChars * 2) { + return maskChar.repeat(value.length); + } + const start = value.substring(0, visibleChars); + const end = value.substring(value.length - visibleChars); + const middle = maskChar.repeat(value.length - visibleChars * 2); + return `${start}${middle}${end}`; + } + + if (value.length <= visibleChars) { + return maskChar.repeat(8); + } + + const start = value.substring(0, visibleChars); + return `${start}${maskChar.repeat(8)}`; +}; + +export const redactHeaders = ( + headers: NetworkRequestHeaders, + config: Partial = {} +): NetworkRequestHeaders => { + const headersToRedact = config.headers || DEFAULT_REDACTION_CONFIG.headers; + const redacted: NetworkRequestHeaders = {}; + + for (const [key, value] of Object.entries(headers)) { + const shouldRedact = headersToRedact.some( + (h) => h.toLowerCase() === key.toLowerCase() + ); + + if (shouldRedact) { + redacted[key] = redactValue(value, config); + } else { + redacted[key] = value; + } + } + + return redacted; +}; + +export const redactUrl = ( + url: string, + config: Partial = {} +): string => { + const paramsToRedact = config.urlParams || DEFAULT_REDACTION_CONFIG.urlParams; + + try { + const urlObj = new URL(url); + let modified = false; + + paramsToRedact.forEach((param) => { + if (urlObj.searchParams.has(param)) { + const value = urlObj.searchParams.get(param); + if (value) { + urlObj.searchParams.set(param, redactValue(value, config)); + modified = true; + } + } + }); + + return modified ? urlObj.toString() : url; + } catch { + return url; + } +}; + +export const redactJsonBody = ( + body: string, + config: Partial = {} +): string => { + const keysToRedact = config.bodyKeys || DEFAULT_REDACTION_CONFIG.bodyKeys; + + try { + const parsed = JSON.parse(body); + const redacted = redactObjectKeys(parsed, keysToRedact, config); + return JSON.stringify(redacted); + } catch { + return body; + } +}; + +const redactObjectKeys = ( + obj: unknown, + keysToRedact: string[], + config: Partial +): unknown => { + if (obj === null || obj === undefined) return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => redactObjectKeys(item, keysToRedact, config)); + } + + if (typeof obj === 'object') { + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + const shouldRedact = keysToRedact.some( + (k) => k.toLowerCase() === key.toLowerCase() + ); + + if (shouldRedact && typeof value === 'string') { + result[key] = redactValue(value, config); + } else if (typeof value === 'object') { + result[key] = redactObjectKeys(value, keysToRedact, config); + } else { + result[key] = value; + } + } + + return result; + } + + return obj; +}; + +export const redactNetworkLog = ( + log: NetworkLog, + config: Partial = {} +): NetworkLog => { + const mergedConfig = { ...DEFAULT_REDACTION_CONFIG, ...config }; + + if (!mergedConfig.enabled) return log; + + const redactedLog: NetworkLog = { + ...log, + url: redactUrl(log.url, mergedConfig), + headers: redactHeaders(log.headers, mergedConfig), + }; + + if (log.body) { + redactedLog.body = redactJsonBody(log.body, mergedConfig); + } + + if (log.response) { + redactedLog.response = { + ...log.response, + headers: redactHeaders(log.response.headers, mergedConfig), + body: redactJsonBody(log.response.body, mergedConfig), + }; + } + + return redactedLog; +}; + +export const detectSensitiveData = ( + log: NetworkLog +): { + hasSensitiveHeaders: boolean; + hasSensitiveBody: boolean; + hasSensitiveUrl: boolean; + warnings: string[]; +} => { + const warnings: string[] = []; + + const sensitiveHeaders = DEFAULT_REDACTION_CONFIG.headers; + const hasSensitiveHeaders = Object.keys(log.headers || {}).some((key) => + sensitiveHeaders.some((h) => h.toLowerCase() === key.toLowerCase()) + ); + + if (hasSensitiveHeaders) { + warnings.push('Contains sensitive headers (e.g., Authorization)'); + } + + const sensitiveParams = DEFAULT_REDACTION_CONFIG.urlParams; + let hasSensitiveUrl = false; + try { + const urlObj = new URL(log.url); + hasSensitiveUrl = sensitiveParams.some((param) => + urlObj.searchParams.has(param) + ); + if (hasSensitiveUrl) { + warnings.push('URL contains sensitive query parameters'); + } + } catch { + // Invalid URL + } + + const sensitiveBodyKeys = DEFAULT_REDACTION_CONFIG.bodyKeys; + let hasSensitiveBody = false; + + const checkBodyForKeys = (body: string | null | undefined): boolean => { + if (!body) return false; + try { + const parsed = JSON.parse(body); + return containsSensitiveKeys(parsed, sensitiveBodyKeys); + } catch { + return sensitiveBodyKeys.some((key) => + body.toLowerCase().includes(key.toLowerCase()) + ); + } + }; + + hasSensitiveBody = + checkBodyForKeys(log.body) || checkBodyForKeys(log.response?.body); + + if (hasSensitiveBody) { + warnings.push('Body may contain sensitive data'); + } + + return { + hasSensitiveHeaders, + hasSensitiveBody, + hasSensitiveUrl, + warnings, + }; +}; + +const containsSensitiveKeys = ( + obj: unknown, + keysToCheck: string[] +): boolean => { + if (obj === null || obj === undefined) return false; + + if (Array.isArray(obj)) { + return obj.some((item) => containsSensitiveKeys(item, keysToCheck)); + } + + if (typeof obj === 'object') { + for (const [key, value] of Object.entries(obj)) { + if (keysToCheck.some((k) => k.toLowerCase() === key.toLowerCase())) { + return true; + } + if ( + typeof value === 'object' && + containsSensitiveKeys(value, keysToCheck) + ) { + return true; + } + } + } + + return false; +}; diff --git a/src/utils/replay.ts b/src/utils/replay.ts new file mode 100644 index 0000000..78ef267 --- /dev/null +++ b/src/utils/replay.ts @@ -0,0 +1,164 @@ +import type { NetworkLog, NetworkResponse } from '../types'; + +export interface ReplayResult { + success: boolean; + response?: NetworkResponse; + error?: string; + originalLog: NetworkLog; + replayedAt: string; +} + +export interface ReplayOptions { + modifyHeaders?: Record; + modifyBody?: string; + timeout?: number; +} + +export const replayRequest = async ( + log: NetworkLog, + options: ReplayOptions = {} +): Promise => { + const startTime = Date.now(); + const replayedAt = new Date().toISOString(); + + const headers: Record = { + ...log.headers, + ...options.modifyHeaders, + }; + + const fetchOptions: RequestInit = { + method: log.method, + headers, + }; + + if (log.body || options.modifyBody) { + fetchOptions.body = options.modifyBody || log.body || undefined; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + options.timeout || 30000 + ); + + fetchOptions.signal = controller.signal; + + const response = await fetch(log.url, fetchOptions); + clearTimeout(timeoutId); + + let responseBody = ''; + try { + responseBody = await response.text(); + } catch { + responseBody = 'Unable to read response body'; + } + + const duration = Date.now() - startTime; + + const networkResponse: NetworkResponse = { + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + body: responseBody, + duration, + }; + + return { + success: true, + response: networkResponse, + originalLog: log, + replayedAt, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + + return { + success: false, + error: errorMessage, + originalLog: log, + replayedAt, + }; + } +}; + +const headersToObject = (headers: Headers): Record => { + const obj: Record = {}; + headers.forEach((value, key) => { + obj[key] = value; + }); + return obj; +}; + +export const canReplayRequest = (log: NetworkLog): boolean => { + const replayableMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']; + return replayableMethods.includes(log.method.toUpperCase()); +}; + +export const getReplayWarnings = (log: NetworkLog): string[] => { + const warnings: string[] = []; + + if (log.method === 'POST' || log.method === 'PUT' || log.method === 'PATCH') { + warnings.push('This request may modify data on the server'); + } + + if (log.method === 'DELETE') { + warnings.push('This request may delete data on the server'); + } + + const hasAuthHeader = Object.keys(log.headers || {}).some((key) => + ['authorization', 'x-api-key', 'cookie'].includes(key.toLowerCase()) + ); + + if (hasAuthHeader) { + warnings.push('Request contains authentication headers'); + } + + return warnings; +}; + +export const compareResponses = ( + original: NetworkResponse | undefined, + replayed: NetworkResponse | undefined +): { + statusChanged: boolean; + bodyChanged: boolean; + durationDiff: number; + summary: string; +} => { + if (!original || !replayed) { + return { + statusChanged: false, + bodyChanged: false, + durationDiff: 0, + summary: 'Unable to compare responses', + }; + } + + const statusChanged = original.status !== replayed.status; + const bodyChanged = original.body !== replayed.body; + const durationDiff = replayed.duration - original.duration; + + const summaryParts: string[] = []; + + if (statusChanged) { + summaryParts.push(`Status: ${original.status} → ${replayed.status}`); + } + + if (bodyChanged) { + summaryParts.push('Response body changed'); + } + + if (Math.abs(durationDiff) > 100) { + const sign = durationDiff > 0 ? '+' : ''; + summaryParts.push(`Duration: ${sign}${durationDiff}ms`); + } + + return { + statusChanged, + bodyChanged, + durationDiff, + summary: summaryParts.length > 0 ? summaryParts.join(', ') : 'No changes', + }; +};