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:
Generated
+24
-2
@@ -11,7 +11,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.2.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -2247,6 +2247,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"packages/cli": {
|
||||||
"name": "@visionl/cli",
|
"name": "@visionl/cli",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -2272,7 +2293,8 @@
|
|||||||
"@visionl/core": "*",
|
"@visionl/core": "*",
|
||||||
"playwright": "^1.52.0",
|
"playwright": "^1.52.0",
|
||||||
"playwright-extra": "^4.3.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": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.0.0"
|
"@types/ws": "^8.0.0"
|
||||||
|
|||||||
+8
-3
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "visionl",
|
"name": "visionl",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"build": "tsc -b",
|
"build": "tsc -b",
|
||||||
@@ -9,8 +11,11 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.0.0",
|
"vitest": "^3.2.7"
|
||||||
"@types/node": "^22.0.0"
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.28.2": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { type PageInfo } from './types/page.js';
|
export { type PageInfo } from './types/page.js';
|
||||||
export { type ApiResponse, ErrorCode } from './types/api.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 {
|
export {
|
||||||
type FingerprintProfile,
|
type FingerprintProfile,
|
||||||
type FingerprintPermissions,
|
type FingerprintPermissions,
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
// WebSocket event type definitions / WebSocket 事件类型定义
|
// WebSocket event type definitions / WebSocket 事件类型定义
|
||||||
import type { PageInfo } from './page.js';
|
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 =
|
export type WsEvent =
|
||||||
| { type: 'page:created'; data: PageInfo }
|
| { type: 'page:created'; data: PageInfo }
|
||||||
| { type: 'page:closed'; data: { id: string } }
|
| { type: 'page:closed'; data: { id: string } }
|
||||||
| { type: 'page:navigated'; data: { id: string; url: string; title: string } }
|
| { type: 'page:navigated'; data: { id: string; url: string; title: string } }
|
||||||
| { type: 'page:crashed'; data: { id: string; error: string } }
|
| { type: 'page:crashed'; data: { id: string; error: string } }
|
||||||
| { type: 'page:console'; data: { id: string; level: string; text: 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 } };
|
| { type: 'page:detection:warning'; data: { id: string; level: string; detail: string } };
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"@visionl/core": "*",
|
"@visionl/core": "*",
|
||||||
"playwright": "^1.52.0",
|
"playwright": "^1.52.0",
|
||||||
"playwright-extra": "^4.3.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": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.0.0"
|
"@types/ws": "^8.0.0"
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
import { chromium } from 'playwright-extra';
|
import { chromium } from 'playwright-extra';
|
||||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||||
import type { FingerprintProfile, PageInfo } from '@visionl/core';
|
import type { FingerprintProfile, PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core';
|
||||||
import { PageRegistry } from './page-registry.js';
|
import { PageRegistry, type RegisteredPage } from './page-registry.js';
|
||||||
import { getProfile } from './stealth/profiles/index.js';
|
import { getProfile } from './stealth/profiles/index.js';
|
||||||
import { applyStealth } from './stealth/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 / 模块级别一次性注入隐身插件
|
// Apply stealth plugin once at module level / 模块级别一次性注入隐身插件
|
||||||
chromium.use(StealthPlugin());
|
chromium.use(StealthPlugin());
|
||||||
@@ -80,19 +84,104 @@ export class BrowserManager {
|
|||||||
const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id };
|
const info: PageInfo = { id, url, alias, title, status: 'active', profile: this.profile.id };
|
||||||
this.registry.add(info, page, context);
|
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;
|
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> {
|
async closePage(idOrAlias: string): Promise<void> {
|
||||||
const entry = this.registry.findByIdOrAlias(idOrAlias);
|
const entry = this.registry.findByIdOrAlias(idOrAlias);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
|
throw Object.assign(new Error(`Page "${idOrAlias}" not found`), { code: 'PAGE_NOT_FOUND' });
|
||||||
}
|
}
|
||||||
|
const pageId = entry.info.id;
|
||||||
await entry.context.close();
|
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);
|
return this.registry.findByIdOrAlias(idOrAlias);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// PageRegistry — tracks active Playwright pages by ID and alias / 页面注册表,通过 ID 和别名追踪活跃页面
|
// PageRegistry — tracks active Playwright pages by ID and alias / 页面注册表,通过 ID 和别名追踪活跃页面
|
||||||
import type { Page, BrowserContext } from 'playwright';
|
import type { Page, BrowserContext } from 'playwright';
|
||||||
import type { PageInfo } from '@visionl/core';
|
import type { PageInfo, ConsoleEntry, NetworkEntry } from '@visionl/core';
|
||||||
|
|
||||||
export interface RegisteredPage {
|
export interface RegisteredPage {
|
||||||
info: PageInfo;
|
info: PageInfo;
|
||||||
page: Page;
|
page: Page;
|
||||||
context: BrowserContext;
|
context: BrowserContext;
|
||||||
|
consoleLog: ConsoleEntry[];
|
||||||
|
networkLog: NetworkEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PageRegistry {
|
export class PageRegistry {
|
||||||
@@ -13,7 +15,7 @@ export class PageRegistry {
|
|||||||
private aliasMap = new Map<string, string>(); // alias → id
|
private aliasMap = new Map<string, string>(); // alias → id
|
||||||
|
|
||||||
add(info: PageInfo, page: Page, context: BrowserContext): void {
|
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) {
|
if (info.alias) {
|
||||||
this.aliasMap.set(info.alias, info.id);
|
this.aliasMap.set(info.alias, info.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,34 @@ export function pageRoutes(bm: BrowserManager): RouteHandler {
|
|||||||
return true;
|
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
|
// GET /pages/:id
|
||||||
// DELETE /pages/:id
|
// DELETE /pages/:id
|
||||||
if (segments[0] === 'pages' && segments.length === 2) {
|
if (segments[0] === 'pages' && segments.length === 2) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { pageRoutes } from './routes/pages.js';
|
|||||||
import { contentRoutes } from './routes/content.js';
|
import { contentRoutes } from './routes/content.js';
|
||||||
import { actionRoutes } from './routes/actions.js';
|
import { actionRoutes } from './routes/actions.js';
|
||||||
import type { BrowserManager } from './browser-manager.js';
|
import type { BrowserManager } from './browser-manager.js';
|
||||||
|
import { createWsRelay } from './ws-relay.js';
|
||||||
|
|
||||||
export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
|
export function startServer(port: number, browserManager?: BrowserManager): Promise<http.Server> {
|
||||||
const routes = [healthRoute];
|
const routes = [healthRoute];
|
||||||
@@ -24,6 +25,7 @@ export function startServer(port: number, browserManager?: BrowserManager): Prom
|
|||||||
|
|
||||||
server.listen(port, '127.0.0.1', () => {
|
server.listen(port, '127.0.0.1', () => {
|
||||||
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
|
console.log(`[daemon] VisionL daemon started on http://127.0.0.1:${port}`);
|
||||||
|
createWsRelay(server);
|
||||||
resolve(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