diff --git a/package-lock.json b/package-lock.json index 41570b9..7037180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0", - "vitest": "^3.0.0" + "vitest": "^3.2.7" } }, "node_modules/@esbuild/aix-ppc64": { @@ -2247,6 +2247,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/cli": { "name": "@visionl/cli", "version": "0.1.0", @@ -2272,7 +2293,8 @@ "@visionl/core": "*", "playwright": "^1.52.0", "playwright-extra": "^4.3.0", - "puppeteer-extra-plugin-stealth": "^2.11.0" + "puppeteer-extra-plugin-stealth": "^2.11.0", + "ws": "^8.21.3" }, "devDependencies": { "@types/ws": "^8.0.0" diff --git a/package.json b/package.json index 7820544..d44f098 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,9 @@ { "name": "visionl", "private": true, - "workspaces": ["packages/*"], + "workspaces": [ + "packages/*" + ], "scripts": { "typecheck": "tsc -b", "build": "tsc -b", @@ -9,8 +11,11 @@ "test:watch": "vitest" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.7.0", - "vitest": "^3.0.0", - "@types/node": "^22.0.0" + "vitest": "^3.2.7" + }, + "allowScripts": { + "esbuild@0.28.2": true } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b28674a..f341e6d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ export { type PageInfo } from './types/page.js'; export { type ApiResponse, ErrorCode } from './types/api.js'; -export { type WsEvent } from './types/ws.js'; +export { type WsEvent, type ConsoleEntry, type NetworkEntry } from './types/ws.js'; export { type FingerprintProfile, type FingerprintPermissions, diff --git a/packages/core/src/types/ws.ts b/packages/core/src/types/ws.ts index e96b442..ce9a775 100644 --- a/packages/core/src/types/ws.ts +++ b/packages/core/src/types/ws.ts @@ -1,10 +1,29 @@ // WebSocket event type definitions / WebSocket 事件类型定义 import type { PageInfo } from './page.js'; +export interface ConsoleEntry { + level: string; + text: string; + timestamp: number; +} + +export interface NetworkEntry { + type: 'request' | 'response' | 'failed'; + url: string; + method?: string; + status?: number; + headers?: Record; + failure?: string; + timestamp: number; +} + export type WsEvent = | { type: 'page:created'; data: PageInfo } | { type: 'page:closed'; data: { id: string } } | { type: 'page:navigated'; data: { id: string; url: string; title: string } } | { type: 'page:crashed'; data: { id: string; error: string } } | { type: 'page:console'; data: { id: string; level: string; text: string } } + | { type: 'page:network:request'; data: { id: string; url: string; method: string; headers: Record } } + | { type: 'page:network:response'; data: { id: string; url: string; status: number; headers: Record } } + | { type: 'page:network:failed'; data: { id: string; url: string; failure: string } } | { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } }; diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 5aa55a0..e0fa0a2 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -12,7 +12,8 @@ "@visionl/core": "*", "playwright": "^1.52.0", "playwright-extra": "^4.3.0", - "puppeteer-extra-plugin-stealth": "^2.11.0" + "puppeteer-extra-plugin-stealth": "^2.11.0", + "ws": "^8.21.3" }, "devDependencies": { "@types/ws": "^8.0.0" diff --git a/packages/daemon/src/browser-manager.ts b/packages/daemon/src/browser-manager.ts index b07983a..eb5f8a7 100644 --- a/packages/daemon/src/browser-manager.ts +++ b/packages/daemon/src/browser-manager.ts @@ -2,10 +2,14 @@ import { chromium } from 'playwright-extra'; import StealthPlugin from 'puppeteer-extra-plugin-stealth'; import type { Browser, BrowserContext, Page } from 'playwright'; -import type { FingerprintProfile, PageInfo } from '@visionl/core'; -import { PageRegistry } from './page-registry.js'; +import type { FingerprintProfile, PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core'; +import { PageRegistry, type RegisteredPage } from './page-registry.js'; import { getProfile } from './stealth/profiles/index.js'; import { applyStealth } from './stealth/index.js'; +import { broadcast } from './ws-relay.js'; + +const MAX_CONSOLE_BUFFER = 100; +const MAX_NETWORK_BUFFER = 200; // Apply stealth plugin once at module level / 模块级别一次性注入隐身插件 chromium.use(StealthPlugin()); @@ -80,19 +84,104 @@ export class BrowserManager { const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id }; this.registry.add(info, page, context); + this.setupPageListeners(page, id); + + // Broadcast page created event / 广播页面创建事件 + broadcast({ type: 'page:created', data: info }); + console.log(`[browser-manager] Page created: ${id} → ${url}`); + return info; } + private setupPageListeners(page: Page, id: string): void { + const entry = this.registry.get(id); + if (!entry) return; + + // Console monitoring / 控制台监控 + page.on('console', (msg) => { + const level = msg.type(); + const text = msg.text(); + const consoleEntry: ConsoleEntry = { level, text, timestamp: Date.now() }; + this.pushToBuffer(entry.consoleLog, consoleEntry, MAX_CONSOLE_BUFFER); + broadcast({ type: 'page:console', data: { id, level, text } }); + }); + + // Network request monitoring / 网络请求监控 + page.on('request', (req) => { + const netEntry: NetworkEntry = { + type: 'request', + url: req.url(), + method: req.method(), + headers: req.headers(), + timestamp: Date.now(), + }; + this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER); + broadcast({ + type: 'page:network:request', + data: { id, url: req.url(), method: req.method(), headers: req.headers() }, + }); + }); + + // Network response monitoring / 网络响应监控 + page.on('response', (res) => { + const netEntry: NetworkEntry = { + type: 'response', + url: res.url(), + status: res.status(), + headers: res.headers(), + timestamp: Date.now(), + }; + this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER); + broadcast({ + type: 'page:network:response', + data: { id, url: res.url(), status: res.status(), headers: res.headers() }, + }); + }); + + // Network failure monitoring / 网络失败监控 + page.on('requestfailed', (req) => { + const failureText = req.failure()?.errorText || 'Unknown error'; + const netEntry: NetworkEntry = { + type: 'failed', + url: req.url(), + failure: failureText, + timestamp: Date.now(), + }; + this.pushToBuffer(entry.networkLog, netEntry, MAX_NETWORK_BUFFER); + broadcast({ + type: 'page:network:failed', + data: { id, url: req.url(), failure: failureText }, + }); + }); + + // Page crash handling / 页面崩溃处理 + page.on('crash', () => { + console.error(`[browser-manager] Page crashed: ${id}`); + entry.info.status = 'crashed'; + broadcast({ type: 'page:crashed', data: { id, error: 'Page crashed' } }); + }); + } + + private pushToBuffer(buffer: T[], item: T, maxSize: number): void { + buffer.push(item); + if (buffer.length > maxSize) { + buffer.shift(); + } + } + async closePage(idOrAlias: string): Promise { const entry = this.registry.findByIdOrAlias(idOrAlias); if (!entry) { throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' }); } + const pageId = entry.info.id; await entry.context.close(); - this.registry.remove(entry.info.id); + this.registry.remove(pageId); + broadcast({ type: 'page:closed', data: { id: pageId } }); + console.log(`[browser-manager] Page closed: ${pageId}`); } - getPage(idOrAlias: string) { + getPage(idOrAlias: string): RegisteredPage | undefined { return this.registry.findByIdOrAlias(idOrAlias); } diff --git a/packages/daemon/src/page-registry.ts b/packages/daemon/src/page-registry.ts index 749027c..03db504 100644 --- a/packages/daemon/src/page-registry.ts +++ b/packages/daemon/src/page-registry.ts @@ -1,11 +1,13 @@ // PageRegistry — tracks active Playwright pages by ID and alias / 页面注册表,通过 ID 和别名追踪活跃页面 import type { Page, BrowserContext } from 'playwright'; -import type { PageInfo } from '@visionl/core'; +import type { PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core'; export interface RegisteredPage { info: PageInfo; page: Page; context: BrowserContext; + consoleLog: ConsoleEntry[]; + networkLog: NetworkEntry[]; } export class PageRegistry { @@ -13,7 +15,7 @@ export class PageRegistry { private aliasMap = new Map(); // alias → id add(info: PageInfo, page: Page, context: BrowserContext): void { - this.pages.set(info.id, { info, page, context }); + this.pages.set(info.id, { info, page, context, consoleLog: [], networkLog: [] }); if (info.alias) { this.aliasMap.set(info.alias, info.id); } diff --git a/packages/daemon/src/routes/pages.ts b/packages/daemon/src/routes/pages.ts index abd1210..b7c952b 100644 --- a/packages/daemon/src/routes/pages.ts +++ b/packages/daemon/src/routes/pages.ts @@ -37,6 +37,34 @@ export function pageRoutes(bm: BrowserManager): RouteHandler { return true; } + // GET /pages/:id/console — console history / 控制台历史 + if (req.method === 'GET' && segments[0] === 'pages' && segments[2] === 'console' && segments.length === 3) { + const id = segments[1]; + const entry = bm.getPage(id); + if (!entry) { + res.writeHead(404); + res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } })); + return true; + } + res.writeHead(200); + res.end(safeStringify({ ok: true, data: entry.consoleLog })); + return true; + } + + // GET /pages/:id/network — network history / 网络请求历史 + if (req.method === 'GET' && segments[0] === 'pages' && segments[2] === 'network' && segments.length === 3) { + const id = segments[1]; + const entry = bm.getPage(id); + if (!entry) { + res.writeHead(404); + res.end(safeStringify({ ok: false, error: { code: 'PAGE_NOT_FOUND', message: `Page "${id}" not found` } })); + return true; + } + res.writeHead(200); + res.end(safeStringify({ ok: true, data: entry.networkLog })); + return true; + } + // GET /pages/:id // DELETE /pages/:id if (segments[0] === 'pages' && segments.length === 2) { diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 2fe3a28..b5061b3 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -4,6 +4,7 @@ import { pageRoutes } from './routes/pages.js'; import { contentRoutes } from './routes/content.js'; import { actionRoutes } from './routes/actions.js'; import type { BrowserManager } from './browser-manager.js'; +import { createWsRelay } from './ws-relay.js'; export function startServer(port: number, browserManager?: BrowserManager): Promise { const routes = [healthRoute]; @@ -24,6 +25,7 @@ export function startServer(port: number, browserManager?: BrowserManager): Prom server.listen(port, '127.0.0.1', () => { console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`); + createWsRelay(server); resolve(server); }); }); diff --git a/packages/daemon/src/ws-relay.ts b/packages/daemon/src/ws-relay.ts new file mode 100644 index 0000000..279ec7a --- /dev/null +++ b/packages/daemon/src/ws-relay.ts @@ -0,0 +1,39 @@ +// WebSocket relay — broadcasts WsEvent JSON to all connected clients / WebSocket 中继,向所有连接的客户端广播事件 +import type http from 'node:http'; +import { WebSocketServer, WebSocket } from 'ws'; +import type { WsEvent } from '@visionl/core'; + +let wss: WebSocketServer | null = null; + +export function createWsRelay(server: http.Server): WebSocketServer { + wss = new WebSocketServer({ server, path: '/ws' }); + + wss.on('connection', (ws: WebSocket) => { + console.log('[ws-relay] Client connected, total:', wss!.clients.size); + + ws.on('close', () => { + console.log('[ws-relay] Client disconnected, total:', wss!.clients.size); + }); + + ws.on('error', (err: Error) => { + console.error('[ws-relay] Client error:', err.message); + }); + }); + + wss.on('error', (err: Error) => { + console.error('[ws-relay] Server error:', err.message); + }); + + console.log('[ws-relay] WebSocket relay attached at path /ws'); + return wss; +} + +export function broadcast(event: WsEvent): void { + if (!wss) return; + const payload = JSON.stringify(event); + for (const client of wss.clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(payload); + } + } +}