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:
parent
e7e5f53b57
commit
7cdb1d1eaa
16
bun.lock
16
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=="],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue