diff --git a/.env.example b/.env.example index 1e7113e..ba110f8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Remote config endpoint for BrowserOS server settings -BROWSEROS_CONFIG_URL= +# NOTE: create .env.dev for development environment and .env.prod for production environment +BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config # API key for LLM access used by Codex BROWSEROS_API_KEY= diff --git a/.gitignore b/.gitignore index c7fbbe2..8da75d8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* .env.dev +.env.prod # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/packages/controller-ext/manifest.json b/packages/controller-ext/manifest.json index 2740527..acdfbfe 100644 --- a/packages/controller-ext/manifest.json +++ b/packages/controller-ext/manifest.json @@ -36,4 +36,3 @@ "128": "assets/icon128.png" } } - diff --git a/packages/controller-ext/src/background/BrowserOSController.ts b/packages/controller-ext/src/background/BrowserOSController.ts new file mode 100644 index 0000000..ec837a4 --- /dev/null +++ b/packages/controller-ext/src/background/BrowserOSController.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import {ActionRegistry} from '@/actions/ActionRegistry'; +import {CreateBookmarkAction} from '@/actions/bookmark/CreateBookmarkAction'; +import {GetBookmarksAction} from '@/actions/bookmark/GetBookmarksAction'; +import {RemoveBookmarkAction} from '@/actions/bookmark/RemoveBookmarkAction'; +import {CaptureScreenshotAction} from '@/actions/browser/CaptureScreenshotAction'; +import {ClearAction} from '@/actions/browser/ClearAction'; +import {ClickAction} from '@/actions/browser/ClickAction'; +import {ClickCoordinatesAction} from '@/actions/browser/ClickCoordinatesAction'; +import {ExecuteJavaScriptAction} from '@/actions/browser/ExecuteJavaScriptAction'; +import {GetAccessibilityTreeAction} from '@/actions/browser/GetAccessibilityTreeAction'; +import {GetInteractiveSnapshotAction} from '@/actions/browser/GetInteractiveSnapshotAction'; +import {GetPageLoadStatusAction} from '@/actions/browser/GetPageLoadStatusAction'; +import {GetSnapshotAction} from '@/actions/browser/GetSnapshotAction'; +import {InputTextAction} from '@/actions/browser/InputTextAction'; +import {ScrollDownAction} from '@/actions/browser/ScrollDownAction'; +import {ScrollToNodeAction} from '@/actions/browser/ScrollToNodeAction'; +import {ScrollUpAction} from '@/actions/browser/ScrollUpAction'; +import {SendKeysAction} from '@/actions/browser/SendKeysAction'; +import {TypeAtCoordinatesAction} from '@/actions/browser/TypeAtCoordinatesAction'; +import {CheckBrowserOSAction} from '@/actions/diagnostics/CheckBrowserOSAction'; +import {GetRecentHistoryAction} from '@/actions/history/GetRecentHistoryAction'; +import {SearchHistoryAction} from '@/actions/history/SearchHistoryAction'; +import {CloseTabAction} from '@/actions/tab/CloseTabAction'; +import {GetActiveTabAction} from '@/actions/tab/GetActiveTabAction'; +import {GetTabsAction} from '@/actions/tab/GetTabsAction'; +import {NavigateAction} from '@/actions/tab/NavigateAction'; +import {OpenTabAction} from '@/actions/tab/OpenTabAction'; +import {SwitchTabAction} from '@/actions/tab/SwitchTabAction'; +import {CONCURRENCY_CONFIG} from '@/config/constants'; +import type {ProtocolRequest, ProtocolResponse} from '@/protocol/types'; +import {ConnectionStatus} from '@/protocol/types'; +import {ConcurrencyLimiter} from '@/utils/ConcurrencyLimiter'; +import {logger} from '@/utils/Logger'; +import {RequestTracker} from '@/utils/RequestTracker'; +import {RequestValidator} from '@/utils/RequestValidator'; +import {ResponseQueue} from '@/utils/ResponseQueue'; +import {WebSocketClient} from '@/websocket/WebSocketClient'; + +/** + * BrowserOS Controller + * + * Main controller class that orchestrates all components. + * Message flow: WebSocket → Validator → Tracker → Limiter → Action → Response/Queue → WebSocket + */ +export class BrowserOSController { + private wsClient: WebSocketClient; + private requestTracker: RequestTracker; + private concurrencyLimiter: ConcurrencyLimiter; + private requestValidator: RequestValidator; + private responseQueue: ResponseQueue; + private actionRegistry: ActionRegistry; + + constructor(port: number) { + logger.info('Initializing BrowserOS Controller...'); + + this.requestTracker = new RequestTracker(); + this.concurrencyLimiter = new ConcurrencyLimiter( + CONCURRENCY_CONFIG.maxConcurrent, + CONCURRENCY_CONFIG.maxQueueSize, + ); + this.requestValidator = new RequestValidator(); + this.responseQueue = new ResponseQueue(); + this.wsClient = new WebSocketClient(port); + this.actionRegistry = new ActionRegistry(); + + this.registerActions(); + this.setupWebSocketHandlers(); + } + + async start(): Promise { + logger.info('Starting BrowserOS Controller...'); + await this.wsClient.connect(); + } + + stop(): void { + logger.info('Stopping BrowserOS Controller...'); + this.wsClient.disconnect(); + this.requestTracker.destroy(); + this.requestValidator.destroy(); + this.responseQueue.clear(); + } + + logStats(): void { + const stats = this.getStats(); + logger.info('=== Controller Stats ==='); + logger.info(`Connection: ${stats.connection}`); + logger.info(`Requests: ${JSON.stringify(stats.requests)}`); + logger.info(`Concurrency: ${JSON.stringify(stats.concurrency)}`); + logger.info(`Validator: ${JSON.stringify(stats.validator)}`); + logger.info(`Response Queue: ${stats.responseQueue.size} queued`); + } + + getStats() { + return { + connection: this.wsClient.getStatus(), + requests: this.requestTracker.getStats(), + concurrency: this.concurrencyLimiter.getStats(), + validator: this.requestValidator.getStats(), + responseQueue: { + size: this.responseQueue.size(), + }, + }; + } + + isConnected(): boolean { + return this.wsClient.isConnected(); + } + + private registerActions(): void { + logger.info('Registering actions...'); + + this.actionRegistry.register('checkBrowserOS', new CheckBrowserOSAction()); + + this.actionRegistry.register('getActiveTab', new GetActiveTabAction()); + this.actionRegistry.register('getTabs', new GetTabsAction()); + this.actionRegistry.register('openTab', new OpenTabAction()); + this.actionRegistry.register('closeTab', new CloseTabAction()); + this.actionRegistry.register('switchTab', new SwitchTabAction()); + this.actionRegistry.register('navigate', new NavigateAction()); + + this.actionRegistry.register('getBookmarks', new GetBookmarksAction()); + this.actionRegistry.register('createBookmark', new CreateBookmarkAction()); + this.actionRegistry.register('removeBookmark', new RemoveBookmarkAction()); + + this.actionRegistry.register('searchHistory', new SearchHistoryAction()); + this.actionRegistry.register( + 'getRecentHistory', + new GetRecentHistoryAction(), + ); + + this.actionRegistry.register( + 'getInteractiveSnapshot', + new GetInteractiveSnapshotAction(), + ); + this.actionRegistry.register('click', new ClickAction()); + this.actionRegistry.register('inputText', new InputTextAction()); + this.actionRegistry.register('clear', new ClearAction()); + this.actionRegistry.register('scrollToNode', new ScrollToNodeAction()); + + this.actionRegistry.register( + 'captureScreenshot', + new CaptureScreenshotAction(), + ); + + this.actionRegistry.register('scrollDown', new ScrollDownAction()); + this.actionRegistry.register('scrollUp', new ScrollUpAction()); + + this.actionRegistry.register( + 'executeJavaScript', + new ExecuteJavaScriptAction(), + ); + this.actionRegistry.register('sendKeys', new SendKeysAction()); + this.actionRegistry.register( + 'getPageLoadStatus', + new GetPageLoadStatusAction(), + ); + this.actionRegistry.register('getSnapshot', new GetSnapshotAction()); + this.actionRegistry.register( + 'getAccessibilityTree', + new GetAccessibilityTreeAction(), + ); + this.actionRegistry.register( + 'clickCoordinates', + new ClickCoordinatesAction(), + ); + this.actionRegistry.register( + 'typeAtCoordinates', + new TypeAtCoordinatesAction(), + ); + + const actions = this.actionRegistry.getAvailableActions(); + logger.info( + `Registered ${actions.length} action(s): ${actions.join(', ')}`, + ); + } + + private setupWebSocketHandlers(): void { + this.wsClient.onMessage((message: ProtocolResponse) => { + this.handleIncomingMessage(message); + }); + + this.wsClient.onStatusChange((status: ConnectionStatus) => { + this.handleStatusChange(status); + }); + } + + private handleIncomingMessage(message: ProtocolResponse): void { + const rawMessage = message as any; + + if (rawMessage.action) { + this.processRequest(rawMessage).catch(error => { + logger.error( + `Unhandled error processing request ${rawMessage.id}: ${error}`, + ); + }); + } else if (rawMessage.ok !== undefined) { + logger.info( + `Received server message: ${rawMessage.id} - ${rawMessage.ok ? 'success' : 'error'}`, + ); + if (rawMessage.data) { + logger.debug(`Server data: ${JSON.stringify(rawMessage.data)}`); + } + } else { + logger.warn( + `Received unknown message format: ${JSON.stringify(rawMessage)}`, + ); + } + } + + private async processRequest(request: unknown): Promise { + let validatedRequest: ProtocolRequest; + let requestId: string | undefined; + + try { + validatedRequest = this.requestValidator.validate(request); + requestId = validatedRequest.id; + + this.requestTracker.start(validatedRequest.id, validatedRequest.action); + + await this.concurrencyLimiter.execute(async () => { + this.requestTracker.markExecuting(validatedRequest.id); + await this.executeAction(validatedRequest); + }); + + this.requestTracker.complete(validatedRequest.id); + this.requestValidator.markComplete(validatedRequest.id); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logger.error(`Request processing failed: ${errorMessage}`); + + if (requestId) { + this.requestTracker.complete(requestId, errorMessage); + this.requestValidator.markComplete(requestId); + + this.sendResponse({ + id: requestId, + ok: false, + error: errorMessage, + }); + } + } + } + + private async executeAction(request: ProtocolRequest): Promise { + logger.info(`Executing action: ${request.action} [${request.id}]`); + + const actionResponse = await this.actionRegistry.dispatch( + request.action, + request.payload, + ); + + this.sendResponse({ + id: request.id, + ok: actionResponse.ok, + data: actionResponse.data, + error: actionResponse.error, + }); + + const status = actionResponse.ok ? 'succeeded' : 'failed'; + logger.info(`Action ${status}: ${request.action} [${request.id}]`); + } + + private sendResponse(response: ProtocolResponse): void { + try { + if (this.wsClient.isConnected()) { + this.wsClient.send(response); + } else { + logger.warn(`Not connected. Queueing response: ${response.id}`); + this.responseQueue.enqueue(response); + } + } catch (error) { + logger.error(`Failed to send response ${response.id}: ${error}`); + this.responseQueue.enqueue(response); + } + } + + private handleStatusChange(status: ConnectionStatus): void { + logger.info(`Connection status changed: ${status}`); + + if (status === ConnectionStatus.CONNECTED) { + if (!this.responseQueue.isEmpty()) { + logger.info( + `Flushing ${this.responseQueue.size()} queued responses...`, + ); + this.responseQueue.flush(response => { + this.wsClient.send(response); + }); + } + } + } +} diff --git a/packages/controller-ext/src/background/index.ts b/packages/controller-ext/src/background/index.ts index 2796d02..88f21af 100644 --- a/packages/controller-ext/src/background/index.ts +++ b/packages/controller-ext/src/background/index.ts @@ -3,376 +3,162 @@ * Copyright 2025 BrowserOS * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {ActionRegistry} from '@/actions/ActionRegistry'; -import {CreateBookmarkAction} from '@/actions/bookmark/CreateBookmarkAction'; -import {GetBookmarksAction} from '@/actions/bookmark/GetBookmarksAction'; -import {RemoveBookmarkAction} from '@/actions/bookmark/RemoveBookmarkAction'; -import {CaptureScreenshotAction} from '@/actions/browser/CaptureScreenshotAction'; -import {ClearAction} from '@/actions/browser/ClearAction'; -import {ClickAction} from '@/actions/browser/ClickAction'; -import {ClickCoordinatesAction} from '@/actions/browser/ClickCoordinatesAction'; -import {ExecuteJavaScriptAction} from '@/actions/browser/ExecuteJavaScriptAction'; -import {GetAccessibilityTreeAction} from '@/actions/browser/GetAccessibilityTreeAction'; -import {GetInteractiveSnapshotAction} from '@/actions/browser/GetInteractiveSnapshotAction'; -import {GetPageLoadStatusAction} from '@/actions/browser/GetPageLoadStatusAction'; -import {GetSnapshotAction} from '@/actions/browser/GetSnapshotAction'; -import {InputTextAction} from '@/actions/browser/InputTextAction'; -import {ScrollDownAction} from '@/actions/browser/ScrollDownAction'; -import {ScrollToNodeAction} from '@/actions/browser/ScrollToNodeAction'; -import {ScrollUpAction} from '@/actions/browser/ScrollUpAction'; -import {SendKeysAction} from '@/actions/browser/SendKeysAction'; -import {TypeAtCoordinatesAction} from '@/actions/browser/TypeAtCoordinatesAction'; -import {CheckBrowserOSAction} from '@/actions/diagnostics/CheckBrowserOSAction'; -import {GetRecentHistoryAction} from '@/actions/history/GetRecentHistoryAction'; -import {SearchHistoryAction} from '@/actions/history/SearchHistoryAction'; -import {CloseTabAction} from '@/actions/tab/CloseTabAction'; -import {GetActiveTabAction} from '@/actions/tab/GetActiveTabAction'; -import {GetTabsAction} from '@/actions/tab/GetTabsAction'; -import {NavigateAction} from '@/actions/tab/NavigateAction'; -import {OpenTabAction} from '@/actions/tab/OpenTabAction'; -import {SwitchTabAction} from '@/actions/tab/SwitchTabAction'; -import {CONCURRENCY_CONFIG} from '@/config/constants'; -import type {ProtocolRequest, ProtocolResponse} from '@/protocol/types'; -import {ConnectionStatus} from '@/protocol/types'; -import {ConcurrencyLimiter} from '@/utils/ConcurrencyLimiter'; import {getWebSocketPort} from '@/utils/ConfigHelper'; import {KeepAlive} from '@/utils/KeepAlive'; import {logger} from '@/utils/Logger'; -import {RequestTracker} from '@/utils/RequestTracker'; -import {RequestValidator} from '@/utils/RequestValidator'; -import {ResponseQueue} from '@/utils/ResponseQueue'; -import {WebSocketClient} from '@/websocket/WebSocketClient'; -/** - * BrowserOS Controller - * - * Main controller class that orchestrates all components. - * Message flow: WebSocket → Validator → Tracker → Limiter → Action → Response/Queue → WebSocket - */ -class BrowserOSController { - private wsClient: WebSocketClient; - private requestTracker: RequestTracker; - private concurrencyLimiter: ConcurrencyLimiter; - private requestValidator: RequestValidator; - private responseQueue: ResponseQueue; - private actionRegistry: ActionRegistry; - - constructor(port: number) { - logger.info('Initializing BrowserOS Controller...'); - - // Initialize all components - this.requestTracker = new RequestTracker(); - this.concurrencyLimiter = new ConcurrencyLimiter( - CONCURRENCY_CONFIG.maxConcurrent, - CONCURRENCY_CONFIG.maxQueueSize, - ); - this.requestValidator = new RequestValidator(); - this.responseQueue = new ResponseQueue(); - this.wsClient = new WebSocketClient(port); - this.actionRegistry = new ActionRegistry(); - - // Register actions - this._registerActions(); - - // Wire up event handlers - this._setupWebSocketHandlers(); - } - - private _registerActions(): void { - logger.info('Registering actions...'); - - // Diagnostic actions - this.actionRegistry.register('checkBrowserOS', new CheckBrowserOSAction()); - - // Tab actions - this.actionRegistry.register('getActiveTab', new GetActiveTabAction()); - this.actionRegistry.register('getTabs', new GetTabsAction()); - this.actionRegistry.register('openTab', new OpenTabAction()); - this.actionRegistry.register('closeTab', new CloseTabAction()); - this.actionRegistry.register('switchTab', new SwitchTabAction()); - this.actionRegistry.register('navigate', new NavigateAction()); - - // Bookmark actions - this.actionRegistry.register('getBookmarks', new GetBookmarksAction()); - this.actionRegistry.register('createBookmark', new CreateBookmarkAction()); - this.actionRegistry.register('removeBookmark', new RemoveBookmarkAction()); - - // History actions - this.actionRegistry.register('searchHistory', new SearchHistoryAction()); - this.actionRegistry.register( - 'getRecentHistory', - new GetRecentHistoryAction(), - ); - - // Browser actions - Interactive Elements (NEW!) - this.actionRegistry.register( - 'getInteractiveSnapshot', - new GetInteractiveSnapshotAction(), - ); - this.actionRegistry.register('click', new ClickAction()); - this.actionRegistry.register('inputText', new InputTextAction()); - this.actionRegistry.register('clear', new ClearAction()); - this.actionRegistry.register('scrollToNode', new ScrollToNodeAction()); - - // Browser actions - Visual & Screenshots - this.actionRegistry.register( - 'captureScreenshot', - new CaptureScreenshotAction(), - ); - - // Browser actions - Scrolling - this.actionRegistry.register('scrollDown', new ScrollDownAction()); - this.actionRegistry.register('scrollUp', new ScrollUpAction()); +import {BrowserOSController} from './BrowserOSController'; + +const STATS_LOG_INTERVAL_MS = 30000; + +type ControllerState = { + controller: BrowserOSController | null; + initPromise: Promise | null; + statsTimer: ReturnType | null; +}; + +type BrowserOSGlobals = typeof globalThis & { + __browserosControllerState?: ControllerState; + __browserosController?: BrowserOSController | null; +}; + +const globals = globalThis as BrowserOSGlobals; +const controllerState: ControllerState = + globals.__browserosControllerState ?? + (() => { + const state: ControllerState = { + controller: globals.__browserosController ?? null, + initPromise: null, + statsTimer: null, + }; + globals.__browserosControllerState = state; + return state; + })(); - // Browser actions - Advanced - this.actionRegistry.register( - 'executeJavaScript', - new ExecuteJavaScriptAction(), - ); - this.actionRegistry.register('sendKeys', new SendKeysAction()); - this.actionRegistry.register( - 'getPageLoadStatus', - new GetPageLoadStatusAction(), - ); - this.actionRegistry.register('getSnapshot', new GetSnapshotAction()); - this.actionRegistry.register( - 'getAccessibilityTree', - new GetAccessibilityTreeAction(), - ); - this.actionRegistry.register( - 'clickCoordinates', - new ClickCoordinatesAction(), - ); - this.actionRegistry.register( - 'typeAtCoordinates', - new TypeAtCoordinatesAction(), - ); +function setDebugController(controller: BrowserOSController | null): void { + globals.__browserosController = controller; +} - const actions = this.actionRegistry.getAvailableActions(); - logger.info( - `Registered ${actions.length} action(s): ${actions.join(', ')}`, - ); +function startStatsTimer(): void { + if (controllerState.statsTimer) { + return; } - async start(): Promise { - logger.info('Starting BrowserOS Controller...'); - await this.wsClient.connect(); - } + controllerState.statsTimer = setInterval(() => { + controllerState.controller?.logStats(); + }, STATS_LOG_INTERVAL_MS); +} - stop(): void { - logger.info('Stopping BrowserOS Controller...'); - this.wsClient.disconnect(); - this.requestTracker.destroy(); - this.requestValidator.destroy(); - this.responseQueue.clear(); +function stopStatsTimer(): void { + if (!controllerState.statsTimer) { + return; } - private _setupWebSocketHandlers(): void { - // Handle incoming messages - this.wsClient.onMessage((message: ProtocolResponse) => { - this._handleIncomingMessage(message); - }); + clearInterval(controllerState.statsTimer); + controllerState.statsTimer = null; +} - // Handle connection status changes - this.wsClient.onStatusChange((status: ConnectionStatus) => { - this._handleStatusChange(status); - }); +async function getOrCreateController(): Promise { + if (controllerState.controller) { + return controllerState.controller; } - private _handleIncomingMessage(message: ProtocolResponse): void { - // Check if this is a request (has 'action' field) or a response/notification - const rawMessage = message as any; - - if (rawMessage.action) { - // This is a request from the server - process it - this._processRequest(rawMessage).catch(error => { - logger.error( - `Unhandled error processing request ${rawMessage.id}: ${error}`, - ); - }); - } else if (rawMessage.ok !== undefined) { - // This is a response or notification from the server - just log it - logger.info( - `Received server message: ${rawMessage.id} - ${rawMessage.ok ? 'success' : 'error'}`, - ); - if (rawMessage.data) { - logger.debug(`Server data: ${JSON.stringify(rawMessage.data)}`); + if (!controllerState.initPromise) { + controllerState.initPromise = (async () => { + try { + await KeepAlive.start(); + const port = await getWebSocketPort(); + const controller = new BrowserOSController(port); + await controller.start(); + + controllerState.controller = controller; + setDebugController(controller); + startStatsTimer(); + + return controller; + } catch (error) { + controllerState.controller = null; + setDebugController(null); + stopStatsTimer(); + try { + await KeepAlive.stop(); + } catch { + // ignore + } + throw error; + } finally { + controllerState.initPromise = null; } - } else { - logger.warn( - `Received unknown message format: ${JSON.stringify(rawMessage)}`, - ); - } + })(); } - private async _processRequest(request: unknown): Promise { - let validatedRequest: ProtocolRequest; - let requestId: string | undefined; - - try { - // Step 1: Validate request (checks schema + duplicate IDs) - validatedRequest = this.requestValidator.validate(request); - requestId = validatedRequest.id; - - // Step 2: Start tracking - this.requestTracker.start(validatedRequest.id, validatedRequest.action); - - // Step 3: Execute with concurrency control - await this.concurrencyLimiter.execute(async () => { - this.requestTracker.markExecuting(validatedRequest.id); - await this._executeAction(validatedRequest); - }); - - // Step 4: Mark complete - this.requestTracker.complete(validatedRequest.id); - this.requestValidator.markComplete(validatedRequest.id); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - logger.error(`Request processing failed: ${errorMessage}`); - - if (requestId) { - this.requestTracker.complete(requestId, errorMessage); - this.requestValidator.markComplete(requestId); - - // Send error response - this._sendResponse({ - id: requestId, - ok: false, - error: errorMessage, - }); - } - } + const initPromise = controllerState.initPromise; + if (!initPromise) { + throw new Error('Controller init promise missing'); } + return initPromise; +} - private async _executeAction(request: ProtocolRequest): Promise { - logger.info(`Executing action: ${request.action} [${request.id}]`); - - // Dispatch to action registry - const actionResponse = await this.actionRegistry.dispatch( - request.action, - request.payload, - ); - - // Send response back to server - this._sendResponse({ - id: request.id, - ok: actionResponse.ok, - data: actionResponse.data, - error: actionResponse.error, - }); - - const status = actionResponse.ok ? 'succeeded' : 'failed'; - logger.info(`Action ${status}: ${request.action} [${request.id}]`); - } +async function shutdownController(reason: string): Promise { + logger.info(`[BrowserOS Controller] Shutdown requested: ${reason}`); - private _sendResponse(response: ProtocolResponse): void { + if (controllerState.initPromise) { try { - if (this.wsClient.isConnected()) { - // Send immediately if connected - this.wsClient.send(response); - } else { - // Queue if disconnected - logger.warn(`Not connected. Queueing response: ${response.id}`); - this.responseQueue.enqueue(response); - } - } catch (error) { - logger.error(`Failed to send response ${response.id}: ${error}`); - // Queue on failure - this.responseQueue.enqueue(response); + await controllerState.initPromise; + } catch { + // ignore start errors during shutdown } } - private _handleStatusChange(status: ConnectionStatus): void { - logger.info(`Connection status changed: ${status}`); - - if (status === ConnectionStatus.CONNECTED) { - // Flush queued responses on reconnect - if (!this.responseQueue.isEmpty()) { - logger.info( - `Flushing ${this.responseQueue.size()} queued responses...`, - ); - this.responseQueue.flush(response => { - this.wsClient.send(response); - }); - } + const controller = controllerState.controller; + if (!controller) { + try { + await KeepAlive.stop(); + } catch { + // ignore } + stopStatsTimer(); + setDebugController(null); + return; } - // Diagnostic methods for monitoring - getStats() { - return { - connection: this.wsClient.getStatus(), - requests: this.requestTracker.getStats(), - concurrency: this.concurrencyLimiter.getStats(), - validator: this.requestValidator.getStats(), - responseQueue: { - size: this.responseQueue.size(), - }, - }; - } + controller.stop(); + controllerState.controller = null; + setDebugController(null); + stopStatsTimer(); - logStats(): void { - const stats = this.getStats(); - logger.info('=== Controller Stats ==='); - logger.info(`Connection: ${stats.connection}`); - logger.info(`Requests: ${JSON.stringify(stats.requests)}`); - logger.info(`Concurrency: ${JSON.stringify(stats.concurrency)}`); - logger.info(`Validator: ${JSON.stringify(stats.validator)}`); - logger.info(`Response Queue: ${stats.responseQueue.size} queued`); + try { + await KeepAlive.stop(); + } catch { + // ignore } } -// Global controller instance -let controller: BrowserOSController | null = null; +function ensureControllerRunning(trigger: string): void { + getOrCreateController().catch(error => { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + logger.error( + `[BrowserOS Controller] Failed to start (trigger=${trigger}): ${message}`, + ); + }); +} -// Initialize on extension load logger.info('[BrowserOS Controller] Extension loaded'); chrome.runtime.onInstalled.addListener(() => { logger.info('[BrowserOS Controller] Extension installed'); }); -chrome.runtime.onStartup.addListener(async () => { - logger.info('[BrowserOS Controller] Browser started'); - - await KeepAlive.start(); - - if (!controller) { - const port = await getWebSocketPort(); - controller = new BrowserOSController(port); - await controller.start(); - } +chrome.runtime.onStartup.addListener(() => { + logger.info('[BrowserOS Controller] Browser startup event'); + ensureControllerRunning('runtime.onStartup'); }); -// Start immediately (service worker context) -(async () => { - // Start KeepAlive to prevent service worker from being terminated - await KeepAlive.start(); - - if (!controller) { - const port = await getWebSocketPort(); - controller = new BrowserOSController(port); - await controller.start(); - - // Log stats every 30 seconds - setInterval(() => { - if (controller) { - controller.logStats(); - } - }, 30000); - } -})(); +// Immediately attempt to start the controller when the service worker initializes +ensureControllerRunning('service-worker-init'); -// Cleanup on unload -chrome.runtime.onSuspend?.addListener(async () => { +chrome.runtime.onSuspend?.addListener(() => { logger.info('[BrowserOS Controller] Extension suspending'); - if (controller) { - controller.stop(); - controller = null; - } - await KeepAlive.stop(); + void shutdownController('runtime.onSuspend'); }); - -// Export for debugging in console -(globalThis as any).__browserosController = controller; diff --git a/packages/controller-ext/src/config/constants.ts b/packages/controller-ext/src/config/constants.ts index a993315..342dc67 100644 --- a/packages/controller-ext/src/config/constants.ts +++ b/packages/controller-ext/src/config/constants.ts @@ -12,10 +12,7 @@ export interface WebSocketConfig { readonly host: string; readonly port: number; readonly path: string; - readonly reconnectDelay: number; - readonly maxReconnectDelay: number; - readonly reconnectMultiplier: number; - readonly maxReconnectAttempts: number; + readonly reconnectIntervalMs: number; readonly heartbeatInterval: number; readonly heartbeatTimeout: number; readonly connectionTimeout: number; @@ -39,10 +36,7 @@ export const WEBSOCKET_CONFIG: WebSocketConfig = { port: 9225, path: '/controller', - reconnectDelay: 1000, - maxReconnectDelay: 30000, - reconnectMultiplier: 1.5, - maxReconnectAttempts: Infinity, + reconnectIntervalMs: 30000, heartbeatInterval: 20000, heartbeatTimeout: 5000, diff --git a/packages/controller-ext/src/config/environment.ts b/packages/controller-ext/src/config/environment.ts deleted file mode 100644 index f89ccb9..0000000 --- a/packages/controller-ext/src/config/environment.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @license - * Copyright 2025 BrowserOS - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import {z} from 'zod'; - -/** - * Environment Variable Management - * - * Centralized location for all environment variable access with type safety. - * Environment variables are injected at build time via webpack DefinePlugin. - * All environment variables are validated using Zod schemas. - */ - -/** - * Get environment variable as string with optional default value - */ -function getEnvString(key: string, defaultValue: string): string { - const value = process.env[key]; - return value !== undefined ? value : defaultValue; -} - -/** - * Get environment variable as number with optional default value - */ -function getEnvNumber(key: string, defaultValue: number): number { - const value = process.env[key]; - if (value === undefined) { - return defaultValue; - } - const parsed = parseInt(value, 10); - return isNaN(parsed) ? defaultValue : parsed; -} - -/** - * Get environment variable as boolean with optional default value - */ -function getEnvBoolean(key: string, defaultValue: boolean): boolean { - const value = process.env[key]; - if (value === undefined) { - return defaultValue; - } - return value.toLowerCase() === 'true'; -} - -// Zod Schemas for Environment Configuration - -/** - * WebSocket configuration schema - */ -export const WebSocketConfigSchema = z.object({ - protocol: z.enum(['ws', 'wss']).describe('WebSocket protocol (ws or wss)'), - host: z.string().min(1).describe('WebSocket server host'), - port: z.number().int().min(1).max(65535).describe('WebSocket server port'), - path: z.string().describe('WebSocket server path'), - - // Connection settings - reconnectDelay: z - .number() - .min(0) - .describe('Initial reconnection delay in ms'), - maxReconnectDelay: z - .number() - .min(0) - .describe('Maximum reconnection delay in ms'), - reconnectMultiplier: z - .number() - .min(1) - .describe('Reconnection delay multiplier'), - maxReconnectAttempts: z - .number() - .min(0) - .describe('Max reconnection attempts (0 = infinite)'), - - // Heartbeat settings - heartbeatInterval: z.number().min(0).describe('Heartbeat interval in ms'), - heartbeatTimeout: z.number().min(0).describe('Heartbeat timeout in ms'), - - // Timeout settings - connectionTimeout: z.number().min(0).describe('Connection timeout in ms'), - requestTimeout: z.number().min(0).describe('Request timeout in ms'), -}); - -/** - * Concurrency configuration schema - */ -export const ConcurrencyConfigSchema = z.object({ - maxConcurrent: z - .number() - .int() - .min(1) - .describe('Maximum concurrent requests'), - maxQueueSize: z.number().int().min(1).describe('Maximum queue size'), -}); - -/** - * Logging configuration schema - */ -export const LoggingConfigSchema = z.object({ - enabled: z.boolean().describe('Whether logging is enabled'), - level: z.enum(['debug', 'info', 'warn', 'error']).describe('Logging level'), - prefix: z.string().describe('Logging prefix'), -}); - -/** - * Full environment configuration schema - */ -export const EnvironmentSchema = z.object({ - websocket: WebSocketConfigSchema, - concurrency: ConcurrencyConfigSchema, - logging: LoggingConfigSchema, -}); - -// Type exports -export type WebSocketConfig = z.infer; -export type ConcurrencyConfig = z.infer; -export type LoggingConfig = z.infer; -export type Environment = z.infer; - -/** - * Raw environment configuration object (before validation) - */ -const envRaw = { - // WebSocket Configuration - websocket: { - protocol: getEnvString('WEBSOCKET_PROTOCOL', 'ws'), - host: getEnvString('WEBSOCKET_HOST', 'localhost'), - port: getEnvNumber('WEBSOCKET_PORT', 9224), - path: getEnvString('WEBSOCKET_PATH', '/controller'), - - // Connection settings - reconnectDelay: getEnvNumber('WEBSOCKET_RECONNECT_DELAY', 1000), - maxReconnectDelay: getEnvNumber('WEBSOCKET_MAX_RECONNECT_DELAY', 30000), - reconnectMultiplier: parseFloat( - getEnvString('WEBSOCKET_RECONNECT_MULTIPLIER', '1.5'), - ), - maxReconnectAttempts: getEnvNumber('WEBSOCKET_MAX_RECONNECT_ATTEMPTS', 0), - - // Heartbeat settings - heartbeatInterval: getEnvNumber('WEBSOCKET_HEARTBEAT_INTERVAL', 30000), - heartbeatTimeout: getEnvNumber('WEBSOCKET_HEARTBEAT_TIMEOUT', 5000), - - // Timeout settings - connectionTimeout: getEnvNumber('WEBSOCKET_CONNECTION_TIMEOUT', 10000), - requestTimeout: getEnvNumber('WEBSOCKET_REQUEST_TIMEOUT', 30000), - }, - - // Concurrency Configuration - concurrency: { - maxConcurrent: getEnvNumber('CONCURRENCY_MAX_CONCURRENT', 100), - maxQueueSize: getEnvNumber('CONCURRENCY_MAX_QUEUE_SIZE', 1000), - }, - - // Logging Configuration - logging: { - enabled: getEnvBoolean('LOGGING_ENABLED', true), - level: getEnvString('LOGGING_LEVEL', 'info') as - | 'debug' - | 'info' - | 'warn' - | 'error', - prefix: getEnvString('LOGGING_PREFIX', '[BrowserOS Controller]'), - }, -}; - -/** - * Validated environment configuration object - * Parsed and validated using Zod schemas - */ -export const env = EnvironmentSchema.parse(envRaw); - -/** - * Validate environment configuration - * Called at startup to ensure all required environment variables are set correctly - * - * @returns Validation result with success flag and any error messages - */ -export function validateEnvironment(): {valid: boolean; errors: string[]} { - try { - EnvironmentSchema.parse(envRaw); - return {valid: true, errors: []}; - } catch (error) { - if (error instanceof z.ZodError) { - const errors = error.issues.map(issue => { - const path = issue.path.join('.'); - return `${path}: ${issue.message}`; - }); - return {valid: false, errors}; - } - return { - valid: false, - errors: [error instanceof Error ? error.message : String(error)], - }; - } -} diff --git a/packages/controller-ext/src/websocket/WebSocketClient.ts b/packages/controller-ext/src/websocket/WebSocketClient.ts index 50af4c0..d27bbb6 100644 --- a/packages/controller-ext/src/websocket/WebSocketClient.ts +++ b/packages/controller-ext/src/websocket/WebSocketClient.ts @@ -11,7 +11,6 @@ import {logger} from '@/utils/Logger'; export class WebSocketClient { private ws: WebSocket | null = null; private status: ConnectionStatus = ConnectionStatus.DISCONNECTED; - private reconnectAttempts = 0; private reconnectTimer: ReturnType | null = null; private heartbeatTimer: ReturnType | null = null; private heartbeatTimeoutTimer: ReturnType | null = null; @@ -130,7 +129,6 @@ export class WebSocketClient { private _handleOpen(): void { logger.info('WebSocket connected'); - this.reconnectAttempts = 0; this.lastPongReceived = Date.now(); this.pendingPing = false; this._setStatus(ConnectionStatus.CONNECTED); @@ -188,21 +186,8 @@ export class WebSocketClient { this._setStatus(ConnectionStatus.RECONNECTING); - // Calculate delay with exponential backoff - const baseDelay = Math.min( - WEBSOCKET_CONFIG.reconnectDelay * - Math.pow(WEBSOCKET_CONFIG.reconnectMultiplier, this.reconnectAttempts), - WEBSOCKET_CONFIG.maxReconnectDelay, - ); - - // Add jitter: ±20% random variation to prevent thundering herd - const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1); - const delay = Math.max(0, baseDelay + jitter); - - this.reconnectAttempts++; - logger.warn( - `Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`, - ); + const delay = WEBSOCKET_CONFIG.reconnectIntervalMs; + logger.warn(`Reconnecting in ${Math.round(delay)}ms`); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; diff --git a/packages/controller-server/src/ControllerBridge.ts b/packages/controller-server/src/ControllerBridge.ts index ef97123..d71df92 100644 --- a/packages/controller-server/src/ControllerBridge.ts +++ b/packages/controller-server/src/ControllerBridge.ts @@ -27,8 +27,8 @@ interface PendingRequest { export class ControllerBridge { private wss: WebSocketServer; - private client: WebSocket | null = null; - private connected = false; + private clients = new Map(); + private primaryClientId: string | null = null; private requestCounter = 0; private pendingRequests = new Map(); private logger: typeof logger; @@ -46,9 +46,8 @@ export class ControllerBridge { }); this.wss.on('connection', (ws: WebSocket) => { - this.logger.info('Extension connected'); - this.client = ws; - this.connected = true; + const clientId = this.registerClient(ws); + this.logger.info('Extension connected', {clientId}); ws.on('message', (data: Buffer) => { try { @@ -57,35 +56,28 @@ export class ControllerBridge { // Handle ping/pong for heartbeat if (parsed.type === 'ping') { - this.logger.debug('Received ping, sending pong'); + this.logger.debug('Received ping, sending pong', {clientId}); ws.send(JSON.stringify({type: 'pong'})); return; } this.logger.debug( - `Received message: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`, + `Received message from ${clientId}: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`, ); const response = parsed as ControllerResponse; this.handleResponse(response); } catch (error) { - this.logger.error(`Error parsing message: ${error}`); + this.logger.error(`Error parsing message from ${clientId}: ${error}`); } }); ws.on('close', () => { - this.logger.info('Extension disconnected'); - this.connected = false; - this.client = null; - - for (const [id, pending] of this.pendingRequests.entries()) { - clearTimeout(pending.timeout); - pending.reject(new Error('Connection closed')); - this.pendingRequests.delete(id); - } + this.logger.info('Extension disconnected', {clientId}); + this.handleClientDisconnect(clientId); }); ws.on('error', (error: Error) => { - this.logger.error(`WebSocket error: ${error.message}`); + this.logger.error(`WebSocket error for ${clientId}: ${error.message}`); }); }); @@ -95,7 +87,7 @@ export class ControllerBridge { } isConnected(): boolean { - return this.connected && this.client !== null; + return this.primaryClientId !== null; } async sendRequest( @@ -107,6 +99,11 @@ export class ControllerBridge { throw new Error('Extension not connected'); } + const client = this.getPrimaryClient(); + if (!client) { + throw new Error('Extension not connected'); + } + const id = `${Date.now()}-${++this.requestCounter}`; return new Promise((resolve, reject) => { @@ -120,8 +117,8 @@ export class ControllerBridge { const request: ControllerRequest = {id, action, payload}; try { const message = JSON.stringify(request); - this.logger.debug(`Sending request: ${message}`); - this.client!.send(message); + this.logger.debug(`Sending request to ${this.primaryClientId}: ${message}`); + client.send(message); } catch (error) { clearTimeout(timeout); this.pendingRequests.delete(id); @@ -152,10 +149,21 @@ export class ControllerBridge { async close(): Promise { return new Promise(resolve => { - if (this.client) { - this.client.close(); - this.client = null; + for (const [id, pending] of this.pendingRequests.entries()) { + clearTimeout(pending.timeout); + pending.reject(new Error('ControllerBridge closing')); + this.pendingRequests.delete(id); + } + + for (const ws of this.clients.values()) { + try { + ws.close(); + } catch { + // ignore + } } + this.clients.clear(); + this.primaryClientId = null; this.wss.close(() => { this.logger.info('WebSocket server closed'); @@ -163,4 +171,53 @@ export class ControllerBridge { }); }); } + + private registerClient(ws: WebSocket): string { + const clientId = `client-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; + this.clients.set(clientId, ws); + + if (!this.primaryClientId) { + this.primaryClientId = clientId; + this.logger.info('Primary controller assigned', {clientId}); + } else { + this.logger.info('Controller connected in standby mode', {clientId, primaryClientId: this.primaryClientId}); + } + + return clientId; + } + + private getPrimaryClient(): WebSocket | null { + if (!this.primaryClientId) { + return null; + } + return this.clients.get(this.primaryClientId) ?? null; + } + + private handleClientDisconnect(clientId: string): void { + const wasPrimary = this.primaryClientId === clientId; + this.clients.delete(clientId); + + if (wasPrimary) { + this.primaryClientId = null; + + for (const [id, pending] of this.pendingRequests.entries()) { + clearTimeout(pending.timeout); + pending.reject(new Error('Primary connection closed')); + this.pendingRequests.delete(id); + } + + this.promoteNextPrimary(); + } + } + + private promoteNextPrimary(): void { + const nextEntry = this.clients.keys().next(); + if (nextEntry.done) { + this.logger.warn('No controller connections available to promote'); + return; + } + + this.primaryClientId = nextEntry.value; + this.logger.info('Promoted controller to primary', {clientId: this.primaryClientId}); + } }