Replace Bun.serve with Hono + ws for Node.js compatibility

Bridge now uses Hono for the HTTP server and ws for WebSocket —
works on both Bun and Node.js. Uses @hono/node-server as fallback
when Bun runtime is not available.
This commit is contained in:
Danila Poyarkov 2026-03-07 14:30:12 +03:00
parent e7e5f53b57
commit 7cdb1d1eaa
3 changed files with 93 additions and 98 deletions

View file

@ -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=="],

View file

@ -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"
}
}

View file

@ -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<typeof setTimeout>
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun WebSocket type not available in browser tsconfig
type BunWebSocket = any
const pending = new Map<string, PendingRequest>()
let browserWs: BunWebSocket | null = null
let browserWs: WebSocket | null = null
let authToken: string | null = null
function sendToBrowser(body: Record<string, unknown>): Promise<unknown> {
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<string, unknown>)
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<string, unknown>)
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' })
}
}