From 65ad78b9c048f3b362b00740602af1a4d0f9d8fe Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Wed, 18 Feb 2026 12:04:02 -0800 Subject: [PATCH] feat(devtools): migrate devtools package into monorepo (#18936) --- .gitignore | 1 + esbuild.config.js | 2 +- package-lock.json | 59 +- package.json | 4 +- .../cli/src/utils/devtoolsService.test.ts | 2 +- packages/cli/src/utils/devtoolsService.ts | 3 +- packages/devtools/GEMINI.md | 85 + packages/devtools/client/index.html | 25 + packages/devtools/client/src/App.tsx | 1875 +++++++++++++++++ packages/devtools/client/src/hooks.ts | 94 + packages/devtools/client/src/main.tsx | 15 + packages/devtools/esbuild.client.js | 30 + packages/devtools/package.json | 32 + packages/devtools/src/index.ts | 359 ++++ packages/devtools/src/types.ts | 39 + packages/devtools/tsconfig.build.json | 13 + packages/devtools/tsconfig.json | 10 + 17 files changed, 2626 insertions(+), 22 deletions(-) create mode 100644 packages/devtools/GEMINI.md create mode 100644 packages/devtools/client/index.html create mode 100644 packages/devtools/client/src/App.tsx create mode 100644 packages/devtools/client/src/hooks.ts create mode 100644 packages/devtools/client/src/main.tsx create mode 100644 packages/devtools/esbuild.client.js create mode 100644 packages/devtools/package.json create mode 100644 packages/devtools/src/index.ts create mode 100644 packages/devtools/src/types.ts create mode 100644 packages/devtools/tsconfig.build.json create mode 100644 packages/devtools/tsconfig.json diff --git a/.gitignore b/.gitignore index afacf2a947..a2a6553cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ packages/*/coverage/ # Generated files packages/cli/src/generated/ packages/core/src/generated/ +packages/devtools/src/_client-assets.ts .integration-tests/ packages/vscode-ide-companion/*.vsix packages/cli/download-ripgrep*/ diff --git a/esbuild.config.js b/esbuild.config.js index b2d33770cc..45e17d0b2f 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -63,7 +63,7 @@ const external = [ '@lydell/node-pty-win32-arm64', '@lydell/node-pty-win32-x64', 'keytar', - 'gemini-cli-devtools', + '@google/gemini-cli-devtools', ]; const baseConfig = { diff --git a/package-lock.json b/package-lock.json index 1f280f503b..5659d5f16f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.5.3", "react-devtools-core": "^6.1.2", + "react-dom": "^19.2.0", "semver": "^7.7.2", "strip-ansi": "^7.1.2", "ts-prune": "^0.10.3", @@ -76,7 +77,6 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", - "gemini-cli-devtools": "^0.2.1", "keytar": "^7.9.0", "node-pty": "^1.0.0" } @@ -1389,6 +1389,10 @@ "resolved": "packages/core", "link": true }, + "node_modules/@google/gemini-cli-devtools": { + "resolved": "packages/devtools", + "link": true + }, "node_modules/@google/gemini-cli-sdk": { "resolved": "packages/sdk", "link": true @@ -9067,18 +9071,6 @@ "node": ">=14" } }, - "node_modules/gemini-cli-devtools": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/gemini-cli-devtools/-/gemini-cli-devtools-0.2.1.tgz", - "integrity": "sha512-PcqPL9ZZjgjsp3oYhcXnUc6yNeLvdZuU/UQp0aT+DA8pt3BZzPzXthlOmIrRRqHBdLjMLPwN5GD29zR5bASXtQ==", - "optional": true, - "dependencies": { - "ws": "^8.16.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/gemini-cli-vscode-ide-companion": { "resolved": "packages/vscode-ide-companion", "link": true @@ -13722,9 +13714,9 @@ } }, "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "peer": true, "engines": { @@ -13765,6 +13757,26 @@ } } }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -17598,6 +17610,21 @@ "uuid": "dist-node/bin/uuid" } }, + "packages/devtools": { + "name": "@google/gemini-cli-devtools", + "version": "0.30.0-nightly.20260210.a2174751d", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.16.0" + }, + "devDependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "engines": { + "node": ">=20" + } + }, "packages/sdk": { "name": "@google/gemini-cli-sdk", "version": "0.29.0-nightly.20260203.71f46f116", diff --git a/package.json b/package.json index 820ae04826..1750b0d99e 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:packages": "npm run build --workspaces", "build:sandbox": "node scripts/build_sandbox.js", - "bundle": "npm run generate && node esbuild.config.js && node scripts/copy_bundle_assets.js", + "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", "test": "npm run test --workspaces --if-present", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", @@ -117,6 +117,7 @@ "npm-run-all": "^4.1.5", "prettier": "^3.5.3", "react-devtools-core": "^6.1.2", + "react-dom": "^19.2.0", "semver": "^7.7.2", "strip-ansi": "^7.1.2", "ts-prune": "^0.10.3", @@ -138,7 +139,6 @@ "@lydell/node-pty-linux-x64": "1.1.0", "@lydell/node-pty-win32-arm64": "1.1.0", "@lydell/node-pty-win32-x64": "1.1.0", - "gemini-cli-devtools": "^0.2.1", "keytar": "^7.9.0", "node-pty": "^1.0.0" }, diff --git a/packages/cli/src/utils/devtoolsService.test.ts b/packages/cli/src/utils/devtoolsService.test.ts index cb3b907d97..981d121ffe 100644 --- a/packages/cli/src/utils/devtoolsService.test.ts +++ b/packages/cli/src/utils/devtoolsService.test.ts @@ -90,7 +90,7 @@ vi.mock('ws', () => ({ default: MockWebSocket, })); -vi.mock('gemini-cli-devtools', () => ({ +vi.mock('@google/gemini-cli-devtools', () => ({ DevTools: { getInstance: () => mockDevToolsInstance, }, diff --git a/packages/cli/src/utils/devtoolsService.ts b/packages/cli/src/utils/devtoolsService.ts index 5e4b7710c4..401e33de88 100644 --- a/packages/cli/src/utils/devtoolsService.ts +++ b/packages/cli/src/utils/devtoolsService.ts @@ -19,7 +19,6 @@ interface IDevTools { getPort(): number; } -const DEVTOOLS_PKG = 'gemini-cli-devtools'; const DEFAULT_DEVTOOLS_PORT = 25417; const DEFAULT_DEVTOOLS_HOST = '127.0.0.1'; const MAX_PROMOTION_ATTEMPTS = 3; @@ -62,7 +61,7 @@ async function startOrJoinDevTools( defaultHost: string, defaultPort: number, ): Promise<{ host: string; port: number }> { - const mod = await import(DEVTOOLS_PKG); + const mod = await import('@google/gemini-cli-devtools'); const devtools: IDevTools = mod.DevTools.getInstance(); const url = await devtools.start(); const actualPort = devtools.getPort(); diff --git a/packages/devtools/GEMINI.md b/packages/devtools/GEMINI.md new file mode 100644 index 0000000000..9da1828a25 --- /dev/null +++ b/packages/devtools/GEMINI.md @@ -0,0 +1,85 @@ +# Gemini CLI DevTools + +Integrated Developer Tools for Gemini CLI, providing a Chrome DevTools-like +interface for Network and Console inspection. Launched automatically when the +`general.devtools` setting is enabled. + +## Features + +- **Network Inspector**: Real-time request/response logging with streaming + chunks and duration tracking +- **Console Inspector**: Real-time console log viewing + (log/warn/error/debug/info) +- **Session Management**: Multiple CLI session support with live connection + status +- **Import/Export**: Import JSONL log files, export current session logs + +## How It Works + +When `general.devtools` is enabled, the CLI's `devtoolsService` automatically: + +1. Probes port 25417 for an existing DevTools instance +2. If found, connects as a WebSocket client +3. If not, starts a new DevTools server and connects to it +4. If another instance races for the port, the loser connects to the winner + +No environment variables needed for normal use. + +## Architecture + +``` +gemini.tsx / nonInteractiveCli.ts + │ (dynamic import) + ▼ + devtoolsService.ts ← orchestration + DevTools lifecycle + │ (imports) + ▼ + activityLogger.ts ← pure logging (capture, file, WebSocket transport) + │ (events) + ▼ + DevTools server (:25417) ← this package (HTTP + WebSocket + SSE) + │ (SSE /events) + ▼ + DevTools UI (React) ← client/ compiled by esbuild +``` + +## Environment Variables + +| Variable | Description | +| -------------------------------- | --------------------------------------------- | +| `GEMINI_CLI_ACTIVITY_LOG_TARGET` | File path for JSONL mode (optional, fallback) | + +## API Endpoints + +| Endpoint | Method | Description | +| --------- | --------- | --------------------------------------------------------------------------- | +| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) | +| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events | + +## Development + +```bash +# Build everything (client + server) +npm run build + +# Rebuild client only after UI changes +npm run build:client +``` + +### Project Structure + +``` +packages/devtools/ +├── src/ +│ └── index.ts # DevTools server (HTTP, WebSocket, SSE) +├── client/ +│ ├── index.html +│ └── src/ +│ ├── main.tsx # React entry +│ ├── App.tsx # DevTools UI +│ └── hooks.ts # Data fetching hooks +├── esbuild.client.js # Client build script +└── dist/ # Build output + ├── src/index.js # Compiled server + └── client/ # Bundled client assets +``` diff --git a/packages/devtools/client/index.html b/packages/devtools/client/index.html new file mode 100644 index 0000000000..6c786b94cd --- /dev/null +++ b/packages/devtools/client/index.html @@ -0,0 +1,25 @@ + + + + + + Gemini CLI DevTools + + + +
+ + + diff --git a/packages/devtools/client/src/App.tsx b/packages/devtools/client/src/App.tsx new file mode 100644 index 0000000000..bb5509b38e --- /dev/null +++ b/packages/devtools/client/src/App.tsx @@ -0,0 +1,1875 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks'; + +type ThemeMode = 'light' | 'dark' | null; // null means follow system + +interface ThemeColors { + bg: string; + bgSecondary: string; + bgHover: string; + border: string; + text: string; + textSecondary: string; + accent: string; + consoleBg: string; + rowBorder: string; + errorBg: string; + warnBg: string; +} + +export default function App() { + const [activeTab, setActiveTab] = useState<'console' | 'network'>('console'); + const { networkLogs, consoleLogs, connectedSessions } = useDevToolsData(); + const [selectedSessionId, setSelectedSessionId] = useState( + null, + ); + const [importedLogs, setImportedLogs] = useState<{ + network: NetworkLog[]; + console: ConsoleLog[]; + } | null>(null); + const [importedSessionId, setImportedSessionId] = useState( + null, + ); + + // --- Theme Logic --- + const [themeMode, setThemeMode] = useState(() => { + const saved = localStorage.getItem('devtools-theme'); + if (!saved) return null; // Default: follow system + return saved as ThemeMode; + }); + + const [systemIsDark, setSystemIsDark] = useState( + window.matchMedia('(prefers-color-scheme: dark)').matches, + ); + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const listener = (e: MediaQueryListEvent) => setSystemIsDark(e.matches); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, []); + + const isDark = themeMode === null ? systemIsDark : themeMode === 'dark'; + + const t = useMemo( + () => ({ + bg: isDark ? '#202124' : '#ffffff', + bgSecondary: isDark ? '#292a2d' : '#f3f3f3', + bgHover: isDark ? '#35363a' : '#e8f0fe', + border: isDark ? '#3c4043' : '#ccc', + text: isDark ? '#e8eaed' : '#333', + textSecondary: isDark ? '#9aa0a6' : '#666', + accent: isDark ? '#8ab4f8' : '#1a73e8', + consoleBg: isDark ? '#1e1e1e' : '#fff', + rowBorder: isDark ? '#303134' : '#f0f0f0', + errorBg: isDark ? '#3c1e1e' : '#fff0f0', + warnBg: isDark ? '#302a10' : '#fff3cd', + }), + [isDark], + ); + + const toggleTheme = () => { + const nextMode = isDark ? 'light' : 'dark'; + setThemeMode(nextMode); + localStorage.setItem('devtools-theme', nextMode); + }; + + // --- Import Logic --- + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result as string; + try { + const networkMap = new Map(); + const consoleLogs: ConsoleLog[] = []; + + content + .split('\n') + .filter((l) => l.trim()) + .forEach((l) => { + const parsed = JSON.parse(l); + const payload = parsed.payload || {}; + const type = parsed.type; + const timestamp = parsed.timestamp; + + if (type === 'console') { + consoleLogs.push({ + ...payload, + type, + timestamp, + id: payload.id || Math.random().toString(36).substring(2, 11), + }); + } else if (type === 'network') { + const id = payload.id; + if (!id) return; + + if (!networkMap.has(id)) { + networkMap.set(id, { + ...payload, + type, + timestamp, + id, + } as NetworkLog); + } else { + // It's likely a response update + const existing = networkMap.get(id)!; + networkMap.set(id, { + ...existing, + ...payload, + // Ensure we don't overwrite the original timestamp or type + type: existing.type, + timestamp: existing.timestamp, + } as NetworkLog); + } + } + }); + + const importId = `[Imported] ${file.name}`; + const networkLogs = Array.from(networkMap.values()).sort( + (a, b) => a.timestamp - b.timestamp, + ); + + setImportedLogs({ network: networkLogs, console: consoleLogs }); + setImportedSessionId(importId); + setSelectedSessionId(importId); + } catch (err) { + console.error('Import error:', err); + alert('Failed to parse session file. Ensure it is a valid JSONL file.'); + } + }; + reader.readAsText(file); + e.target.value = ''; + }; + + // --- Export Logic --- + const handleExport = () => { + if (!selectedSessionId) return; + + // Collect entries with timestamps, then sort before serializing + const entries: Array<{ timestamp: number; data: object }> = []; + + // Export console logs + filteredConsoleLogs.forEach((log) => { + entries.push({ + timestamp: log.timestamp, + data: { + type: 'console', + payload: { type: log.type, content: log.content }, + sessionId: log.sessionId, + timestamp: log.timestamp, + }, + }); + }); + + // Export network logs + filteredNetworkLogs.forEach((log) => { + entries.push({ + timestamp: log.timestamp, + data: { + type: 'network', + payload: log, + sessionId: log.sessionId, + timestamp: log.timestamp, + }, + }); + }); + + // Sort by timestamp, then serialize + entries.sort((a, b) => a.timestamp - b.timestamp); + + const content = entries.map((e) => JSON.stringify(e.data)).join('\n'); + const blob = new Blob([content], { type: 'application/jsonl' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `session-${selectedSessionId}.jsonl`; + a.click(); + + URL.revokeObjectURL(url); + }; + + // --- Session Discovery --- + const sessions = useMemo(() => { + const sessionMap = new Map(); + const updateMap = (l: { sessionId?: string; timestamp: number }) => { + if (!l.sessionId) return; + const currentMax = sessionMap.get(l.sessionId) || 0; + if (l.timestamp > currentMax) sessionMap.set(l.sessionId, l.timestamp); + }; + networkLogs.forEach(updateMap); + consoleLogs.forEach(updateMap); + + const discovered = Array.from(sessionMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map((entry) => entry[0]); + + if (importedSessionId) { + return [importedSessionId, ...discovered]; + } + return discovered; + }, [networkLogs, consoleLogs, importedSessionId]); + + useEffect(() => { + if (sessions.length > 0 && selectedSessionId === null) { + setSelectedSessionId(sessions[0]); + } + }, [sessions, selectedSessionId]); + + const filteredConsoleLogs = useMemo(() => { + if (!selectedSessionId) return []; + if (selectedSessionId === importedSessionId && importedLogs) { + return importedLogs.console; + } + return consoleLogs.filter((l) => l.sessionId === selectedSessionId); + }, [consoleLogs, selectedSessionId, importedSessionId, importedLogs]); + + const filteredNetworkLogs = useMemo(() => { + if (!selectedSessionId) return []; + if (selectedSessionId === importedSessionId && importedLogs) { + return importedLogs.network; + } + return networkLogs.filter((l) => l.sessionId === selectedSessionId); + }, [networkLogs, selectedSessionId, importedSessionId, importedLogs]); + + return ( +
+ + + {/* Toolbar */} +
+
+ setActiveTab('console')} + label="Console" + t={t} + /> + setActiveTab('network')} + label="Network" + t={t} + /> +
+ +
+ {selectedSessionId && + connectedSessions.includes(selectedSessionId) && ( + + )} + + + +
+ + Session: + + {sessions.length > 0 ? ( + + ) : ( + + No Sessions + + )} + {selectedSessionId && + !selectedSessionId.startsWith('[Imported]') && ( + + + + {connectedSessions.includes(selectedSessionId) + ? 'Connected' + : 'Disconnected'} + + + )} +
+ + +
+
+ + {/* Content */} +
+ {selectedSessionId ? ( + <> +
+ +
+
+ +
+ + ) : ( +
+ Please start Gemini CLI to begin debugging +
+ )} +
+
+ ); +} + +function TabButton({ + active, + onClick, + label, + t, +}: { + active: boolean; + onClick: () => void; + label: string; + t: ThemeColors; +}) { + return ( +
+ {label} +
+ ); +} + +// --- Console Components --- + +function ConsoleLogEntry({ log, t }: { log: ConsoleLog; t: ThemeColors }) { + const [isExpanded, setIsExpanded] = useState(false); + const content = log.content || ''; + const lines = content.split('\n'); + const CHAR_LIMIT = 500; + const LINE_LIMIT = 5; + + const isTooLong = content.length > CHAR_LIMIT; + const isTooManyLines = lines.length > LINE_LIMIT; + const needsCollapse = isTooLong || isTooManyLines; + + const isError = log.type === 'error'; + const isWarn = log.type === 'warn'; + const bg = isError ? t.errorBg : isWarn ? t.warnBg : 'transparent'; + const color = isError ? '#f28b82' : isWarn ? '#fdd663' : t.text; + const icon = isError ? '❌' : isWarn ? '⚠️' : ' '; + + let displayContent = content; + if (needsCollapse && !isExpanded) { + if (isTooManyLines) { + displayContent = lines.slice(0, LINE_LIMIT).join('\n') + '\n...'; + } else { + displayContent = content.substring(0, CHAR_LIMIT) + '...'; + } + } + + return ( +
+
+ {icon} +
+ +
+
+ {displayContent} +
+
+ +
+ {needsCollapse && ( +
setIsExpanded(!isExpanded)} + style={{ + fontSize: '12px', + + color: t.text, + + cursor: 'pointer', + + fontWeight: 'bold', + + userSelect: 'none', + + width: '20px', + + height: '20px', + + display: 'flex', + + alignItems: 'center', + + justifyContent: 'center', + + borderRadius: '4px', + + border: `1px solid ${t.border}`, + + background: t.bgSecondary, + + transition: 'all 0.1s', + }} + onMouseOver={(e) => { + (e.currentTarget as HTMLDivElement).style.background = t.bgHover; + }} + onMouseOut={(e) => { + (e.currentTarget as HTMLDivElement).style.background = + t.bgSecondary; + }} + title={isExpanded ? 'Collapse' : 'Expand'} + > + {isExpanded ? '−' : '+'} +
+ )} + +
+ {new Date(log.timestamp).toLocaleTimeString([], { + hour12: false, + + hour: '2-digit', + + minute: '2-digit', + + second: '2-digit', + })} +
+
+
+ ); +} + +function ConsoleView({ logs, t }: { logs: ConsoleLog[]; t: ThemeColors }) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs.length]); + + if (logs.length === 0) { + return ( +
+ No console logs in this session +
+ ); + } + + return ( +
+ {logs.map((log) => ( + + ))} + +
+
+ ); +} + +// --- Network Components --- + +function NetworkView({ + logs, + t, + isDark, +}: { + logs: NetworkLog[]; + t: ThemeColors; + isDark: boolean; +}) { + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState(''); + const [groupByDomain, setGroupByDomain] = useState(true); + const [expandedGroups, setExpandedGroups] = useState>( + {}, + ); + const [sidebarWidth, setSidebarWidth] = useState(400); + const isResizing = useRef(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing.current) return; + const newWidth = Math.max( + 200, + Math.min(e.clientX, window.innerWidth - 200), + ); + setSidebarWidth(newWidth); + }; + const handleMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + const startResizing = () => { + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; + + const filteredLogs = useMemo(() => { + let result = logs; + if (filter) { + const lower = filter.toLowerCase(); + result = logs.filter((l) => l.url.toLowerCase().includes(lower)); + } + return result; + }, [logs, filter]); + + const groupedLogs = useMemo(() => { + if (!groupByDomain) return null; + const groups: Record = {}; + filteredLogs.forEach((log) => { + let groupKey = 'Other'; + try { + const url = new URL(log.url); + const lastSlashIndex = url.pathname.lastIndexOf('/'); + const basePath = + lastSlashIndex !== -1 + ? url.pathname.substring(0, lastSlashIndex + 1) + : '/'; + groupKey = url.hostname + basePath; + } catch { + /* ignore */ + } + if (!groups[groupKey]) groups[groupKey] = []; + groups[groupKey].push(log); + }); + return groups; + }, [filteredLogs, groupByDomain]); + + useEffect(() => { + if (groupedLogs) { + setExpandedGroups((prev) => { + const next = { ...prev }; + Object.keys(groupedLogs).forEach((key) => { + if (next[key] === undefined) { + // Collapse play.googleapis.com by default + next[key] = !key.includes('play.googleapis.com'); + } + }); + return next; + }); + } + }, [groupedLogs]); + + const toggleGroup = (key: string) => { + setExpandedGroups((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + // --- Context Menu --- (reserved for future actions) + + const selectedLog = logs.find((l) => l.id === selectedId); + + const renderLogItem = (log: NetworkLog, nameOverride?: string) => { + const isPending = log.pending; + const status = log.response + ? log.response.status + : log.error + ? 'ERR' + : '...'; + const isError = log.error || (log.response && log.response.status >= 400); + + let name = nameOverride || log.url; + if (!nameOverride) { + try { + const urlObj = new URL(log.url); + name = urlObj.pathname + urlObj.search; + } catch { + /* ignore */ + } + } + + const isSelected = log.id === selectedId; + + return ( +
setSelectedId(log.id)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + borderBottom: `1px solid ${t.rowBorder}`, + display: 'flex', + flexDirection: 'column', + fontSize: '12px', + backgroundColor: isSelected ? t.bgHover : 'transparent', + color: isError ? '#f28b82' : t.text, + paddingLeft: nameOverride ? '24px' : '12px', + }} + > +
+ + {log.method} + + + {name} + + + {isPending ? '⏳' : status} + +
+
+ ); + }; + + if (logs.length === 0) { + return ( +
+ No network activity in this session +
+ ); + } + + return ( +
+ {/* List */} +
+
+ setFilter(e.target.value)} + style={{ + flex: 1, + boxSizing: 'border-box', + padding: '4px 10px', + background: t.bg, + color: t.text, + border: `1px solid ${t.border}`, + borderRadius: '4px', + fontSize: '12px', + }} + /> + +
+
+ {groupByDomain && groupedLogs + ? Object.keys(groupedLogs).map((groupKey) => ( +
+
toggleGroup(groupKey)} + style={{ + padding: '6px 12px', + background: t.bgSecondary, + fontWeight: 'bold', + fontSize: '11px', + borderBottom: `1px solid ${t.rowBorder}`, + wordBreak: 'break-all', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + userSelect: 'none', + }} + > + + {expandedGroups[groupKey] ? '▼' : '▶'} + + {groupKey} + + {groupedLogs[groupKey].length} + +
+ {expandedGroups[groupKey] && + groupedLogs[groupKey].map((log) => { + let displayName = log.url; + try { + const url = new URL(log.url); + const lastSlashIndex = url.pathname.lastIndexOf('/'); + const suffix = url.pathname.substring( + lastSlashIndex + 1, + ); + displayName = (suffix || '/') + url.search; + } catch { + /* ignore */ + } + return renderLogItem(log, displayName); + })} +
+ )) + : filteredLogs.map((log) => renderLogItem(log))} +
+
+ + {/* Resizer */} +
+ + {/* Detail */} +
+ {selectedLog ? ( + + ) : ( +
+ Select a request to view details +
+ )} +
+
+ ); +} + +type Tab = 'headers' | 'payload' | 'response'; + +function NetworkDetail({ log, t }: { log: NetworkLog; t: ThemeColors }) { + const [activeTab, setActiveTab] = useState('headers'); + const status = log.response + ? log.pending + ? '⏳' + : log.response.status + : log.error + ? 'Error' + : '⏳'; + + return ( +
+
+
+ {log.url} +
+
+ + {log.method} + + + + {status} + + + {new Date(log.timestamp).toLocaleTimeString()} + {log.response && ( + <> + + + {log.response.durationMs}ms + + + )} +
+
+
+ {(['headers', 'payload', 'response'] as const).map((tab) => ( +
setActiveTab(tab)} + style={{ + padding: '8px 16px', + cursor: 'pointer', + fontWeight: 600, + fontSize: '12px', + textTransform: 'capitalize', + borderBottom: + activeTab === tab + ? `2px solid ${t.accent}` + : '2px solid transparent', + color: activeTab === tab ? t.accent : t.textSecondary, + transition: 'all 0.2s', + }} + > + {tab} +
+ ))} +
+
+ {activeTab === 'headers' && ( +
+
+ + + + {log.error && ( + + )} +
+
+ {log.response ? ( + + ) : ( + + (no response yet) + + )} +
+
+ +
+
+ )} + {activeTab === 'payload' && } + {activeTab === 'response' && ( + + )} +
+
+ ); +} + +function Section({ + title, + children, + t, +}: { + title: string; + children: React.ReactNode; + t: ThemeColors; +}) { + const [collapsed, setCollapsed] = useState(false); + return ( +
+
setCollapsed(!collapsed)} + style={{ + padding: '8px 12px', + background: t.bgSecondary, + fontWeight: 'bold', + fontSize: '11px', + cursor: 'pointer', + userSelect: 'none', + display: 'flex', + alignItems: 'center', + gap: '8px', + }} + > + + {collapsed ? '▶' : '▼'} + + {title} +
+ {!collapsed && ( +
{children}
+ )} +
+ ); +} + +function Pair({ + k, + v, + color, + t, +}: { + k: string; + v: string; + color?: string; + t: ThemeColors; +}) { + return ( +
+
+ {k}: +
+
+ {v} +
+
+ ); +} + +function HeadersMap({ + headers, + t, +}: { + headers: Record | undefined; + t: ThemeColors; +}) { + if (!headers) return
(none)
; + return ( + <> + {Object.entries(headers).map(([k, v]) => ( + + ))} + + ); +} + +function BodyView({ + content, + chunks, + t, +}: { + content?: string; + chunks?: Array<{ index: number; data: string; timestamp: number }>; + t: ThemeColors; +}) { + const [mode, setMode] = useState<'json' | 'raw'>('json'); + const hasChunks = chunks && chunks.length > 0; + const safeContent = hasChunks + ? chunks.map((c) => c.data).join('') + : content || ''; + const getFormattedJson = () => { + try { + return JSON.stringify(JSON.parse(safeContent), null, 2); + } catch { + return safeContent; + } + }; + + const copyJson = () => { + navigator.clipboard.writeText(getFormattedJson()).catch(() => { + // Clipboard API unavailable — silently ignore + }); + }; + + const downloadJson = () => { + const blob = new Blob([getFormattedJson()], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'body.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + if (!safeContent && !hasChunks) + return ( +
+ (No content) +
+ ); + + const iconBtn = { + background: 'none', + border: 'none', + cursor: 'pointer', + color: t.textSecondary, + padding: '2px', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + } as const; + + return ( +
+
+ {(['json', 'raw'] as const).map((m) => ( + + ))} +
+ + +
+
+ {hasChunks && mode === 'raw' ? ( +
+ {chunks.map((chunk, i) => ( +
+
+ [ + {new Date(chunk.timestamp).toLocaleTimeString('en-US', { + hour12: false, + })} + .{String(chunk.timestamp % 1000).padStart(3, '0')}] +
+
+                  {chunk.data}
+                
+
+ ))} +
+ ) : mode === 'raw' ? ( +
+            {safeContent}
+          
+ ) : ( + + )} +
+
+ ); +} + +function JsonViewer({ content, t }: { content: string; t: ThemeColors }) { + const safeContent = content || ''; + if (safeContent.includes('data:')) { + const chunks = safeContent + .split(/\n\s*\n/) + .map((eventBlock, i) => ({ + index: i + 1, + jsonStr: eventBlock + .split('\n') + .filter((line) => line.trim().startsWith('data:')) + .map((line) => line.trim().substring(5).trim()) + .join(''), + })) + .filter((c) => c.jsonStr); + if (chunks.length > 0) { + return ( +
+ {chunks.map((chunk) => ( +
+
+ CHUNK {chunk.index} +
+ +
+ ))} +
+ ); + } + } + return ; +} + +function tryParse(str: string) { + try { + return JSON.parse(str); + } catch { + return str; + } +} + +interface JsonLine { + text: string; + foldStart: boolean; + foldEnd: number; // -1 if not a fold start + closingBracket: string; // '}' or ']' for fold starts +} + +function jsonToLines(data: unknown): JsonLine[] { + const str = + typeof data === 'string' && !data.startsWith('{') && !data.startsWith('[') + ? data + : JSON.stringify(data, null, 2); + if (str == null) + return [ + { text: 'undefined', foldStart: false, foldEnd: -1, closingBracket: '' }, + ]; + const raw = str.split('\n'); + const lines: JsonLine[] = raw.map((text) => ({ + text, + foldStart: false, + foldEnd: -1, + closingBracket: '', + })); + // Match opening brackets to closing brackets using a stack + const stack: Array<{ index: number; bracket: string }> = []; + for (let i = 0; i < raw.length; i++) { + const trimmed = raw[i].trimEnd(); + const last = trimmed[trimmed.length - 1]; + if (last === '{' || last === '[') { + stack.push({ index: i, bracket: last === '{' ? '}' : ']' }); + } + // Check for closing bracket (with or without trailing comma) + const stripped = trimmed.replace(/,\s*$/, ''); + const closeChar = stripped[stripped.length - 1]; + if ( + (closeChar === '}' || closeChar === ']') && + stack.length > 0 && + stack[stack.length - 1].bracket === closeChar + ) { + const open = stack.pop()!; + lines[open.index].foldStart = true; + lines[open.index].foldEnd = i; + lines[open.index].closingBracket = open.bracket; + } + } + return lines; +} + +// Tokenize a JSON line for syntax highlighting +const TOKEN_RE = + /("(?:[^"\\]|\\.)*")\s*(?=:)|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}[\]:,])/g; + +function highlightLine(text: string, t: ThemeColors): React.ReactNode { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + TOKEN_RE.lastIndex = 0; + + while ((match = TOKEN_RE.exec(text)) !== null) { + // Push any unmatched text before this token (whitespace/other) + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const [full, key, str, num, bool, nul] = match; + if (key) { + parts.push( + + {full} + , + ); + } else if (str) { + // Unescape JSON string escapes so \n renders as actual newlines. + // Use JSON.parse to handle all escape sequences correctly in one pass + // (manual chained .replace() can double-unescape e.g. \\n → \). + let unescaped: string; + try { + unescaped = JSON.parse(full) as string; + } catch { + unescaped = full.slice(1, -1); + } + const strLines = unescaped.split('\n'); + if (strLines.length <= 1) { + parts.push( + + {full} + , + ); + } else { + const indent = ' '.repeat(match.index + 1); + parts.push( + , + ); + } + } else if (num) { + parts.push( + + {full} + , + ); + } else if (bool) { + parts.push( + + {full} + , + ); + } else if (nul) { + parts.push( + + {full} + , + ); + } else { + // punctuation + parts.push({full}); + } + lastIndex = match.index + full.length; + } + // Remaining text + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; +} + +const STRING_LINE_THRESHOLD = 20; + +function CollapsibleString({ + lines, + indent, + t, +}: { + lines: string[]; + indent: string; + t: ThemeColors; +}) { + const [expanded, setExpanded] = useState(false); + const needsTruncation = lines.length > STRING_LINE_THRESHOLD; + const displayLines = + needsTruncation && !expanded + ? lines.slice(0, STRING_LINE_THRESHOLD) + : lines; + + return ( + + "{displayLines[0]} + {displayLines.slice(1).map((sl, si) => ( + + {'\n'} + {indent} + {sl} + + ))} + {needsTruncation && ( + <> + {'\n'} + {indent} + setExpanded(!expanded)} + style={{ + color: t.accent, + cursor: 'pointer', + fontStyle: 'italic', + userSelect: 'none', + }} + > + {expanded + ? '▲ collapse' + : `... ${lines.length - STRING_LINE_THRESHOLD} more lines`} + + + )} + " + + ); +} + +function CodeView({ data, t }: { data: unknown; t: ThemeColors }) { + const lines = useMemo(() => jsonToLines(data), [data]); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const contentRef = useRef(null); + + const toggleFold = (lineIndex: number) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(lineIndex)) { + next.delete(lineIndex); + } else { + next.add(lineIndex); + } + return next; + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + e.preventDefault(); + const sel = window.getSelection(); + if (sel && contentRef.current) { + sel.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(contentRef.current); + sel.addRange(range); + } + } + }; + + // Build visible lines, skipping folded regions + const visibleLines: Array<{ + index: number; + content: React.ReactNode; + foldable: boolean; + isCollapsed: boolean; + }> = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const isCollapsed = collapsed.has(i); + if (line.foldStart && isCollapsed) { + // Show the opening line with collapsed indicator + const indent = line.text.match(/^(\s*)/)?.[1] || ''; + const trimmed = line.text.trimStart(); + visibleLines.push({ + index: i, + content: ( + <> + {indent.length > 0 && {indent}} + {highlightLine(trimmed, t)} + + {' '} + ... {line.closingBracket} + + + ), + foldable: true, + isCollapsed: true, + }); + // Skip to the line after foldEnd + i = line.foldEnd + 1; + } else { + visibleLines.push({ + index: i, + content: highlightLine(line.text, t), + foldable: line.foldStart, + isCollapsed: false, + }); + i++; + } + } + + return ( +
+ {visibleLines.map((vl) => ( + + {/* Gutter cell */} +
toggleFold(vl.index) : undefined} + > + {vl.foldable ? ( + {vl.isCollapsed ? '▶' : '▼'} + ) : ( + '' + )} +
+ {/* Content cell */} +
+ {vl.content} +
+
+ ))} +
+ ); +} diff --git a/packages/devtools/client/src/hooks.ts b/packages/devtools/client/src/hooks.ts new file mode 100644 index 0000000000..09c94bff62 --- /dev/null +++ b/packages/devtools/client/src/hooks.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import type { + NetworkLog, + InspectorConsoleLog as ConsoleLog, +} from '../../src/types.js'; + +export type { NetworkLog }; +export type { InspectorConsoleLog as ConsoleLog } from '../../src/types.js'; + +export function useDevToolsData() { + const [networkLogs, setNetworkLogs] = useState([]); + const [consoleLogs, setConsoleLogs] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [connectedSessions, setConnectedSessions] = useState([]); + + useEffect(() => { + const evtSource = new EventSource('/events'); + + evtSource.onopen = () => setIsConnected(true); + evtSource.onerror = () => setIsConnected(false); + + evtSource.addEventListener('snapshot', (e) => { + try { + const data = JSON.parse(e.data); + // Merge with existing data to preserve logs across server restarts + setNetworkLogs((prev) => { + if (data.networkLogs.length === 0) return prev; + const merged = new Map(prev.map((l: NetworkLog) => [l.id, l])); + for (const log of data.networkLogs) merged.set(log.id, log); + return Array.from(merged.values()); + }); + setConsoleLogs((prev) => { + if (data.consoleLogs.length === 0) return prev; + const existingIds = new Set(prev.map((l: ConsoleLog) => l.id)); + const newLogs = data.consoleLogs.filter( + (l: ConsoleLog) => !existingIds.has(l.id), + ); + const merged = [...prev, ...newLogs]; + return merged.length > 5000 ? merged.slice(-5000) : merged; + }); + setConnectedSessions(data.sessions); + } catch { + // Malformed snapshot — ignore + } + }); + + evtSource.addEventListener('network', (e) => { + try { + const log = JSON.parse(e.data) as NetworkLog; + setNetworkLogs((prev) => { + const idx = prev.findIndex((l) => l.id === log.id); + if (idx > -1) { + const next = [...prev]; + next[idx] = log; + return next; + } + return [...prev, log]; + }); + } catch { + // Malformed network event — ignore + } + }); + + evtSource.addEventListener('console', (e) => { + try { + const log = JSON.parse(e.data) as ConsoleLog; + setConsoleLogs((prev) => { + const next = [...prev, log]; + return next.length > 5000 ? next.slice(-5000) : next; + }); + } catch { + // Malformed console event — ignore + } + }); + + evtSource.addEventListener('session', (e) => { + try { + setConnectedSessions(JSON.parse(e.data)); + } catch { + // Malformed session event — ignore + } + }); + + return () => evtSource.close(); + }, []); + + return { networkLogs, consoleLogs, isConnected, connectedSessions }; +} diff --git a/packages/devtools/client/src/main.tsx b/packages/devtools/client/src/main.tsx new file mode 100644 index 0000000000..a0698aa77d --- /dev/null +++ b/packages/devtools/client/src/main.tsx @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/devtools/esbuild.client.js b/packages/devtools/esbuild.client.js new file mode 100644 index 0000000000..2ff1a6f2d4 --- /dev/null +++ b/packages/devtools/esbuild.client.js @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import esbuild from 'esbuild'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + +mkdirSync('dist/client', { recursive: true }); + +await esbuild.build({ + entryPoints: ['client/src/main.tsx'], + bundle: true, + minify: true, + format: 'esm', + target: 'es2020', + jsx: 'automatic', + outfile: 'dist/client/main.js', +}); + +// Embed client assets as string constants so the devtools server can be +// bundled into the CLI without needing readFileSync + __dirname at runtime. +const indexHtml = readFileSync('client/index.html', 'utf-8'); +const clientJs = readFileSync('dist/client/main.js', 'utf-8'); + +writeFileSync( + 'src/_client-assets.ts', + `/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Auto-generated by esbuild.client.js — do not edit\nexport const INDEX_HTML = ${JSON.stringify(indexHtml)};\nexport const CLIENT_JS = ${JSON.stringify(clientJs)};\n`, +); diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000000..03b874ffad --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,32 @@ +{ + "name": "@google/gemini-cli-devtools", + "version": "0.30.0-nightly.20260210.a2174751d", + "license": "Apache-2.0", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build": "npm run build:client && tsc -p tsconfig.build.json", + "build:client": "node esbuild.client.js" + }, + "files": [ + "dist", + "client/index.html" + ], + "engines": { + "node": ">=20" + }, + "devDependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "dependencies": { + "ws": "^8.16.0" + } +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts new file mode 100644 index 0000000000..81cb909957 --- /dev/null +++ b/packages/devtools/src/index.ts @@ -0,0 +1,359 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { WebSocketServer, type WebSocket } from 'ws'; +import type { + NetworkLog, + ConsoleLogPayload, + InspectorConsoleLog, +} from './types.js'; +import { INDEX_HTML, CLIENT_JS } from './_client-assets.js'; + +export type { + NetworkLog, + ConsoleLogPayload, + InspectorConsoleLog, +} from './types.js'; + +interface IncomingNetworkPayload extends Partial { + chunk?: { + index: number; + data: string; + timestamp: number; + }; +} + +export interface SessionInfo { + sessionId: string; + ws: WebSocket; + lastPing: number; +} + +/** + * DevTools Viewer + * + * Receives logs via WebSocket from CLI sessions. + */ +export class DevTools extends EventEmitter { + private static instance: DevTools | undefined; + private logs: NetworkLog[] = []; + private consoleLogs: InspectorConsoleLog[] = []; + private server: http.Server | null = null; + private wss: WebSocketServer | null = null; + private sessions = new Map(); + private heartbeatTimer: NodeJS.Timeout | null = null; + private port = 25417; + private static readonly DEFAULT_PORT = 25417; + private static readonly MAX_PORT_RETRIES = 10; + + private constructor() { + super(); + // Each SSE client adds 3 listeners; raise the limit to avoid warnings + this.setMaxListeners(50); + } + + static getInstance(): DevTools { + if (!DevTools.instance) { + DevTools.instance = new DevTools(); + } + return DevTools.instance; + } + + addInternalConsoleLog( + payload: ConsoleLogPayload, + sessionId?: string, + timestamp?: number, + ) { + const entry: InspectorConsoleLog = { + ...payload, + id: randomUUID(), + sessionId, + timestamp: timestamp || Date.now(), + }; + this.consoleLogs.push(entry); + if (this.consoleLogs.length > 5000) this.consoleLogs.shift(); + this.emit('console-update', entry); + } + + addInternalNetworkLog( + payload: IncomingNetworkPayload, + sessionId?: string, + timestamp?: number, + ) { + if (!payload.id) return; + const existingIndex = this.logs.findIndex((l) => l.id === payload.id); + if (existingIndex > -1) { + const existing = this.logs[existingIndex]; + + // Handle chunk accumulation + if (payload.chunk) { + const chunks = existing.chunks || []; + chunks.push(payload.chunk); + this.logs[existingIndex] = { + ...existing, + chunks, + sessionId: sessionId || existing.sessionId, + }; + } else { + this.logs[existingIndex] = { + ...existing, + ...payload, + sessionId: sessionId || existing.sessionId, + // Drop chunks once we have the full response body — the data + // is redundant and keeping both can blow past V8's string limit + // when serializing the snapshot. + chunks: payload.response?.body ? undefined : existing.chunks, + response: payload.response + ? { ...existing.response, ...payload.response } + : existing.response, + } as NetworkLog; + } + this.emit('update', this.logs[existingIndex]); + } else if (payload.url) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const entry = { + ...payload, + sessionId, + timestamp: timestamp || Date.now(), + chunks: payload.chunk ? [payload.chunk] : undefined, + } as NetworkLog; + this.logs.push(entry); + if (this.logs.length > 2000) this.logs.shift(); + this.emit('update', entry); + } + } + + getUrl(): string { + return `http://127.0.0.1:${this.port}`; + } + + getPort(): number { + return this.port; + } + + stop(): Promise { + return new Promise((resolve) => { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + if (this.wss) { + this.wss.close(); + this.wss = null; + } + if (this.server) { + this.server.close(() => resolve()); + this.server = null; + } else { + resolve(); + } + // Reset singleton so a fresh start() is possible + DevTools.instance = undefined; + }); + } + + start(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + resolve(this.getUrl()); + return; + } + this.server = http.createServer((req, res) => { + // Only allow same-origin requests — the client is served from this + // server so cross-origin access is unnecessary and would let arbitrary + // websites exfiltrate logs (which may contain API keys/headers). + const origin = req.headers.origin; + if (origin) { + const allowed = `http://127.0.0.1:${this.port}`; + if (origin === allowed) { + res.setHeader('Access-Control-Allow-Origin', allowed); + } + } + + // API routes + if (req.url === '/events') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + // Send full snapshot on connect + const snapshot = JSON.stringify({ + networkLogs: this.logs, + consoleLogs: this.consoleLogs, + sessions: Array.from(this.sessions.keys()), + }); + res.write(`event: snapshot\ndata: ${snapshot}\n\n`); + + // Incremental updates + const onNetwork = (log: NetworkLog) => { + res.write(`event: network\ndata: ${JSON.stringify(log)}\n\n`); + }; + const onConsole = (log: InspectorConsoleLog) => { + res.write(`event: console\ndata: ${JSON.stringify(log)}\n\n`); + }; + const onSession = () => { + const sessions = Array.from(this.sessions.keys()); + res.write(`event: session\ndata: ${JSON.stringify(sessions)}\n\n`); + }; + this.on('update', onNetwork); + this.on('console-update', onConsole); + this.on('session-update', onSession); + req.on('close', () => { + this.off('update', onNetwork); + this.off('console-update', onConsole); + this.off('session-update', onSession); + }); + } else if (req.url === '/' || req.url === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(INDEX_HTML); + } else if (req.url === '/assets/main.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(CLIENT_JS); + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + this.server.on('error', (e: unknown) => { + if ( + typeof e === 'object' && + e !== null && + 'code' in e && + e.code === 'EADDRINUSE' + ) { + if (this.port - DevTools.DEFAULT_PORT >= DevTools.MAX_PORT_RETRIES) { + reject( + new Error( + `DevTools: all ports ${DevTools.DEFAULT_PORT}–${this.port} in use`, + ), + ); + return; + } + this.port++; + this.server?.listen(this.port, '127.0.0.1'); + } else { + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + this.server.listen(this.port, '127.0.0.1', () => { + this.setupWebSocketServer(); + resolve(this.getUrl()); + }); + }); + } + + private setupWebSocketServer() { + if (!this.server) return; + + this.wss = new WebSocketServer({ server: this.server, path: '/ws' }); + + this.wss.on('connection', (ws: WebSocket) => { + let sessionId: string | null = null; + + ws.on('message', (data: Buffer) => { + try { + const message = JSON.parse(data.toString()); + + // Handle registration first + if (message.type === 'register') { + sessionId = String(message.sessionId); + if (!sessionId) return; + + this.sessions.set(sessionId, { + sessionId, + ws, + lastPing: Date.now(), + }); + + // Notify session update + this.emit('session-update'); + + // Send registration acknowledgement + ws.send( + JSON.stringify({ + type: 'registered', + sessionId, + timestamp: Date.now(), + }), + ); + } else if (sessionId) { + this.handleWebSocketMessage(sessionId, message); + } + } catch { + // Invalid WebSocket message + } + }); + + ws.on('close', () => { + if (sessionId) { + this.sessions.delete(sessionId); + this.emit('session-update'); + } + }); + + ws.on('error', () => { + // WebSocket error — no action needed + }); + }); + + // Heartbeat mechanism + this.heartbeatTimer = setInterval(() => { + const now = Date.now(); + this.sessions.forEach((session, sessionId) => { + if (now - session.lastPing > 30000) { + session.ws.close(); + this.sessions.delete(sessionId); + } else { + // Send ping + session.ws.send(JSON.stringify({ type: 'ping', timestamp: now })); + } + }); + }, 10000); + this.heartbeatTimer.unref(); + } + + private handleWebSocketMessage( + sessionId: string, + message: Record, + ) { + const session = this.sessions.get(sessionId); + if (!session) return; + + switch (message['type']) { + case 'pong': + session.lastPing = Date.now(); + break; + + case 'console': + this.addInternalConsoleLog( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['payload'] as ConsoleLogPayload, + sessionId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['timestamp'] as number, + ); + break; + + case 'network': + this.addInternalNetworkLog( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['payload'] as IncomingNetworkPayload, + sessionId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['timestamp'] as number, + ); + break; + + default: + break; + } + } +} diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts new file mode 100644 index 0000000000..ffaf038d67 --- /dev/null +++ b/packages/devtools/src/types.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface NetworkLog { + id: string; + sessionId?: string; + timestamp: number; + method: string; + url: string; + headers: Record; + body?: string; + pending?: boolean; + chunks?: Array<{ + index: number; + data: string; + timestamp: number; + }>; + response?: { + status: number; + headers: Record; + body?: string; + durationMs: number; + }; + error?: string; +} + +export interface ConsoleLogPayload { + type: 'log' | 'warn' | 'error' | 'debug' | 'info'; + content: string; +} + +export interface InspectorConsoleLog extends ConsoleLogPayload { + id: string; + sessionId?: string; + timestamp: number; +} diff --git a/packages/devtools/tsconfig.build.json b/packages/devtools/tsconfig.build.json new file mode 100644 index 0000000000..f90fcb9cac --- /dev/null +++ b/packages/devtools/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist/src", + "declaration": true, + "sourceMap": true, + "noEmit": false, + "composite": false, + "incremental": false, + "verbatimModuleSyntax": false + }, + "include": ["src"] +} diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 0000000000..fe76dc95a8 --- /dev/null +++ b/packages/devtools/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "client/src"] +}