diff --git a/bun.lock b/bun.lock index 8712f8446..e05eb5ce3 100644 --- a/bun.lock +++ b/bun.lock @@ -42,6 +42,7 @@ }, "devDependencies": { "@agentclientprotocol/sdk": "^0.14.1", + "@hono/node-server": "^1.19.11", "@iconify-json/lucide": "^1.2.94", "@modelcontextprotocol/sdk": "^1.27.1", "@playwright/test": "^1.58.2", @@ -49,9 +50,11 @@ "@types/culori": "^4.0.1", "@types/node": "^25.3.2", "@types/prismjs": "^1.26.6", + "@types/ws": "^8.18.1", "@typescript/native-preview": "^7.0.0-dev.20260228.1", "@vitejs/plugin-vue": "^6.0.4", "esbuild": "^0.27.3", + "hono": "^4.12.5", "jscpd": "^4.0.8", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", @@ -62,6 +65,7 @@ "vite": "^7.0.4", "vite-plugin-pwa": "^1.2.0", "workbox-window": "^7.4.0", + "ws": "^8.19.0", }, }, "packages/acp": { @@ -552,7 +556,7 @@ "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@iconify-json/lucide": ["@iconify-json/lucide@1.2.95", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-SxDM/NEJtcGGAiLAnaZ7rcmVxkJI8esswTZLCm5BfNcoPf/yawIImb4nNfsu4dtFzP/Cl6KRg+vZq521zOUOnQ=="], @@ -1452,7 +1456,7 @@ "hi-base32": ["hi-base32@0.5.1", "", {}, "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA=="], - "hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="], "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], @@ -2418,12 +2422,20 @@ "@libp2p/crypto/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@modelcontextprotocol/sdk/hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + "@multiformats/dns/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="], "@open-pencil/acp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], + "@open-pencil/mcp/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@open-pencil/mcp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], + "@open-pencil/mcp/hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], + "@rollup/plugin-babel/rollup": ["rollup@2.80.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ=="], "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], diff --git a/package.json b/package.json index b098424bf..5e74d130a 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ }, "devDependencies": { "@agentclientprotocol/sdk": "^0.14.1", + "@hono/node-server": "^1.19.11", "@iconify-json/lucide": "^1.2.94", "@modelcontextprotocol/sdk": "^1.27.1", "@playwright/test": "^1.58.2", @@ -74,9 +75,11 @@ "@types/culori": "^4.0.1", "@types/node": "^25.3.2", "@types/prismjs": "^1.26.6", + "@types/ws": "^8.18.1", "@typescript/native-preview": "^7.0.0-dev.20260228.1", "@vitejs/plugin-vue": "^6.0.4", "esbuild": "^0.27.3", + "hono": "^4.12.5", "jscpd": "^4.0.8", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", @@ -86,6 +89,7 @@ "unplugin-vue-components": "^31.0.0", "vite": "^7.0.4", "vite-plugin-pwa": "^1.2.0", - "workbox-window": "^7.4.0" + "workbox-window": "^7.4.0", + "ws": "^8.19.0" } } \ No newline at end of file diff --git a/src/automation/bridge.ts b/src/automation/bridge.ts index a7379fdfc..0bcae1b64 100644 --- a/src/automation/bridge.ts +++ b/src/automation/bridge.ts @@ -1,11 +1,14 @@ /** - * Automation bridge — runs in the Vite/Bun process (NOT in the browser). + * Automation bridge — runs in the Vite process (Node.js or Bun). * - * The browser page connects via WebSocket and registers itself - * as the RPC executor. CLI/MCP clients connect via HTTP. + * Hono HTTP server on :7600 for CLI/MCP clients. + * ws WebSocket server on :7601 for the browser page. * - * Flow: CLI → HTTP POST /rpc → bridge → WebSocket → browser → execute → response + * Flow: CLI → HTTP POST /rpc → Hono → WebSocket → browser → execute → response */ +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { WebSocketServer, type WebSocket } from 'ws' const AUTOMATION_PORT = 7600 const AUTOMATION_WS_PORT = 7601 @@ -17,16 +20,13 @@ interface PendingRequest { timer: ReturnType } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun WebSocket type not available in browser tsconfig -type BunWebSocket = any - const pending = new Map() -let browserWs: BunWebSocket | null = null +let browserWs: WebSocket | null = null let authToken: string | null = null function sendToBrowser(body: Record): Promise { return new Promise((resolve, reject) => { - if (!browserWs) { + if (!browserWs || browserWs.readyState !== browserWs.OPEN) { reject(new Error('OpenPencil app is not connected')) return } @@ -63,103 +63,82 @@ function handleBrowserMessage(data: string) { else req.resolve(msg.result) } } catch { - // ignore + // ignore malformed messages } } export function startAutomationBridge() { - // @ts-expect-error -- Bun global not in browser tsconfig, but this file runs in Vite/Bun - const BunGlobal = globalThis.Bun - if (!BunGlobal) { - console.warn('[automation] Bun runtime not available, skipping bridge') - return - } + const wss = new WebSocketServer({ port: AUTOMATION_WS_PORT, host: '127.0.0.1' }) - BunGlobal.serve({ - port: AUTOMATION_WS_PORT, - hostname: '127.0.0.1', - fetch(req: Request, server: { upgrade: (req: Request) => boolean }) { - if (server.upgrade(req)) return undefined - return new Response('WebSocket only', { status: 400 }) - }, - websocket: { - open(ws: BunWebSocket) { - browserWs = ws - }, - message(_ws: BunWebSocket, message: string | ArrayBuffer) { - handleBrowserMessage( - typeof message === 'string' ? message : new TextDecoder().decode(message as ArrayBuffer) - ) - }, - close(ws: BunWebSocket) { - if (browserWs === ws) { - browserWs = null - authToken = null - for (const [id, req] of pending) { - clearTimeout(req.timer) - req.reject(new Error('Browser disconnected')) - pending.delete(id) - } + wss.on('connection', (ws) => { + browserWs = ws + + ws.on('message', (raw) => { + handleBrowserMessage(typeof raw === 'string' ? raw : raw.toString('utf-8')) + }) + + ws.on('close', () => { + if (browserWs === ws) { + browserWs = null + authToken = null + for (const [id, req] of pending) { + clearTimeout(req.timer) + req.reject(new Error('Browser disconnected')) + pending.delete(id) } } + }) + }) + + const app = new Hono() + app.use('*', cors()) + + app.get('/health', (c) => { + return c.json({ + status: browserWs ? 'ok' : 'no_app', + ...(browserWs && authToken ? { token: authToken } : {}) + }) + }) + + app.use('/rpc', async (c, next) => { + if (!browserWs || !authToken) { + return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503) + } + const auth = c.req.header('authorization') + const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null + if (provided !== authToken) { + return c.json({ error: 'Unauthorized' }, 401) + } + await next() + }) + + app.post('/rpc', async (c) => { + const body = await c.req.json().catch(() => null) + if (!body || typeof body !== 'object') { + return c.json({ error: 'Invalid request body' }, 400) + } + try { + const result = await sendToBrowser(body as Record) + return c.json({ ok: true, result }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return c.json({ ok: false, error: msg }, 502) } }) - BunGlobal.serve({ - port: AUTOMATION_PORT, - hostname: '127.0.0.1', - async fetch(req: Request) { - const url = new URL(req.url) - const headers = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Authorization, Content-Type' - } - - if (req.method === 'OPTIONS') { - return new Response(null, { status: 204, headers }) - } - - if (url.pathname === '/health') { - return Response.json( - { - status: browserWs ? 'ok' : 'no_app', - ...(browserWs && authToken ? { token: authToken } : {}) - }, - { headers } - ) - } - - if (!browserWs || !authToken) { - return Response.json( - { error: 'OpenPencil app is not connected. Is a document open?' }, - { status: 503, headers } - ) - } - - const auth = req.headers.get('authorization') - const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null - if (provided !== authToken) { - return Response.json({ error: 'Unauthorized' }, { status: 401, headers }) - } - - if (url.pathname === '/rpc' && req.method === 'POST') { - const body = await req.json().catch(() => null) - if (!body || typeof body !== 'object') { - return Response.json({ error: 'Invalid request body' }, { status: 400, headers }) - } - try { - const result = await sendToBrowser(body as Record) - return Response.json({ ok: true, result }, { headers }) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - return Response.json({ ok: false, error: msg }, { status: 502, headers }) - } - } - - return Response.json({ error: 'Not found' }, { status: 404, headers }) - } - }) + startServer(app) console.log(`[automation] HTTP http://127.0.0.1:${AUTOMATION_PORT}`) console.log(`[automation] WS ws://127.0.0.1:${AUTOMATION_WS_PORT}`) } + +async function startServer(app: Hono) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- runtime detection across Bun/Node + if ((globalThis as any).Bun !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun.serve not in Node types + ;(globalThis as any).Bun.serve({ fetch: app.fetch, port: AUTOMATION_PORT, hostname: '127.0.0.1' }) + } else { + const { serve } = await import('@hono/node-server') + serve({ fetch: app.fetch, port: AUTOMATION_PORT, hostname: '127.0.0.1' }) + } +}