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 }