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:
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<string, string> } }
|
||||
| { type: 'page:network:response'; data: { id: string; url: string; status: number; headers: Record<string, string> } }
|
||||
| { type: 'page:network:failed'; data: { id: string; url: string; failure: string } }
|
||||
| { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user