feat(daemon): add WebSocket relay with console/network monitoring

- Add ws-relay.ts: WebSocket server at /ws with broadcast function
- Add network event types (request/response/failed) to core ws.ts types
- Add ConsoleEntry and NetworkEntry types
- Add consoleLog and networkLog buffers to PageRegistry (100/200 max)
- Add console listener in createPage: broadcasts page:console events
- Add network listeners (request/response/requestfailed) in createPage
- Add page crash handler broadcasting page:crashed events
- Add history endpoints: GET /pages/:id/console and GET /pages/:id/network
- Integrate createWsRelay in server.ts startup
- Bump ws from devDependency to full dependency
This commit is contained in:
2026-08-12 21:28:02 +08:00
parent b843eea9ae
commit e79590a6d8
10 changed files with 220 additions and 13 deletions
+2 -1
View File
@@ -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"
+93 -4
View File
@@ -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<T>(buffer: T[], item: T, maxSize: number): void {
buffer.push(item);
if (buffer.length > maxSize) {
buffer.shift();
}
}
async closePage(idOrAlias: string): Promise<void> {
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);
}
+4 -2
View File
@@ -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<string, string>(); // 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);
}
+28
View File
@@ -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) {
+2
View File
@@ -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<http.Server> {
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);
});
});
+39
View File
@@ -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);
}
}
}