/ src / browser / bridge.ts
bridge.ts
  1  /**
  2   * Browser session manager — auto-spawns daemon and provides IPage.
  3   */
  4  
  5  import { spawn, type ChildProcess } from 'node:child_process';
  6  import { fileURLToPath } from 'node:url';
  7  import * as path from 'node:path';
  8  import * as fs from 'node:fs';
  9  import type { IPage } from '../types.js';
 10  import type { IBrowserFactory } from '../runtime.js';
 11  import { Page } from './page.js';
 12  import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
 13  import { DEFAULT_DAEMON_PORT } from '../constants.js';
 14  import { BrowserConnectError } from '../errors.js';
 15  import { PKG_VERSION } from '../version.js';
 16  
 17  const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
 18  
 19  export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
 20  
 21  /**
 22   * Browser factory: manages daemon lifecycle and provides IPage instances.
 23   */
 24  export class BrowserBridge implements IBrowserFactory {
 25    private _state: BrowserBridgeState = 'idle';
 26    private _page: Page | null = null;
 27    private _daemonProc: ChildProcess | null = null;
 28  
 29    get state(): BrowserBridgeState {
 30      return this._state;
 31    }
 32  
 33    async connect(opts: { timeout?: number; workspace?: string; idleTimeout?: number } = {}): Promise<IPage> {
 34      if (this._state === 'connected' && this._page) return this._page;
 35      if (this._state === 'connecting') throw new Error('Already connecting');
 36      if (this._state === 'closing') throw new Error('Session is closing');
 37      if (this._state === 'closed') throw new Error('Session is closed');
 38  
 39      this._state = 'connecting';
 40  
 41      try {
 42        await this._ensureDaemon(opts.timeout);
 43        this._page = new Page(opts.workspace, opts.idleTimeout);
 44        this._state = 'connected';
 45        return this._page;
 46      } catch (err) {
 47        this._state = 'idle';
 48        throw err;
 49      }
 50    }
 51  
 52    async close(): Promise<void> {
 53      if (this._state === 'closed') return;
 54      this._state = 'closing';
 55      // We don't kill the daemon — it's persistent.
 56      // Just clean up our reference.
 57      this._page = null;
 58      this._state = 'closed';
 59    }
 60  
 61    private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
 62      const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
 63      const timeoutMs = effectiveSeconds * 1000;
 64  
 65      const health = await getDaemonHealth();
 66  
 67      // Fast path: everything ready
 68      if (health.state === 'ready') return;
 69  
 70      // Daemon running but no extension
 71      if (health.state === 'no-extension') {
 72        // Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
 73        const daemonVersion = health.status?.daemonVersion;
 74        const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;
 75  
 76        if (isStale) {
 77          // Stale daemon — restart it so extension gets a fresh WebSocket endpoint
 78          const reason = daemonVersion
 79            ? `v${daemonVersion} ≠ v${PKG_VERSION}`
 80            : `pre-version daemon, CLI is v${PKG_VERSION}`;
 81          if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
 82            process.stderr.write(`⚠️  Stale daemon detected (${reason}). Restarting...\n`);
 83          }
 84          const shutdownAccepted = await requestDaemonShutdown();
 85          const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
 86  
 87          if (!portReleased) {
 88            // Stale daemon replacement failed — don't blindly spawn on an occupied port
 89            throw new BrowserConnectError(
 90              'Stale daemon could not be replaced',
 91              `A stale daemon (${reason}) is running but did not shut down.\n` +
 92              '  Run manually: opencli daemon stop && opencli doctor',
 93              'daemon-not-running',
 94            );
 95          }
 96          // Port released — fall through to spawn a fresh daemon
 97        } else {
 98          // Same version — wait for extension to connect
 99          if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
100            process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
101            process.stderr.write('   Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
102          }
103          if (await this._pollUntilReady(timeoutMs)) return;
104          throw new BrowserConnectError(
105            'Browser Bridge extension not connected',
106            'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
107            'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
108            'If not installed:\n' +
109            '  1. Download: https://github.com/jackwener/opencli/releases\n' +
110            '  2. Open chrome://extensions → Developer Mode → Load unpacked',
111            'extension-not-connected',
112          );
113        }
114      }
115  
116      // No daemon — spawn one
117      const __dirname = path.dirname(fileURLToPath(import.meta.url));
118      const parentDir = path.resolve(__dirname, '..');
119      const daemonTs = path.join(parentDir, 'daemon.ts');
120      const daemonJs = path.join(parentDir, 'daemon.js');
121      const isTs = fs.existsSync(daemonTs);
122      const daemonPath = isTs ? daemonTs : daemonJs;
123  
124      if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
125        process.stderr.write('⏳ Starting daemon...\n');
126      }
127  
128      const spawnArgs = isTs
129        ? [process.execPath, '--import', 'tsx/esm', daemonPath]
130        : [process.execPath, daemonPath];
131  
132      this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
133        detached: true,
134        stdio: 'ignore',
135        env: { ...process.env },
136      });
137      this._daemonProc.unref();
138  
139      // Wait for daemon + extension
140      if (await this._pollUntilReady(timeoutMs)) return;
141  
142      const finalHealth = await getDaemonHealth();
143      if (finalHealth.state === 'no-extension') {
144        throw new BrowserConnectError(
145          'Browser Bridge extension not connected',
146          'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
147          'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
148          'If not installed:\n' +
149          '  1. Download: https://github.com/jackwener/opencli/releases\n' +
150          '  2. Open chrome://extensions → Developer Mode → Load unpacked',
151          'extension-not-connected',
152        );
153      }
154  
155      throw new BrowserConnectError(
156        'Failed to start opencli daemon',
157        `Try running manually:\n  node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`,
158        'daemon-not-running',
159      );
160    }
161  
162    /** Poll until daemon is fully stopped (port released). */
163    private async _waitForDaemonStop(timeoutMs: number): Promise<boolean> {
164      const deadline = Date.now() + timeoutMs;
165      while (Date.now() < deadline) {
166        await new Promise(resolve => setTimeout(resolve, 200));
167        const h = await getDaemonHealth();
168        if (h.state === 'stopped') return true;
169      }
170      return false;
171    }
172  
173    /** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
174    private async _pollUntilReady(timeoutMs: number): Promise<boolean> {
175      const deadline = Date.now() + timeoutMs;
176      while (Date.now() < deadline) {
177        await new Promise(resolve => setTimeout(resolve, 200));
178        const h = await getDaemonHealth();
179        if (h.state === 'ready') return true;
180      }
181      return false;
182    }
183  }