network-terminal 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { createServer } = require('vite');
4
+ const path = require('path');
5
+
6
+ const args = process.argv.slice(2);
7
+ const portIndex = args.indexOf('--port');
8
+ const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 3000;
9
+
10
+ async function start() {
11
+ const standalonePath = path.join(__dirname, '..', 'standalone');
12
+
13
+ console.log('\x1b[32m%s\x1b[0m', '\n >_ Network Terminal\n');
14
+ console.log(' Starting development server...\n');
15
+
16
+ try {
17
+ const server = await createServer({
18
+ configFile: path.join(standalonePath, 'vite.config.js'),
19
+ root: standalonePath,
20
+ server: {
21
+ port,
22
+ open: true,
23
+ },
24
+ });
25
+
26
+ await server.listen();
27
+
28
+ const actualPort = server.config.server.port;
29
+
30
+ console.log('\x1b[32m%s\x1b[0m', ` Server running at:\n`);
31
+ console.log(` > Local: \x1b[36mhttp://localhost:${actualPort}\x1b[0m`);
32
+ console.log('\n Press \x1b[33mCtrl+C\x1b[0m to stop\n');
33
+
34
+ } catch (err) {
35
+ console.error('\x1b[31mError starting server:\x1b[0m', err.message);
36
+ process.exit(1);
37
+ }
38
+ }
39
+
40
+ start();
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "network-terminal",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "A browser-based terminal UI for monitoring Fetch/XHR requests with real-time display of routes, payloads, and responses",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "network-terminal": "./bin/cli.js"
10
+ },
8
11
  "exports": {
9
12
  ".": {
10
13
  "types": "./dist/index.d.ts",
@@ -18,7 +21,10 @@
18
21
  }
19
22
  },
20
23
  "files": [
21
- "dist"
24
+ "dist",
25
+ "bin",
26
+ "standalone",
27
+ "src"
22
28
  ],
23
29
  "scripts": {
24
30
  "build": "tsup",
@@ -40,18 +46,25 @@
40
46
  ],
41
47
  "author": "",
42
48
  "license": "MIT",
49
+ "dependencies": {
50
+ "@vitejs/plugin-react": "^4.2.0",
51
+ "react": "^18.2.0",
52
+ "react-dom": "^18.2.0",
53
+ "vite": "^5.0.0"
54
+ },
43
55
  "peerDependencies": {
44
56
  "react": ">=17.0.0",
45
57
  "react-dom": ">=17.0.0"
46
58
  },
59
+ "peerDependenciesMeta": {
60
+ "react": { "optional": true },
61
+ "react-dom": { "optional": true }
62
+ },
47
63
  "devDependencies": {
48
64
  "@types/react": "^18.2.0",
49
65
  "@types/react-dom": "^18.2.0",
50
- "react": "^18.2.0",
51
- "react-dom": "^18.2.0",
52
66
  "tsup": "^8.0.0",
53
- "typescript": "^5.0.0",
54
- "vite": "^7.3.1"
67
+ "typescript": "^5.0.0"
55
68
  },
56
69
  "repository": {
57
70
  "type": "git",
@@ -0,0 +1,121 @@
1
+ import React from 'react';
2
+ import { LogEntryProps } from '../types';
3
+ import { formatJson, formatTime } from '../utils/formatters';
4
+ import { getStatusColor, getMethodColor } from '../utils/colors';
5
+
6
+ const styles = {
7
+ container: {
8
+ marginBottom: '16px',
9
+ borderBottom: '1px solid #1f2937',
10
+ paddingBottom: '16px',
11
+ },
12
+ header: {
13
+ display: 'flex',
14
+ alignItems: 'center',
15
+ gap: '8px',
16
+ marginBottom: '4px',
17
+ flexWrap: 'wrap' as const,
18
+ },
19
+ timestamp: {
20
+ color: '#6b7280',
21
+ fontSize: '12px',
22
+ fontFamily: 'monospace',
23
+ },
24
+ method: {
25
+ fontWeight: 'bold',
26
+ fontFamily: 'monospace',
27
+ },
28
+ status: {
29
+ fontWeight: 'bold',
30
+ fontFamily: 'monospace',
31
+ },
32
+ duration: {
33
+ color: '#6b7280',
34
+ fontSize: '12px',
35
+ fontFamily: 'monospace',
36
+ },
37
+ url: {
38
+ color: '#60a5fa',
39
+ wordBreak: 'break-all' as const,
40
+ marginBottom: '8px',
41
+ fontFamily: 'monospace',
42
+ fontSize: '13px',
43
+ },
44
+ bodyContainer: {
45
+ marginTop: '8px',
46
+ },
47
+ bodyLabel: {
48
+ color: '#a78bfa',
49
+ fontSize: '12px',
50
+ marginBottom: '4px',
51
+ fontFamily: 'monospace',
52
+ },
53
+ pre: {
54
+ backgroundColor: '#1f2937',
55
+ padding: '8px',
56
+ borderRadius: '4px',
57
+ color: '#86efac',
58
+ overflowX: 'auto' as const,
59
+ fontSize: '12px',
60
+ whiteSpace: 'pre-wrap' as const,
61
+ fontFamily: 'monospace',
62
+ maxHeight: '192px',
63
+ overflowY: 'auto' as const,
64
+ margin: 0,
65
+ },
66
+ errorPre: {
67
+ backgroundColor: '#1f2937',
68
+ padding: '8px',
69
+ borderRadius: '4px',
70
+ color: '#fca5a5',
71
+ overflowX: 'auto' as const,
72
+ fontSize: '12px',
73
+ whiteSpace: 'pre-wrap' as const,
74
+ fontFamily: 'monospace',
75
+ margin: 0,
76
+ },
77
+ } as const;
78
+
79
+ export const LogEntry: React.FC<LogEntryProps> = ({ log, type }) => {
80
+ return (
81
+ <div style={styles.container}>
82
+ <div style={styles.header}>
83
+ <span style={styles.timestamp}>[{formatTime(log.timestamp)}]</span>
84
+ <span style={{ ...styles.method, color: getMethodColor(log.method) }}>
85
+ {log.method}
86
+ </span>
87
+ {type === 'response' && log.status && (
88
+ <span style={{ ...styles.status, color: getStatusColor(log.status) }}>
89
+ {log.status} {log.statusText}
90
+ </span>
91
+ )}
92
+ {log.duration !== undefined && type === 'response' && (
93
+ <span style={styles.duration}>({log.duration}ms)</span>
94
+ )}
95
+ </div>
96
+
97
+ <div style={styles.url}>{log.url}</div>
98
+
99
+ {type === 'request' ? (
100
+ log.requestBody ? (
101
+ <div style={styles.bodyContainer}>
102
+ <div style={styles.bodyLabel}>{'\u25B8'} Payload:</div>
103
+ <pre style={styles.pre}>{formatJson(log.requestBody)}</pre>
104
+ </div>
105
+ ) : null
106
+ ) : log.error ? (
107
+ <div style={styles.bodyContainer}>
108
+ <div style={{ ...styles.bodyLabel, color: '#f87171' }}>
109
+ {'\u25B8'} Error:
110
+ </div>
111
+ <pre style={styles.errorPre}>{log.error}</pre>
112
+ </div>
113
+ ) : log.responseBody ? (
114
+ <div style={styles.bodyContainer}>
115
+ <div style={styles.bodyLabel}>{'\u25B8'} Response:</div>
116
+ <pre style={styles.pre}>{formatJson(log.responseBody)}</pre>
117
+ </div>
118
+ ) : null}
119
+ </div>
120
+ );
121
+ };
@@ -0,0 +1,219 @@
1
+ import React, { useState, useCallback, useEffect } from 'react';
2
+ import { NetworkLog, NetworkTerminalProps } from '../types';
3
+ import { Terminal } from './Terminal';
4
+ import { useNetworkInterceptor } from '../hooks/useNetworkInterceptor';
5
+
6
+ const styles = {
7
+ floatingButton: {
8
+ position: 'fixed' as const,
9
+ bottom: '16px',
10
+ right: '16px',
11
+ zIndex: 9999,
12
+ backgroundColor: '#1f2937',
13
+ color: '#4ade80',
14
+ padding: '8px 16px',
15
+ borderRadius: '8px',
16
+ fontFamily: 'monospace',
17
+ fontSize: '14px',
18
+ boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
19
+ border: '1px solid #374151',
20
+ cursor: 'pointer',
21
+ },
22
+ container: {
23
+ position: 'fixed' as const,
24
+ bottom: 0,
25
+ left: 0,
26
+ right: 0,
27
+ zIndex: 9999,
28
+ backgroundColor: '#111827',
29
+ borderTop: '2px solid #22c55e',
30
+ boxShadow: '0 -10px 15px -3px rgba(0, 0, 0, 0.1)',
31
+ },
32
+ containerTop: {
33
+ position: 'fixed' as const,
34
+ top: 0,
35
+ left: 0,
36
+ right: 0,
37
+ zIndex: 9999,
38
+ backgroundColor: '#111827',
39
+ borderBottom: '2px solid #22c55e',
40
+ boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
41
+ },
42
+ mainHeader: {
43
+ display: 'flex',
44
+ alignItems: 'center',
45
+ justifyContent: 'space-between',
46
+ padding: '8px 16px',
47
+ backgroundColor: '#1f2937',
48
+ borderBottom: '1px solid #374151',
49
+ },
50
+ headerLeft: {
51
+ display: 'flex',
52
+ alignItems: 'center',
53
+ gap: '12px',
54
+ },
55
+ title: {
56
+ color: '#4ade80',
57
+ fontFamily: 'monospace',
58
+ fontWeight: 'bold',
59
+ },
60
+ hint: {
61
+ color: '#6b7280',
62
+ fontFamily: 'monospace',
63
+ fontSize: '12px',
64
+ },
65
+ headerRight: {
66
+ display: 'flex',
67
+ alignItems: 'center',
68
+ gap: '8px',
69
+ },
70
+ clearButton: {
71
+ color: '#9ca3af',
72
+ fontSize: '14px',
73
+ fontFamily: 'monospace',
74
+ padding: '4px 12px',
75
+ borderRadius: '4px',
76
+ border: 'none',
77
+ background: 'transparent',
78
+ cursor: 'pointer',
79
+ },
80
+ closeButton: {
81
+ color: '#9ca3af',
82
+ fontSize: '18px',
83
+ fontWeight: 'bold',
84
+ padding: '0 8px',
85
+ border: 'none',
86
+ background: 'transparent',
87
+ cursor: 'pointer',
88
+ borderRadius: '4px',
89
+ },
90
+ terminalsContainer: {
91
+ display: 'flex',
92
+ gap: '8px',
93
+ padding: '8px',
94
+ height: '450px',
95
+ },
96
+ terminalWrapper: {
97
+ flex: 1,
98
+ display: 'flex',
99
+ flexDirection: 'column' as const,
100
+ },
101
+ } as const;
102
+
103
+ export const NetworkTerminal: React.FC<NetworkTerminalProps> = ({
104
+ maxLogs = 100,
105
+ defaultVisible = false,
106
+ position = 'bottom',
107
+ height = '450px',
108
+ zIndex = 9999,
109
+ }) => {
110
+ const [logs, setLogs] = useState<NetworkLog[]>([]);
111
+ const [isVisible, setIsVisible] = useState(defaultVisible);
112
+ const [requestExpanded, setRequestExpanded] = useState(true);
113
+ const [responseExpanded, setResponseExpanded] = useState(true);
114
+
115
+ const addLog = useCallback(
116
+ (log: NetworkLog) => {
117
+ setLogs((prev) => [...prev.slice(-(maxLogs - 1)), log]);
118
+ },
119
+ [maxLogs]
120
+ );
121
+
122
+ const updateLog = useCallback((id: string, updates: Partial<NetworkLog>) => {
123
+ setLogs((prev) =>
124
+ prev.map((log) => (log.id === id ? { ...log, ...updates } : log))
125
+ );
126
+ }, []);
127
+
128
+ useNetworkInterceptor({
129
+ enabled: isVisible,
130
+ onLogAdd: addLog,
131
+ onLogUpdate: updateLog,
132
+ });
133
+
134
+ useEffect(() => {
135
+ const handleKeyDown = (e: KeyboardEvent) => {
136
+ if (e.ctrlKey && e.shiftKey && e.key === 'N') {
137
+ e.preventDefault();
138
+ setIsVisible((prev) => !prev);
139
+ }
140
+ };
141
+
142
+ window.addEventListener('keydown', handleKeyDown);
143
+ return () => window.removeEventListener('keydown', handleKeyDown);
144
+ }, []);
145
+
146
+ const clearLogs = () => setLogs([]);
147
+
148
+ if (!isVisible) {
149
+ return (
150
+ <button
151
+ onClick={() => setIsVisible(true)}
152
+ style={{
153
+ ...styles.floatingButton,
154
+ zIndex,
155
+ }}
156
+ title="Open Network Terminal (Ctrl+Shift+N)"
157
+ >
158
+ {'>'}_ Network
159
+ </button>
160
+ );
161
+ }
162
+
163
+ const containerStyle =
164
+ position === 'top'
165
+ ? { ...styles.containerTop, zIndex }
166
+ : { ...styles.container, zIndex };
167
+
168
+ return (
169
+ <div style={containerStyle}>
170
+ <div style={styles.mainHeader}>
171
+ <div style={styles.headerLeft}>
172
+ <span style={styles.title}>{'>'}_ Network Terminal</span>
173
+ <span style={styles.hint}>Press Ctrl+Shift+N to toggle</span>
174
+ </div>
175
+ <div style={styles.headerRight}>
176
+ <button
177
+ onClick={clearLogs}
178
+ style={styles.clearButton}
179
+ onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
180
+ onMouseLeave={(e) => (e.currentTarget.style.color = '#9ca3af')}
181
+ >
182
+ Clear All
183
+ </button>
184
+ <button
185
+ onClick={() => setIsVisible(false)}
186
+ style={styles.closeButton}
187
+ onMouseEnter={(e) => (e.currentTarget.style.color = '#f87171')}
188
+ onMouseLeave={(e) => (e.currentTarget.style.color = '#9ca3af')}
189
+ >
190
+ {'\u00D7'}
191
+ </button>
192
+ </div>
193
+ </div>
194
+
195
+ <div style={{ ...styles.terminalsContainer, height }}>
196
+ <div style={styles.terminalWrapper}>
197
+ <Terminal
198
+ title="Requests"
199
+ logs={logs}
200
+ type="request"
201
+ onClear={clearLogs}
202
+ expanded={requestExpanded}
203
+ onToggleExpand={() => setRequestExpanded(!requestExpanded)}
204
+ />
205
+ </div>
206
+ <div style={styles.terminalWrapper}>
207
+ <Terminal
208
+ title="Responses"
209
+ logs={logs}
210
+ type="response"
211
+ onClear={clearLogs}
212
+ expanded={responseExpanded}
213
+ onToggleExpand={() => setResponseExpanded(!responseExpanded)}
214
+ />
215
+ </div>
216
+ </div>
217
+ </div>
218
+ );
219
+ };
@@ -0,0 +1,86 @@
1
+ import React, { useRef, useState, useEffect } from 'react';
2
+ import { TerminalProps } from '../types';
3
+ import { TerminalHeader } from './TerminalHeader';
4
+ import { LogEntry } from './LogEntry';
5
+
6
+ const styles = {
7
+ container: {
8
+ display: 'flex',
9
+ flexDirection: 'column' as const,
10
+ backgroundColor: '#111827',
11
+ borderRadius: '8px',
12
+ border: '1px solid #374151',
13
+ overflow: 'hidden',
14
+ transition: 'all 0.2s',
15
+ flex: 1,
16
+ },
17
+ collapsed: {
18
+ height: '48px',
19
+ flex: 'none' as const,
20
+ },
21
+ body: {
22
+ flex: 1,
23
+ overflow: 'auto',
24
+ padding: '16px',
25
+ fontFamily: 'monospace',
26
+ fontSize: '14px',
27
+ minHeight: '200px',
28
+ maxHeight: '400px',
29
+ },
30
+ empty: {
31
+ color: '#6b7280',
32
+ fontStyle: 'italic',
33
+ },
34
+ } as const;
35
+
36
+ export const Terminal: React.FC<TerminalProps> = ({
37
+ title,
38
+ logs,
39
+ type,
40
+ onClear,
41
+ expanded,
42
+ onToggleExpand,
43
+ }) => {
44
+ const terminalRef = useRef<HTMLDivElement>(null);
45
+ const [autoScroll, setAutoScroll] = useState(true);
46
+
47
+ useEffect(() => {
48
+ if (autoScroll && terminalRef.current) {
49
+ terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
50
+ }
51
+ }, [logs, autoScroll]);
52
+
53
+ const handleScroll = () => {
54
+ if (terminalRef.current) {
55
+ const { scrollTop, scrollHeight, clientHeight } = terminalRef.current;
56
+ setAutoScroll(scrollHeight - scrollTop - clientHeight < 50);
57
+ }
58
+ };
59
+
60
+ return (
61
+ <div
62
+ style={{
63
+ ...styles.container,
64
+ ...(expanded ? {} : styles.collapsed),
65
+ }}
66
+ >
67
+ <TerminalHeader
68
+ title={title}
69
+ count={logs.length}
70
+ expanded={expanded}
71
+ onClear={onClear}
72
+ onToggleExpand={onToggleExpand}
73
+ />
74
+
75
+ {expanded && (
76
+ <div ref={terminalRef} onScroll={handleScroll} style={styles.body}>
77
+ {logs.length === 0 ? (
78
+ <div style={styles.empty}>Waiting for network requests...</div>
79
+ ) : (
80
+ logs.map((log) => <LogEntry key={log.id} log={log} type={type} />)
81
+ )}
82
+ </div>
83
+ )}
84
+ </div>
85
+ );
86
+ };
@@ -0,0 +1,93 @@
1
+ import React from 'react';
2
+ import { TerminalHeaderProps } from '../types';
3
+
4
+ const styles = {
5
+ header: {
6
+ display: 'flex',
7
+ alignItems: 'center',
8
+ justifyContent: 'space-between',
9
+ padding: '8px 16px',
10
+ backgroundColor: '#1f2937',
11
+ borderBottom: '1px solid #374151',
12
+ },
13
+ left: {
14
+ display: 'flex',
15
+ alignItems: 'center',
16
+ gap: '8px',
17
+ },
18
+ trafficLights: {
19
+ display: 'flex',
20
+ gap: '6px',
21
+ },
22
+ dot: {
23
+ width: '12px',
24
+ height: '12px',
25
+ borderRadius: '50%',
26
+ },
27
+ title: {
28
+ color: '#d1d5db',
29
+ fontFamily: 'monospace',
30
+ fontSize: '14px',
31
+ marginLeft: '8px',
32
+ },
33
+ count: {
34
+ color: '#6b7280',
35
+ fontFamily: 'monospace',
36
+ fontSize: '12px',
37
+ },
38
+ right: {
39
+ display: 'flex',
40
+ alignItems: 'center',
41
+ gap: '8px',
42
+ },
43
+ button: {
44
+ color: '#9ca3af',
45
+ fontSize: '12px',
46
+ fontFamily: 'monospace',
47
+ padding: '4px 8px',
48
+ borderRadius: '4px',
49
+ border: 'none',
50
+ background: 'transparent',
51
+ cursor: 'pointer',
52
+ },
53
+ } as const;
54
+
55
+ export const TerminalHeader: React.FC<TerminalHeaderProps> = ({
56
+ title,
57
+ count,
58
+ expanded,
59
+ onClear,
60
+ onToggleExpand,
61
+ }) => {
62
+ return (
63
+ <div style={styles.header}>
64
+ <div style={styles.left}>
65
+ <div style={styles.trafficLights}>
66
+ <div style={{ ...styles.dot, backgroundColor: '#ef4444' }} />
67
+ <div style={{ ...styles.dot, backgroundColor: '#eab308' }} />
68
+ <div style={{ ...styles.dot, backgroundColor: '#22c55e' }} />
69
+ </div>
70
+ <span style={styles.title}>{title}</span>
71
+ <span style={styles.count}>({count} entries)</span>
72
+ </div>
73
+ <div style={styles.right}>
74
+ <button
75
+ onClick={onClear}
76
+ style={styles.button}
77
+ onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
78
+ onMouseLeave={(e) => (e.currentTarget.style.color = '#9ca3af')}
79
+ >
80
+ Clear
81
+ </button>
82
+ <button
83
+ onClick={onToggleExpand}
84
+ style={styles.button}
85
+ onMouseEnter={(e) => (e.currentTarget.style.color = '#fff')}
86
+ onMouseLeave={(e) => (e.currentTarget.style.color = '#9ca3af')}
87
+ >
88
+ {expanded ? '\u25BC' : '\u25B2'}
89
+ </button>
90
+ </div>
91
+ </div>
92
+ );
93
+ };
@@ -0,0 +1,190 @@
1
+ import { useEffect, useRef, useCallback } from 'react';
2
+ import { NetworkLog, UseNetworkInterceptorProps } from '../types';
3
+
4
+ export const useNetworkInterceptor = ({
5
+ enabled,
6
+ onLogAdd,
7
+ onLogUpdate,
8
+ }: UseNetworkInterceptorProps) => {
9
+ const originalFetch = useRef<typeof fetch | null>(null);
10
+ const originalXHROpen = useRef<typeof XMLHttpRequest.prototype.open | null>(null);
11
+ const originalXHRSend = useRef<typeof XMLHttpRequest.prototype.send | null>(null);
12
+
13
+ const generateId = useCallback((prefix: string) => {
14
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
15
+ }, []);
16
+
17
+ // Intercept Fetch
18
+ useEffect(() => {
19
+ if (!enabled) return;
20
+
21
+ originalFetch.current = window.fetch;
22
+
23
+ window.fetch = async function (input, init) {
24
+ const id = generateId('fetch');
25
+ const startTime = performance.now();
26
+
27
+ const url =
28
+ typeof input === 'string'
29
+ ? input
30
+ : input instanceof URL
31
+ ? input.href
32
+ : input.url;
33
+
34
+ const method = init?.method || 'GET';
35
+
36
+ let requestBody: unknown = null;
37
+ if (init?.body) {
38
+ try {
39
+ requestBody =
40
+ typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
41
+ } catch {
42
+ requestBody = init.body;
43
+ }
44
+ }
45
+
46
+ onLogAdd({
47
+ id,
48
+ timestamp: new Date(),
49
+ method: method.toUpperCase(),
50
+ url,
51
+ requestBody,
52
+ type: 'fetch',
53
+ });
54
+
55
+ try {
56
+ const response = await originalFetch.current!(input, init);
57
+ const duration = Math.round(performance.now() - startTime);
58
+
59
+ const clonedResponse = response.clone();
60
+ let responseBody: unknown = null;
61
+
62
+ try {
63
+ responseBody = await clonedResponse.json();
64
+ } catch {
65
+ try {
66
+ responseBody = await clonedResponse.text();
67
+ } catch {
68
+ responseBody = '[Could not read response body]';
69
+ }
70
+ }
71
+
72
+ onLogUpdate(id, {
73
+ status: response.status,
74
+ statusText: response.statusText,
75
+ responseBody,
76
+ duration,
77
+ });
78
+
79
+ return response;
80
+ } catch (error: unknown) {
81
+ const duration = Math.round(performance.now() - startTime);
82
+ const errorMessage =
83
+ error instanceof Error ? error.message : 'Network Error';
84
+ onLogUpdate(id, {
85
+ error: errorMessage,
86
+ duration,
87
+ });
88
+ throw error;
89
+ }
90
+ };
91
+
92
+ return () => {
93
+ if (originalFetch.current) {
94
+ window.fetch = originalFetch.current;
95
+ }
96
+ };
97
+ }, [enabled, onLogAdd, onLogUpdate, generateId]);
98
+
99
+ // Intercept XHR
100
+ useEffect(() => {
101
+ if (!enabled) return;
102
+
103
+ originalXHROpen.current = XMLHttpRequest.prototype.open;
104
+ originalXHRSend.current = XMLHttpRequest.prototype.send;
105
+
106
+ XMLHttpRequest.prototype.open = function (
107
+ method: string,
108
+ url: string | URL
109
+ ) {
110
+ (this as XMLHttpRequest & { _networkTerminal: NetworkLog & { startTime: number } })._networkTerminal = {
111
+ id: generateId('xhr'),
112
+ method: method.toUpperCase(),
113
+ url: url.toString(),
114
+ startTime: 0,
115
+ timestamp: new Date(),
116
+ type: 'xhr',
117
+ };
118
+ return originalXHROpen.current!.apply(
119
+ this,
120
+ arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.open>
121
+ );
122
+ };
123
+
124
+ XMLHttpRequest.prototype.send = function (body?: Document | XMLHttpRequestBodyInit | null) {
125
+ const meta = (this as XMLHttpRequest & { _networkTerminal?: NetworkLog & { startTime: number } })._networkTerminal;
126
+
127
+ if (meta) {
128
+ meta.startTime = performance.now();
129
+
130
+ let requestBody: unknown = null;
131
+ if (body) {
132
+ try {
133
+ requestBody = typeof body === 'string' ? JSON.parse(body) : body;
134
+ } catch {
135
+ requestBody = body;
136
+ }
137
+ }
138
+
139
+ onLogAdd({
140
+ id: meta.id,
141
+ timestamp: new Date(),
142
+ method: meta.method,
143
+ url: meta.url,
144
+ requestBody,
145
+ type: 'xhr',
146
+ });
147
+
148
+ this.addEventListener('load', function () {
149
+ const duration = Math.round(performance.now() - meta.startTime);
150
+
151
+ let responseBody: unknown = null;
152
+ try {
153
+ responseBody = JSON.parse(this.responseText);
154
+ } catch {
155
+ responseBody = this.responseText;
156
+ }
157
+
158
+ onLogUpdate(meta.id, {
159
+ status: this.status,
160
+ statusText: this.statusText,
161
+ responseBody,
162
+ duration,
163
+ });
164
+ });
165
+
166
+ this.addEventListener('error', function () {
167
+ const duration = Math.round(performance.now() - meta.startTime);
168
+ onLogUpdate(meta.id, {
169
+ error: 'Network Error',
170
+ duration,
171
+ });
172
+ });
173
+ }
174
+
175
+ return originalXHRSend.current!.apply(
176
+ this,
177
+ arguments as unknown as Parameters<typeof XMLHttpRequest.prototype.send>
178
+ );
179
+ };
180
+
181
+ return () => {
182
+ if (originalXHROpen.current) {
183
+ XMLHttpRequest.prototype.open = originalXHROpen.current;
184
+ }
185
+ if (originalXHRSend.current) {
186
+ XMLHttpRequest.prototype.send = originalXHRSend.current;
187
+ }
188
+ };
189
+ }, [enabled, onLogAdd, onLogUpdate, generateId]);
190
+ };
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export { NetworkTerminal } from './components/NetworkTerminal';
2
+ export { Terminal } from './components/Terminal';
3
+ export { TerminalHeader } from './components/TerminalHeader';
4
+ export { LogEntry } from './components/LogEntry';
5
+ export { useNetworkInterceptor } from './hooks/useNetworkInterceptor';
6
+ export { formatJson, truncate, formatTime } from './utils/formatters';
7
+ export { getStatusColor, getMethodColor } from './utils/colors';
8
+ export { networkTerminal } from './vite-plugin';
9
+ export type {
10
+ NetworkLog,
11
+ NetworkTerminalProps,
12
+ TerminalProps,
13
+ TerminalHeaderProps,
14
+ LogEntryProps,
15
+ UseNetworkInterceptorProps,
16
+ } from './types';
17
+ export type { NetworkTerminalPluginOptions } from './vite-plugin';
package/src/types.ts ADDED
@@ -0,0 +1,50 @@
1
+ export interface NetworkLog {
2
+ id: string;
3
+ timestamp: Date;
4
+ method: string;
5
+ url: string;
6
+ status?: number;
7
+ statusText?: string;
8
+ requestHeaders?: Record<string, string>;
9
+ requestBody?: unknown;
10
+ responseBody?: unknown;
11
+ duration?: number;
12
+ error?: string;
13
+ type: 'fetch' | 'xhr';
14
+ }
15
+
16
+ export interface TerminalProps {
17
+ title: string;
18
+ logs: NetworkLog[];
19
+ type: 'request' | 'response';
20
+ onClear: () => void;
21
+ expanded: boolean;
22
+ onToggleExpand: () => void;
23
+ }
24
+
25
+ export interface LogEntryProps {
26
+ log: NetworkLog;
27
+ type: 'request' | 'response';
28
+ }
29
+
30
+ export interface TerminalHeaderProps {
31
+ title: string;
32
+ count: number;
33
+ expanded: boolean;
34
+ onClear: () => void;
35
+ onToggleExpand: () => void;
36
+ }
37
+
38
+ export interface UseNetworkInterceptorProps {
39
+ enabled: boolean;
40
+ onLogAdd: (log: NetworkLog) => void;
41
+ onLogUpdate: (id: string, updates: Partial<NetworkLog>) => void;
42
+ }
43
+
44
+ export interface NetworkTerminalProps {
45
+ maxLogs?: number;
46
+ defaultVisible?: boolean;
47
+ position?: 'bottom' | 'top';
48
+ height?: string;
49
+ zIndex?: number;
50
+ }
@@ -0,0 +1,18 @@
1
+ export const getStatusColor = (status?: number): string => {
2
+ if (!status) return '#9ca3af'; // gray-400
3
+ if (status >= 200 && status < 300) return '#4ade80'; // green-400
4
+ if (status >= 300 && status < 400) return '#facc15'; // yellow-400
5
+ if (status >= 400 && status < 500) return '#fb923c'; // orange-400
6
+ return '#f87171'; // red-400
7
+ };
8
+
9
+ export const getMethodColor = (method: string): string => {
10
+ const colors: Record<string, string> = {
11
+ GET: '#22d3ee', // cyan-400
12
+ POST: '#4ade80', // green-400
13
+ PUT: '#facc15', // yellow-400
14
+ PATCH: '#fb923c', // orange-400
15
+ DELETE: '#f87171', // red-400
16
+ };
17
+ return colors[method.toUpperCase()] || '#9ca3af';
18
+ };
@@ -0,0 +1,26 @@
1
+ export const formatJson = (data: unknown): string => {
2
+ if (!data) return '';
3
+ try {
4
+ if (typeof data === 'string') {
5
+ const parsed = JSON.parse(data);
6
+ return JSON.stringify(parsed, null, 2);
7
+ }
8
+ return JSON.stringify(data, null, 2);
9
+ } catch {
10
+ return String(data);
11
+ }
12
+ };
13
+
14
+ export const truncate = (str: string, maxLength: number): string => {
15
+ if (str.length <= maxLength) return str;
16
+ return str.slice(0, maxLength) + '...';
17
+ };
18
+
19
+ export const formatTime = (date: Date): string => {
20
+ return date.toLocaleTimeString('en-US', {
21
+ hour12: false,
22
+ hour: '2-digit',
23
+ minute: '2-digit',
24
+ second: '2-digit',
25
+ });
26
+ };
@@ -0,0 +1,88 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ export interface NetworkTerminalPluginOptions {
4
+ /** Position of the terminal: 'top' or 'bottom' (default: 'bottom') */
5
+ position?: 'top' | 'bottom';
6
+ /** Maximum number of logs to keep (default: 100) */
7
+ maxLogs?: number;
8
+ /** Terminal height (default: '450px') */
9
+ height?: string;
10
+ /** CSS z-index (default: 9999) */
11
+ zIndex?: number;
12
+ }
13
+
14
+ const VIRTUAL_MODULE_ID = 'virtual:network-terminal';
15
+ const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
16
+
17
+ export function networkTerminal(options: NetworkTerminalPluginOptions = {}): Plugin {
18
+ const {
19
+ position = 'bottom',
20
+ maxLogs = 100,
21
+ height = '450px',
22
+ zIndex = 9999,
23
+ } = options;
24
+
25
+ return {
26
+ name: 'vite-plugin-network-terminal',
27
+ apply: 'serve', // Only apply during development
28
+
29
+ resolveId(id) {
30
+ if (id === VIRTUAL_MODULE_ID) {
31
+ return RESOLVED_VIRTUAL_MODULE_ID;
32
+ }
33
+ },
34
+
35
+ load(id) {
36
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
37
+ return `
38
+ import React from 'react';
39
+ import ReactDOM from 'react-dom/client';
40
+ import { NetworkTerminal } from 'network-terminal';
41
+
42
+ function initNetworkTerminal() {
43
+ const containerId = '__network-terminal-root__';
44
+ let container = document.getElementById(containerId);
45
+
46
+ if (!container) {
47
+ container = document.createElement('div');
48
+ container.id = containerId;
49
+ document.body.appendChild(container);
50
+ }
51
+
52
+ const root = ReactDOM.createRoot(container);
53
+ root.render(
54
+ React.createElement(NetworkTerminal, {
55
+ position: '${position}',
56
+ maxLogs: ${maxLogs},
57
+ height: '${height}',
58
+ zIndex: ${zIndex},
59
+ defaultVisible: false,
60
+ })
61
+ );
62
+ }
63
+
64
+ if (document.readyState === 'loading') {
65
+ document.addEventListener('DOMContentLoaded', initNetworkTerminal);
66
+ } else {
67
+ initNetworkTerminal();
68
+ }
69
+ `;
70
+ }
71
+ },
72
+
73
+ transformIndexHtml(html) {
74
+ return {
75
+ html,
76
+ tags: [
77
+ {
78
+ tag: 'script',
79
+ attrs: { type: 'module', src: '/@id/__x00__virtual:network-terminal' },
80
+ injectTo: 'body',
81
+ },
82
+ ],
83
+ };
84
+ },
85
+ };
86
+ }
87
+
88
+ export default networkTerminal;
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Network Terminal</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
15
+ background: #0f172a;
16
+ color: #e2e8f0;
17
+ min-height: 100vh;
18
+ }
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <div id="root"></div>
23
+ <script type="module" src="/main.tsx"></script>
24
+ </body>
25
+ </html>
@@ -0,0 +1,216 @@
1
+ import React, { useState } from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { NetworkTerminal } from '../src';
4
+
5
+ const styles = {
6
+ container: {
7
+ padding: '40px',
8
+ maxWidth: '800px',
9
+ margin: '0 auto',
10
+ },
11
+ header: {
12
+ marginBottom: '40px',
13
+ },
14
+ title: {
15
+ fontSize: '32px',
16
+ fontWeight: 'bold',
17
+ color: '#4ade80',
18
+ fontFamily: 'monospace',
19
+ marginBottom: '8px',
20
+ },
21
+ subtitle: {
22
+ color: '#94a3b8',
23
+ fontSize: '16px',
24
+ },
25
+ section: {
26
+ background: '#1e293b',
27
+ borderRadius: '12px',
28
+ padding: '24px',
29
+ marginBottom: '24px',
30
+ },
31
+ sectionTitle: {
32
+ fontSize: '18px',
33
+ fontWeight: '600',
34
+ marginBottom: '16px',
35
+ color: '#f1f5f9',
36
+ },
37
+ buttonGroup: {
38
+ display: 'flex',
39
+ gap: '12px',
40
+ flexWrap: 'wrap' as const,
41
+ },
42
+ button: {
43
+ padding: '10px 20px',
44
+ borderRadius: '8px',
45
+ border: 'none',
46
+ cursor: 'pointer',
47
+ fontWeight: '500',
48
+ fontSize: '14px',
49
+ transition: 'all 0.2s',
50
+ },
51
+ getButton: {
52
+ background: '#22c55e',
53
+ color: '#000',
54
+ },
55
+ postButton: {
56
+ background: '#3b82f6',
57
+ color: '#fff',
58
+ },
59
+ putButton: {
60
+ background: '#f59e0b',
61
+ color: '#000',
62
+ },
63
+ deleteButton: {
64
+ background: '#ef4444',
65
+ color: '#fff',
66
+ },
67
+ errorButton: {
68
+ background: '#6b7280',
69
+ color: '#fff',
70
+ },
71
+ inputGroup: {
72
+ display: 'flex',
73
+ gap: '12px',
74
+ marginBottom: '16px',
75
+ },
76
+ input: {
77
+ flex: 1,
78
+ padding: '10px 16px',
79
+ borderRadius: '8px',
80
+ border: '1px solid #374151',
81
+ background: '#0f172a',
82
+ color: '#e2e8f0',
83
+ fontSize: '14px',
84
+ },
85
+ hint: {
86
+ marginTop: '24px',
87
+ padding: '16px',
88
+ background: '#0f172a',
89
+ borderRadius: '8px',
90
+ borderLeft: '4px solid #4ade80',
91
+ },
92
+ hintText: {
93
+ color: '#94a3b8',
94
+ fontSize: '14px',
95
+ fontFamily: 'monospace',
96
+ },
97
+ kbd: {
98
+ background: '#374151',
99
+ padding: '2px 8px',
100
+ borderRadius: '4px',
101
+ fontSize: '12px',
102
+ color: '#4ade80',
103
+ },
104
+ };
105
+
106
+ function App() {
107
+ const [customUrl, setCustomUrl] = useState('https://jsonplaceholder.typicode.com/posts/1');
108
+
109
+ const makeRequest = async (method: string, url: string, body?: object) => {
110
+ try {
111
+ const options: RequestInit = {
112
+ method,
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ },
116
+ };
117
+ if (body) {
118
+ options.body = JSON.stringify(body);
119
+ }
120
+ await fetch(url, options);
121
+ } catch (error) {
122
+ console.error('Request failed:', error);
123
+ }
124
+ };
125
+
126
+ return (
127
+ <div style={styles.container}>
128
+ <div style={styles.header}>
129
+ <h1 style={styles.title}>&gt;_ Network Terminal</h1>
130
+ <p style={styles.subtitle}>Monitor your Fetch/XHR requests in real-time</p>
131
+ </div>
132
+
133
+ <div style={styles.section}>
134
+ <h2 style={styles.sectionTitle}>Quick Actions</h2>
135
+ <div style={styles.buttonGroup}>
136
+ <button
137
+ style={{ ...styles.button, ...styles.getButton }}
138
+ onClick={() => makeRequest('GET', 'https://jsonplaceholder.typicode.com/posts/1')}
139
+ >
140
+ GET Request
141
+ </button>
142
+ <button
143
+ style={{ ...styles.button, ...styles.postButton }}
144
+ onClick={() =>
145
+ makeRequest('POST', 'https://jsonplaceholder.typicode.com/posts', {
146
+ title: 'Test Post',
147
+ body: 'This is a test post body',
148
+ userId: 1,
149
+ })
150
+ }
151
+ >
152
+ POST Request
153
+ </button>
154
+ <button
155
+ style={{ ...styles.button, ...styles.putButton }}
156
+ onClick={() =>
157
+ makeRequest('PUT', 'https://jsonplaceholder.typicode.com/posts/1', {
158
+ id: 1,
159
+ title: 'Updated Title',
160
+ body: 'Updated body content',
161
+ userId: 1,
162
+ })
163
+ }
164
+ >
165
+ PUT Request
166
+ </button>
167
+ <button
168
+ style={{ ...styles.button, ...styles.deleteButton }}
169
+ onClick={() => makeRequest('DELETE', 'https://jsonplaceholder.typicode.com/posts/1')}
170
+ >
171
+ DELETE Request
172
+ </button>
173
+ <button
174
+ style={{ ...styles.button, ...styles.errorButton }}
175
+ onClick={() => makeRequest('GET', 'https://jsonplaceholder.typicode.com/invalid-endpoint')}
176
+ >
177
+ 404 Error
178
+ </button>
179
+ </div>
180
+ </div>
181
+
182
+ <div style={styles.section}>
183
+ <h2 style={styles.sectionTitle}>Custom Request</h2>
184
+ <div style={styles.inputGroup}>
185
+ <input
186
+ type="text"
187
+ value={customUrl}
188
+ onChange={(e) => setCustomUrl(e.target.value)}
189
+ placeholder="Enter URL..."
190
+ style={styles.input}
191
+ />
192
+ <button
193
+ style={{ ...styles.button, ...styles.getButton }}
194
+ onClick={() => makeRequest('GET', customUrl)}
195
+ >
196
+ Send GET
197
+ </button>
198
+ </div>
199
+ </div>
200
+
201
+ <div style={styles.hint}>
202
+ <p style={styles.hintText}>
203
+ Press <span style={styles.kbd}>Ctrl+Shift+N</span> to toggle the Network Terminal
204
+ </p>
205
+ </div>
206
+
207
+ <NetworkTerminal defaultVisible={true} position="bottom" maxLogs={50} />
208
+ </div>
209
+ );
210
+ }
211
+
212
+ ReactDOM.createRoot(document.getElementById('root')!).render(
213
+ <React.StrictMode>
214
+ <App />
215
+ </React.StrictMode>
216
+ );
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import path from 'path';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ resolve: {
8
+ alias: {
9
+ 'network-terminal': path.resolve(__dirname, '../src'),
10
+ },
11
+ },
12
+ });