/ src / browser / daemon-client.ts
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  }