daemon-client.ts
1 /** 2 * HTTP client for communicating with the opencli daemon. 3 * 4 * Provides a typed send() function that posts a Command and returns a Result. 5 */ 6 7 import { DEFAULT_DAEMON_PORT } from '../constants.js'; 8 import type { BrowserSessionInfo } from '../types.js'; 9 import { sleep } from '../utils.js'; 10 import { classifyBrowserError } from './errors.js'; 11 12 const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); 13 const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`; 14 const OPENCLI_HEADERS = { 'X-OpenCLI': '1' }; 15 16 let _idCounter = 0; 17 18 function generateId(): string { 19 return `cmd_${process.pid}_${Date.now()}_${++_idCounter}`; 20 } 21 22 export interface DaemonCommand { 23 id: string; 24 action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind-current' | 'network-capture-start' | 'network-capture-read' | 'cdp' | 'frames'; 25 /** Target page identity (targetId). Cross-layer contract with the extension. */ 26 page?: string; 27 code?: string; 28 workspace?: string; 29 url?: string; 30 op?: string; 31 index?: number; 32 domain?: string; 33 matchDomain?: string; 34 matchPathPrefix?: string; 35 format?: 'png' | 'jpeg'; 36 quality?: number; 37 fullPage?: boolean; 38 39 /** Local file paths for set-file-input action */ 40 files?: string[]; 41 /** CSS selector for file input element (set-file-input action) */ 42 selector?: string; 43 /** Raw text payload for insert-text action */ 44 text?: string; 45 /** URL substring filter pattern for network capture */ 46 pattern?: string; 47 cdpMethod?: string; 48 cdpParams?: Record<string, unknown>; 49 /** When true, automation windows are created in the foreground */ 50 windowFocused?: boolean; 51 /** Custom idle timeout in seconds for this workspace session. Overrides the default. */ 52 idleTimeout?: number; 53 /** Frame index for cross-frame operations (0-based, from 'frames' action) */ 54 frameIndex?: number; 55 } 56 57 export interface DaemonResult { 58 id: string; 59 ok: boolean; 60 data?: unknown; 61 error?: string; 62 /** Page identity (targetId) — present on page-scoped command responses */ 63 page?: string; 64 } 65 66 export interface DaemonStatus { 67 ok: boolean; 68 pid: number; 69 uptime: number; 70 daemonVersion?: string; 71 extensionConnected: boolean; 72 extensionVersion?: string; 73 extensionCompatRange?: string; 74 pending: number; 75 memoryMB: number; 76 port: number; 77 } 78 79 async function requestDaemon(pathname: string, init?: RequestInit & { timeout?: number }): Promise<Response> { 80 const { timeout = 2000, headers, ...rest } = init ?? {}; 81 const controller = new AbortController(); 82 const timer = setTimeout(() => controller.abort(), timeout); 83 try { 84 return await fetch(`${DAEMON_URL}${pathname}`, { 85 ...rest, 86 headers: { ...OPENCLI_HEADERS, ...headers }, 87 signal: controller.signal, 88 }); 89 } finally { 90 clearTimeout(timer); 91 } 92 } 93 94 export async function fetchDaemonStatus(opts?: { timeout?: number }): Promise<DaemonStatus | null> { 95 try { 96 const res = await requestDaemon('/status', { timeout: opts?.timeout ?? 2000 }); 97 if (!res.ok) return null; 98 return await res.json() as DaemonStatus; 99 } catch { 100 return null; 101 } 102 } 103 104 export type DaemonHealth = 105 | { state: 'stopped'; status: null } 106 | { state: 'no-extension'; status: DaemonStatus } 107 | { state: 'ready'; status: DaemonStatus }; 108 109 /** 110 * Unified daemon health check — single entry point for all status queries. 111 * Replaces isDaemonRunning(), isExtensionConnected(), and checkDaemonStatus(). 112 */ 113 export async function getDaemonHealth(opts?: { timeout?: number }): Promise<DaemonHealth> { 114 const status = await fetchDaemonStatus(opts); 115 if (!status) return { state: 'stopped', status: null }; 116 if (!status.extensionConnected) return { state: 'no-extension', status }; 117 return { state: 'ready', status }; 118 } 119 120 export async function requestDaemonShutdown(opts?: { timeout?: number }): Promise<boolean> { 121 try { 122 const res = await requestDaemon('/shutdown', { method: 'POST', timeout: opts?.timeout ?? 5000 }); 123 return res.ok; 124 } catch { 125 return false; 126 } 127 } 128 129 /** 130 * Internal: send a command to the daemon with retry logic. 131 * Returns the raw DaemonResult. All retry policy lives here — callers 132 * (sendCommand, sendCommandFull) only shape the return value. 133 * 134 * Retries up to 4 times: 135 * - Network errors (TypeError, AbortError): retry at 500ms 136 * - Transient browser errors: retry at the delay suggested by classifyBrowserError() 137 */ 138 async function sendCommandRaw( 139 action: DaemonCommand['action'], 140 params: Omit<DaemonCommand, 'id' | 'action'>, 141 ): Promise<DaemonResult> { 142 const maxRetries = 4; 143 144 for (let attempt = 1; attempt <= maxRetries; attempt++) { 145 const id = generateId(); 146 const wf = process.env.OPENCLI_WINDOW_FOCUSED; 147 const windowFocused = (wf === '1' || wf === 'true') ? true : undefined; 148 const command: DaemonCommand = { id, action, ...params, ...(windowFocused && { windowFocused }) }; 149 try { 150 const res = await requestDaemon('/command', { 151 method: 'POST', 152 headers: { 'Content-Type': 'application/json' }, 153 body: JSON.stringify(command), 154 timeout: 30000, 155 }); 156 157 const result = (await res.json()) as DaemonResult; 158 159 if (!result.ok) { 160 const isDuplicateCommandId = res.status === 409 161 || (result.error ?? '').includes('Duplicate command id'); 162 if (isDuplicateCommandId && attempt < maxRetries) { 163 continue; 164 } 165 const advice = classifyBrowserError(new Error(result.error ?? '')); 166 if (advice.retryable && attempt < maxRetries) { 167 await sleep(advice.delayMs); 168 continue; 169 } 170 throw new Error(result.error ?? 'Daemon command failed'); 171 } 172 173 return result; 174 } catch (err) { 175 const isNetworkError = err instanceof TypeError 176 || (err instanceof Error && err.name === 'AbortError'); 177 if (isNetworkError && attempt < maxRetries) { 178 await sleep(500); 179 continue; 180 } 181 throw err; 182 } 183 } 184 throw new Error('sendCommand: max retries exhausted'); 185 } 186 187 /** 188 * Send a command to the daemon and return the result data. 189 */ 190 export async function sendCommand( 191 action: DaemonCommand['action'], 192 params: Omit<DaemonCommand, 'id' | 'action'> = {}, 193 ): Promise<unknown> { 194 const result = await sendCommandRaw(action, params); 195 return result.data; 196 } 197 198 /** 199 * Like sendCommand, but returns both data and page identity (targetId). 200 * Use this for page-scoped commands where the caller needs the page identity. 201 */ 202 export async function sendCommandFull( 203 action: DaemonCommand['action'], 204 params: Omit<DaemonCommand, 'id' | 'action'> = {}, 205 ): Promise<{ data: unknown; page?: string }> { 206 const result = await sendCommandRaw(action, params); 207 return { data: result.data, page: result.page }; 208 } 209 210 export async function listSessions(): Promise<BrowserSessionInfo[]> { 211 const result = await sendCommand('sessions'); 212 return Array.isArray(result) ? result : []; 213 } 214 215 export async function bindCurrentTab(workspace: string, opts: { matchDomain?: string; matchPathPrefix?: string } = {}): Promise<unknown> { 216 return sendCommand('bind-current', { workspace, ...opts }); 217 }