Files
opencli/src/daemon.ts
T
jackwener 59c0d639a5 refactor: fix 9 issues from round 2 code review
Bug fixes:
- #1 /logs?level=error returned 404 — use pathname for route matching
- #2 Duplicate initialization — added 'initialized' guard flag

Should fix:
- #4 Added screenshot() to IPage interface
- #5 Graceful shutdown rejects pending requests before exit
- #6 Use process.execPath instead of 'npx tsx' for faster daemon spawn

Cleanup:
- #7 Removed duplicate 'browser' keyword in package.json
- #8 Removed unused normalizeEvaluateSource import from browser.ts
- #9 Changed dynamic import to static import in intercept.ts
- #10 Added explicit throw at end of sendCommand for clarity

61 tests pass (4 test files). Extension: 10.55KB.
2026-03-19 16:36:06 +08:00

218 lines
7.2 KiB
TypeScript

/**
* opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
*
* Architecture:
* CLI → HTTP POST /command → daemon → WebSocket → Extension
* Extension → WebSocket result → daemon → HTTP response → CLI
*
* Lifecycle:
* - Auto-spawned by opencli on first browser command
* - Auto-exits after 5 minutes of idle
* - Listens on localhost:19825
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// ─── State ───────────────────────────────────────────────────────────
let extensionWs: WebSocket | null = null;
const pending = new Map<string, {
resolve: (data: unknown) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>();
let idleTimer: ReturnType<typeof setTimeout> | null = null;
// Extension log ring buffer
interface LogEntry { level: string; msg: string; ts: number; }
const LOG_BUFFER_SIZE = 200;
const logBuffer: LogEntry[] = [];
function pushLog(entry: LogEntry): void {
logBuffer.push(entry);
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
}
// ─── Idle auto-exit ──────────────────────────────────────────────────
function resetIdleTimer(): void {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
console.error('[daemon] Idle timeout, shutting down');
process.exit(0);
}, IDLE_TIMEOUT);
}
// ─── HTTP Server ─────────────────────────────────────────────────────
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
function jsonResponse(res: ServerResponse, status: number, data: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
res.end(JSON.stringify(data));
}
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const url = req.url ?? '/';
const pathname = url.split('?')[0];
if (req.method === 'GET' && pathname === '/status') {
jsonResponse(res, 200, {
ok: true,
extensionConnected: extensionWs?.readyState === WebSocket.OPEN,
pending: pending.size,
});
return;
}
if (req.method === 'GET' && pathname === '/logs') {
const params = new URL(url, `http://localhost:${PORT}`).searchParams;
const level = params.get('level');
const filtered = level
? logBuffer.filter(e => e.level === level)
: logBuffer;
jsonResponse(res, 200, { ok: true, logs: filtered });
return;
}
if (req.method === 'DELETE' && pathname === '/logs') {
logBuffer.length = 0;
jsonResponse(res, 200, { ok: true });
return;
}
if (req.method === 'POST' && url === '/command') {
resetIdleTimer();
try {
const body = JSON.parse(await readBody(req));
if (!body.id) {
jsonResponse(res, 400, { ok: false, error: 'Missing command id' });
return;
}
if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) {
jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' });
return;
}
const result = await new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(body.id);
reject(new Error('Command timeout (30s)'));
}, 30000);
pending.set(body.id, { resolve, reject, timer });
extensionWs!.send(JSON.stringify(body));
});
jsonResponse(res, 200, result);
} catch (err) {
jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, {
ok: false,
error: err instanceof Error ? err.message : 'Invalid request',
});
}
return;
}
jsonResponse(res, 404, { error: 'Not found' });
}
// ─── WebSocket for Extension ─────────────────────────────────────────
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
wss.on('connection', (ws) => {
console.error('[daemon] Extension connected');
extensionWs = ws;
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
// Handle log messages from extension
if (msg.type === 'log') {
const prefix = msg.level === 'error' ? '❌' : msg.level === 'warn' ? '⚠️' : '📋';
console.error(`${prefix} [ext] ${msg.msg}`);
pushLog({ level: msg.level, msg: msg.msg, ts: msg.ts ?? Date.now() });
return;
}
// Handle command results
const p = pending.get(msg.id);
if (p) {
clearTimeout(p.timer);
pending.delete(msg.id);
p.resolve(msg);
}
} catch {
// Ignore malformed messages
}
});
ws.on('close', () => {
console.error('[daemon] Extension disconnected');
if (extensionWs === ws) {
extensionWs = null;
// Reject all pending requests since the extension is gone
for (const [id, p] of pending) {
clearTimeout(p.timer);
p.reject(new Error('Extension disconnected'));
}
pending.clear();
}
});
ws.on('error', () => {
if (extensionWs === ws) extensionWs = null;
});
});
// ─── Start ───────────────────────────────────────────────────────────
httpServer.listen(PORT, '127.0.0.1', () => {
console.error(`[daemon] Listening on http://127.0.0.1:${PORT}`);
resetIdleTimer();
});
httpServer.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
process.exit(0);
}
console.error('[daemon] Server error:', err.message);
process.exit(1);
});
// Graceful shutdown
function shutdown(): void {
// Reject all pending requests so CLI doesn't hang
for (const [, p] of pending) {
clearTimeout(p.timer);
p.reject(new Error('Daemon shutting down'));
}
pending.clear();
if (extensionWs) extensionWs.close();
httpServer.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);