diff --git a/apps/server/src/browser/backends/cdp.ts b/apps/server/src/browser/backends/cdp.ts index 9da036cf..54187d62 100644 --- a/apps/server/src/browser/backends/cdp.ts +++ b/apps/server/src/browser/backends/cdp.ts @@ -4,6 +4,10 @@ import { type RawSend, } from '@browseros/cdp-protocol/create-api' import type { ProtocolApi } from '@browseros/cdp-protocol/protocol-api' +import { EXIT_CODES } from '@browseros/shared/constants/exit-codes' +import { CDP_LIMITS } from '@browseros/shared/constants/limits' +import { TIMEOUTS } from '@browseros/shared/constants/timeouts' +import { logger } from '../../lib/logger' import type { CdpTarget, CdpBackend as ICdpBackend } from './types' interface PendingRequest { @@ -20,6 +24,8 @@ class CdpBackend implements ICdpBackend { private messageId = 0 private pending = new Map() private connected = false + private disconnecting = false + private reconnecting = false private eventHandlers = new Map void)[]>() private sessionCache = new Map() @@ -32,37 +38,114 @@ class CdpBackend implements ICdpBackend { } async connect(): Promise { - const versionResponse = await fetch( - `http://localhost:${this.port}/json/version`, - ) - const version = (await versionResponse.json()) as { - webSocketDebuggerUrl: string + const maxRetries = CDP_LIMITS.CONNECT_MAX_RETRIES + const retryDelay = TIMEOUTS.CDP_CONNECT_RETRY_DELAY + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + await this.attemptConnect() + return + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + if (attempt < maxRetries) { + logger.warn( + `CDP connection attempt ${attempt}/${maxRetries} failed: ${msg}. Retrying in ${retryDelay}ms...`, + ) + await Bun.sleep(retryDelay) + } else { + throw new Error( + `CDP connection failed after ${maxRetries} attempts: ${msg}`, + ) + } + } } - const wsUrl = version.webSocketDebuggerUrl + } + private attemptConnect(): Promise { return new Promise((resolve, reject) => { - this.ws = new WebSocket(wsUrl) + fetch(`http://localhost:${this.port}/json/version`) + .then((res) => res.json()) + .then((version) => { + const wsUrl = (version as { webSocketDebuggerUrl: string }) + .webSocketDebuggerUrl + let opened = false + const ws = new WebSocket(wsUrl) - this.ws.onopen = () => { - this.connected = true - resolve() - } + ws.onopen = () => { + opened = true + this.ws = ws + this.connected = true + this.disconnecting = false + resolve() + } - this.ws.onerror = (event) => { - reject(new Error(`CDP WebSocket error: ${event}`)) - } + ws.onerror = (event) => { + if (!opened) reject(new Error(`CDP WebSocket error: ${event}`)) + } - this.ws.onclose = () => { - this.connected = false - } + ws.onclose = () => { + this.connected = false + this.ws = null + if (opened) this.handleUnexpectedClose() + } - this.ws.onmessage = (event) => { - this.handleMessage(event.data as string) - } + ws.onmessage = (event) => { + this.handleMessage(event.data as string) + } + }) + .catch(reject) + }) + } + + private handleUnexpectedClose(): void { + if (this.disconnecting || this.reconnecting) return + + this.rejectPendingRequests() + + logger.error( + 'CDP WebSocket closed unexpectedly, attempting reconnection...', + ) + this.reconnecting = true + this.reconnectOrCrash().finally(() => { + this.reconnecting = false }) } + private rejectPendingRequests(): void { + const error = new Error('CDP connection lost') + for (const request of this.pending.values()) { + request.reject(error) + } + this.pending.clear() + } + + private async reconnectOrCrash(): Promise { + const maxRetries = CDP_LIMITS.CONNECT_MAX_RETRIES + const retryDelay = TIMEOUTS.CDP_CONNECT_RETRY_DELAY + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.info(`CDP reconnection attempt ${attempt}/${maxRetries}...`) + await Bun.sleep(retryDelay) + await this.attemptConnect() + logger.info('CDP reconnected successfully') + return + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + logger.warn( + `CDP reconnection attempt ${attempt}/${maxRetries} failed: ${msg}`, + ) + } + } + + logger.error( + `CDP reconnection failed after ${maxRetries} attempts, exiting for restart`, + ) + process.exit(EXIT_CODES.GENERAL_ERROR) + } + async disconnect(): Promise { + this.disconnecting = true if (this.ws) { this.ws.close() this.ws = null diff --git a/packages/shared/src/constants/limits.ts b/packages/shared/src/constants/limits.ts index 2d0aae5e..c3cbd771 100644 --- a/packages/shared/src/constants/limits.ts +++ b/packages/shared/src/constants/limits.ts @@ -25,6 +25,10 @@ export const PAGINATION = { DEFAULT_PAGE_SIZE: 20, } as const +export const CDP_LIMITS = { + CONNECT_MAX_RETRIES: 3, +} as const + export const CONTENT_LIMITS = { BODY_CONTEXT_SIZE: 10_000, MAX_QUEUE_SIZE: 1_000, diff --git a/packages/shared/src/constants/timeouts.ts b/packages/shared/src/constants/timeouts.ts index 0da1f7ed..3d16060f 100644 --- a/packages/shared/src/constants/timeouts.ts +++ b/packages/shared/src/constants/timeouts.ts @@ -21,6 +21,7 @@ export const TIMEOUTS = { // CDP connection CDP_CONNECT: 10_000, + CDP_CONNECT_RETRY_DELAY: 1_000, // External API calls KLAVIS_FETCH: 30_000,