| |
| |
| |
| |
| |
|
|
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| 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(); |
| |
| 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]; |
|
|
| |
| 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, |
| |
| |
| |
| 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) { |
| |
| 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(); |
| } |
| |
| DevTools.instance = undefined; |
| }); |
| } |
|
|
| start(): Promise<string> { |
| return new Promise((resolve, reject) => { |
| if (this.server) { |
| resolve(this.getUrl()); |
| return; |
| } |
| this.server = http.createServer((req, res) => { |
| |
| |
| |
| 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); |
| } |
| } |
|
|
| |
| 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', |
| }); |
|
|
| |
| const snapshot = JSON.stringify({ |
| networkLogs: this.logs, |
| consoleLogs: this.consoleLogs, |
| sessions: Array.from(this.sessions.keys()), |
| }); |
| res.write(`event: snapshot\ndata: ${snapshot}\n\n`); |
|
|
| |
| 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()); |
|
|
| |
| if (message.type === 'register') { |
| sessionId = String(message.sessionId); |
| if (!sessionId) return; |
|
|
| this.sessions.set(sessionId, { |
| sessionId, |
| ws, |
| lastPing: Date.now(), |
| }); |
|
|
| |
| this.emit('session-update'); |
|
|
| |
| ws.send( |
| JSON.stringify({ |
| type: 'registered', |
| sessionId, |
| timestamp: Date.now(), |
| }), |
| ); |
| } else if (sessionId) { |
| this.handleWebSocketMessage(sessionId, message); |
| } |
| } catch { |
| |
| } |
| }); |
|
|
| ws.on('close', () => { |
| if (sessionId) { |
| this.sessions.delete(sessionId); |
| this.emit('session-update'); |
| } |
| }); |
|
|
| ws.on('error', () => { |
| |
| }); |
| }); |
|
|
| |
| 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 { |
| |
| 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( |
| |
| message['payload'] as ConsoleLogPayload, |
| sessionId, |
| |
| message['timestamp'] as number, |
| ); |
| break; |
|
|
| case 'network': |
| this.addInternalNetworkLog( |
| |
| message['payload'] as IncomingNetworkPayload, |
| sessionId, |
| |
| message['timestamp'] as number, |
| ); |
| break; |
|
|
| default: |
| break; |
| } |
| } |
| } |
|
|