/** * 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 } 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 ) => 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 #approved = 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.#approved = 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.#approved = 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)) } } async #handleMessage(raw: string) { let msg: InboundMessage try { msg = JSON.parse(raw) } catch { return } if (!(await this.#checkApproval())) { this.#send({ type: 'error', message: 'User denied the connection request.' }) return } switch (msg.type) { case 'execute': this.#handleExecute(msg) break case 'stop': this.#handlers.onStop() break } } async #checkApproval(): Promise { if (this.#approved) return true const { allowAllHubConnection } = await chrome.storage.local.get('allowAllHubConnection') if (allowAllHubConnection === true) { this.#approved = true return true } const ok = window.confirm( 'An external application is requesting to control your browser via Page Agent Ext.\nAllow this session?' ) if (ok) this.#approved = true return ok } 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, stop: () => void, configure: (config: ExtConfig) => Promise, config: ExtConfig | null ): { wsState: HubWsState } { const wsPort = new URLSearchParams(location.search).get('ws') const [wsState, setWsState] = useState(() => (wsPort ? 'connecting' : 'disconnected')) const hubWsRef = useRef(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 } }