#!/usr/bin/env node import { readFileSync } from 'node:fs' import http from 'node:http' import { fileURLToPath } from 'node:url' import { WebSocketServer } from 'ws' const EXT_ID = 'akldabonmimlicnjlflnapfeklbfemhj' const STORE_URL = `https://chromewebstore.google.com/detail/page-agent-ext/${EXT_ID}` const LOOPBACK_HOST = 'localhost' const launcherTemplate = readFileSync( fileURLToPath(new URL('./launcher.html', import.meta.url)), 'utf-8' ) /** * HTTP + WebSocket bridge to the hub.html extension tab. * - HTTP serves the launcher page (triggers extension to open hub) * - WS carries execute/stop commands and result/error responses */ export class HubBridge { /** @type {number} */ port /** @type {http.Server} */ #httpServer /** @type {WebSocketServer} */ #wss /** @type {import('ws').WebSocket | null} */ #hub = null /** @type {{ resolve: (r: {success: boolean, data: string}) => void, reject: (e: Error) => void } | null} */ #pendingTask = null /** @param {number} port */ constructor(port) { this.port = port this.#httpServer = http.createServer((_req, res) => { const html = launcherTemplate .replaceAll('__EXT_ID__', EXT_ID) .replaceAll('__STORE_URL__', STORE_URL) .replaceAll('__WS_PORT__', String(port)) res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) res.end(html) }) this.#wss = new WebSocketServer({ server: this.#httpServer }) this.#wss.on('connection', (ws) => this.#onConnection(ws)) } /** @returns {Promise} */ async start() { return new Promise((resolve, reject) => { this.#httpServer.on('error', (/** @type {NodeJS.ErrnoException} */ err) => { if (err.code === 'EADDRINUSE') { reject( new Error(`Port ${this.port} is in use. Another Page Agent MCP server may be running.`) ) } else { reject(err) } }) this.#httpServer.listen(this.port, LOOPBACK_HOST, () => { console.error(`[page-agent-mcp] HTTP + WS on http://${LOOPBACK_HOST}:${this.port}`) resolve() }) }) } get connected() { return this.#hub?.readyState === 1 } get busy() { return this.#pendingTask !== null } /** * @param {string} task * @param {Record} [config] * @returns {Promise<{success: boolean, data: string}>} */ async executeTask(task, config) { if (!this.connected) throw new Error('Hub is not connected. Is the extension running?') if (this.#pendingTask) throw new Error('Agent is already running a task.') return new Promise((resolve, reject) => { this.#pendingTask = { resolve, reject } this.#hub.send(JSON.stringify({ type: 'execute', task, config })) }) } stopTask() { if (this.connected) { this.#hub.send(JSON.stringify({ type: 'stop' })) } } // TODO: Add version checking /** @param {import('ws').WebSocket} ws */ #onConnection(ws) { if (this.#hub && this.#hub.readyState === 1) { ws.close(4000, 'Another hub is already connected') return } this.#hub = ws console.error('[page-agent-mcp] Hub connected') ws.on('message', (/** @type {Buffer} */ rawData) => { /** @type {{ type: string, success?: boolean, data?: string, message?: string }} */ let msg try { msg = JSON.parse(rawData.toString('utf-8')) } catch { return } if (msg.type === 'result') { this.#pendingTask?.resolve({ success: msg.success ?? false, data: msg.data ?? '' }) this.#pendingTask = null } else if (msg.type === 'error') { this.#pendingTask?.reject(new Error(msg.message ?? 'Unknown error from hub')) this.#pendingTask = null } }) ws.on('close', () => { console.error('[page-agent-mcp] Hub disconnected') if (this.#hub === ws) this.#hub = null if (this.#pendingTask) { this.#pendingTask.reject(new Error('Hub disconnected while task was running')) this.#pendingTask = null } }) } }