Download packages/devtools/src/index.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 12 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/devtools/src/index.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/devtools/src/index.ts
-
curl -L -o index.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/devtools/src/index.ts
12 kB
| /** | |
| * @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<NetworkLog> { | |
| 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<string, SessionInfo>(); | |
| 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 > 10) this.logs.shift(); | |
| this.emit('update', entry); | |
| } | |
| } | |
| getUrl(): string { | |
| return `http://127.0.0.1:${this.port}`; | |
| } | |
| getPort(): number { | |
| return this.port; | |
| } | |
| stop(): Promise<void> { | |
| 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<string> { | |
| 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 === '/api/trigger-debugger' && req.method === 'POST') { | |
| let body = ''; | |
| req.on('data', (chunk) => { | |
| body += chunk; | |
| }); | |
| req.on('end', () => { | |
| try { | |
| const parsed: unknown = JSON.parse(body); | |
| if ( | |
| typeof parsed !== 'object' || | |
| parsed === null || | |
| !('sessionId' in parsed) || | |
| typeof parsed.sessionId !== 'string' | |
| ) { | |
| res.writeHead(400, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ error: 'Invalid request' })); | |
| return; | |
| } | |
| const sessionId = parsed.sessionId; | |
| const session = this.sessions.get(sessionId); | |
| if (session) { | |
| session.ws.send(JSON.stringify({ type: 'trigger-debugger' })); | |
| res.writeHead(200, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ success: true })); | |
| } else { | |
| res.writeHead(404, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ error: 'Session not found' })); | |
| } | |
| } catch { | |
| res.writeHead(400, { 'Content-Type': 'application/json' }); | |
| res.end(JSON.stringify({ error: 'Invalid request' })); | |
| } | |
| }); | |
| } else 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 { | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment | |
| 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<string, unknown>, | |
| ) { | |
| 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; | |
| } | |
| } | |
| } | |