network-terminal 1.0.1 → 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.
@@ -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>