feat(ext): hub protocol
This commit is contained in:
219
packages/extension/src/entrypoints/hub/hub-ws.ts
Normal file
219
packages/extension/src/entrypoints/hub/hub-ws.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Hub WebSocket Protocol
|
||||
*
|
||||
* Hub connects as WS client to `ws://localhost:{port}`.
|
||||
* All messages are JSON. One task at a time.
|
||||
*
|
||||
* Inbound (Caller → Hub):
|
||||
* { type: "execute", task: string, config?: object }
|
||||
* { type: "stop" }
|
||||
*
|
||||
* Outbound (Hub → Caller):
|
||||
* { type: "ready" }
|
||||
* { type: "result", success: boolean, data: string }
|
||||
* { type: "error", message: string }
|
||||
*/
|
||||
import type { ExecutionResult } from '@page-agent/core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import type { ExtConfig } from '@/agent/useAgent'
|
||||
|
||||
// --- Protocol types ---
|
||||
|
||||
interface ExecuteMessage {
|
||||
type: 'execute'
|
||||
task: string
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface StopMessage {
|
||||
type: 'stop'
|
||||
}
|
||||
|
||||
type InboundMessage = ExecuteMessage | StopMessage
|
||||
|
||||
interface ReadyMessage {
|
||||
type: 'ready'
|
||||
}
|
||||
|
||||
interface ResultMessage {
|
||||
type: 'result'
|
||||
success: boolean
|
||||
data: string
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
type: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
type OutboundMessage = ReadyMessage | ResultMessage | ErrorMessage
|
||||
|
||||
export type HubWsState = 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
// --- HubWs class ---
|
||||
|
||||
export interface HubWsHandlers {
|
||||
onExecute: (
|
||||
task: string,
|
||||
config?: Record<string, unknown>
|
||||
) => Promise<{ success: boolean; data: string }>
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Framework-agnostic WebSocket client for Hub.
|
||||
* Connects to an external WS server, receives tasks, dispatches to handlers,
|
||||
* and sends results back. No React, no DOM.
|
||||
*/
|
||||
export class HubWs {
|
||||
#ws: WebSocket | null = null
|
||||
#state: HubWsState = 'disconnected'
|
||||
#busy = false
|
||||
#handlers: HubWsHandlers
|
||||
#port: number
|
||||
#onStateChange: (state: HubWsState) => void
|
||||
|
||||
constructor(port: number, handlers: HubWsHandlers, onStateChange: (state: HubWsState) => void) {
|
||||
this.#port = port
|
||||
this.#handlers = handlers
|
||||
this.#onStateChange = onStateChange
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.#state
|
||||
}
|
||||
|
||||
get busy() {
|
||||
return this.#busy
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.#ws) return
|
||||
this.#setState('connecting')
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:${this.#port}`)
|
||||
this.#ws = ws
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this.#setState('connected')
|
||||
this.#send({ type: 'ready' })
|
||||
})
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
this.#ws = null
|
||||
this.#busy = false
|
||||
this.#setState('disconnected')
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
this.#handleMessage(event.data as string)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#ws?.close()
|
||||
this.#ws = null
|
||||
this.#busy = false
|
||||
this.#setState('disconnected')
|
||||
}
|
||||
|
||||
#setState(state: HubWsState) {
|
||||
if (this.#state === state) return
|
||||
this.#state = state
|
||||
this.#onStateChange(state)
|
||||
}
|
||||
|
||||
#send(msg: OutboundMessage) {
|
||||
if (this.#ws?.readyState === WebSocket.OPEN) {
|
||||
this.#ws.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
#handleMessage(raw: string) {
|
||||
let msg: InboundMessage
|
||||
try {
|
||||
msg = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case 'execute':
|
||||
this.#handleExecute(msg)
|
||||
break
|
||||
case 'stop':
|
||||
this.#handlers.onStop()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async #handleExecute(msg: ExecuteMessage) {
|
||||
if (this.#busy) {
|
||||
this.#send({ type: 'error', message: 'Hub is busy with another task' })
|
||||
return
|
||||
}
|
||||
|
||||
this.#busy = true
|
||||
try {
|
||||
const result = await this.#handlers.onExecute(msg.task, msg.config)
|
||||
this.#send({ type: 'result', success: result.success, data: result.data })
|
||||
} catch (err) {
|
||||
this.#send({ type: 'error', message: err instanceof Error ? err.message : String(err) })
|
||||
} finally {
|
||||
this.#busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- React hook ---
|
||||
|
||||
/**
|
||||
* React hook that bridges HubWs to the agent's execute/stop/configure.
|
||||
* Handles the config-before-execute dance internally.
|
||||
*/
|
||||
export function useHubWs(
|
||||
execute: (task: string) => Promise<ExecutionResult>,
|
||||
stop: () => void,
|
||||
configure: (config: ExtConfig) => Promise<void>,
|
||||
config: ExtConfig | null
|
||||
): { wsState: HubWsState } {
|
||||
const wsPort = new URLSearchParams(location.search).get('ws')
|
||||
const [wsState, setWsState] = useState<HubWsState>(() => (wsPort ? 'connecting' : 'disconnected'))
|
||||
const hubWsRef = useRef<HubWs | null>(null)
|
||||
|
||||
const latest = useRef({ execute, stop, configure, config })
|
||||
useEffect(() => {
|
||||
latest.current = { execute, stop, configure, config }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsPort) return
|
||||
|
||||
const hubWs = new HubWs(
|
||||
Number(wsPort),
|
||||
{
|
||||
onExecute: async (task, incomingConfig) => {
|
||||
const { execute, configure, config } = latest.current
|
||||
if (incomingConfig) {
|
||||
await configure({ ...config, ...incomingConfig } as ExtConfig)
|
||||
}
|
||||
const result = await execute(task)
|
||||
return { success: result.success, data: result.data }
|
||||
},
|
||||
onStop: () => latest.current.stop(),
|
||||
},
|
||||
setWsState
|
||||
)
|
||||
|
||||
hubWs.connect()
|
||||
hubWsRef.current = hubWs
|
||||
|
||||
return () => {
|
||||
hubWs.disconnect()
|
||||
hubWsRef.current = null
|
||||
}
|
||||
}, [wsPort])
|
||||
|
||||
return { wsState }
|
||||
}
|
||||
Reference in New Issue
Block a user