/** * The main function group which determines the flow of remote browser management. * Holds the singleton instances of browser pool and socket.io server. */ import { Socket } from "socket.io"; import { v4 as uuid } from "uuid"; import { Page } from "playwright-core"; import { createSocketConnection, createSocketConnectionForRun } from "../socket-connection/connection"; import { io, browserPool } from "../server"; import { RemoteBrowser } from "./classes/RemoteBrowser"; import { RemoteBrowserOptions } from "../types"; import logger from "../logger"; /** * Starts and initializes a {@link RemoteBrowser} instance. * Creates a new socket connection over a dedicated namespace * and registers all interaction event handlers. * Returns the id of an active browser or the new remote browser's generated id. * @param options {@link RemoteBrowserOptions} to be used when launching the browser * @returns string * @category BrowserManagement-Controller */ export const initializeRemoteBrowserForRecording = (userId: string, mode: string = "dom"): string => { const id = getActiveBrowserIdByState(userId, "recording") || uuid(); createSocketConnection( io.of(id), userId, async (socket: Socket) => { // browser is already active const activeId = getActiveBrowserIdByState(userId, "recording"); if (activeId) { const remoteBrowser = browserPool.getRemoteBrowser(activeId); remoteBrowser?.updateSocket(socket); } else { const browserSession = new RemoteBrowser(socket, userId, id, true); browserSession.interpreter.subscribeToPausing(); try { await browserSession.initialize(userId); await browserSession.registerEditorEvents(); logger.info('DOM streaming started for remote browser in recording mode'); browserPool.addRemoteBrowser(id, browserSession, userId, false, "recording"); } catch (initError: any) { logger.error(`Failed to initialize browser for recording: ${initError.message}`); logger.info('Sending browser failure notification to frontend'); socket.emit('dom-mode-error', { userId: userId, error: 'Failed to start the browser, please try again in some time.' }); socket.emit('error', { userId: userId, message: 'Failed to start the browser, please try again in some time.', details: initError.message }); await new Promise(resolve => setTimeout(resolve, 100)); try { await browserSession.switchOff(); logger.debug('Cleaned up failed browser session'); } catch (cleanupError: any) { logger.warn(`Failed to cleanup browser session: ${cleanupError.message}`); } logger.info('Browser initialization failed, user notified'); return id; } } socket.emit('loaded'); }); return id; }; /** * Starts and initializes a {@link RemoteBrowser} instance for interpretation. * Creates a new {@link Socket} connection over a dedicated namespace. * Returns the new remote browser's generated id. * @param userId User ID for browser ownership * @returns string Browser ID * @category BrowserManagement-Controller */ export const createRemoteBrowserForRun = (userId: string): string => { if (!userId) { logger.log('error', 'createRemoteBrowserForRun: Missing required parameter userId'); throw new Error('userId is required'); } const id = uuid(); const slotReserved = browserPool.reserveBrowserSlotAtomic(id, userId, "run"); if (!slotReserved) { logger.log('warn', `Cannot create browser for user ${userId}: no available slots`); throw new Error('User has reached maximum browser limit'); } logger.log('info', `createRemoteBrowserForRun: Reserved slot ${id} for user ${userId}`); initializeBrowserAsync(id, userId) .catch((error: any) => { logger.log('error', `Unhandled error in initializeBrowserAsync for browser ${id}: ${error.message}`); browserPool.failBrowserSlot(id); }); return id; }; /** * Terminates a remote browser recording session * and removes the browser from the browser pool. * @param id instance id of the remote browser to be terminated * @returns {Promise} * @category BrowserManagement-Controller */ export const destroyRemoteBrowser = async (id: string, userId: string): Promise => { const DESTROY_TIMEOUT = 30000; const destroyPromise = (async () => { try { const browserSession = browserPool.getRemoteBrowser(id); if (!browserSession) { logger.log('info', `Browser with id: ${id} not found, may have already been destroyed`); return true; } logger.log('debug', `Switching off the browser with id: ${id}`); try { await browserSession.switchOff(); } catch (switchOffError) { logger.log('warn', `Error switching off browser ${id}: ${switchOffError}`); } try { const namespace = io.of(id); const sockets = await namespace.fetchSockets(); for (const socket of sockets) { socket.disconnect(true); } namespace.removeAllListeners(); await new Promise(resolve => setTimeout(resolve, 100)); const nsps = (io as any)._nsps; if (nsps && nsps.has(`/${id}`)) { const ns = nsps.get(`/${id}`); if (ns && ns.sockets && ns.sockets.size === 0) { nsps.delete(`/${id}`); logger.log('debug', `Deleted empty namespace /${id} from io._nsps Map`); } else { logger.log('warn', `Namespace /${id} still has ${ns?.sockets?.size || 0} sockets, skipping manual deletion`); } } logger.log('debug', `Cleaned up socket namespace for browser ${id}`); } catch (namespaceCleanupError: any) { logger.log('warn', `Error cleaning up socket namespace for browser ${id}: ${namespaceCleanupError.message}`); } return browserPool.deleteRemoteBrowser(id); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.log('error', `Failed to destroy browser ${id}: ${errorMessage}`); try { return browserPool.deleteRemoteBrowser(id); } catch (deleteError) { logger.log('error', `Failed to delete browser ${id} from pool: ${deleteError}`); return false; } } })(); try { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`Browser destruction timed out after ${DESTROY_TIMEOUT}ms`)), DESTROY_TIMEOUT) ); return await Promise.race([destroyPromise, timeoutPromise]); } catch (timeoutError: any) { logger.log('error', `Browser ${id} destruction timeout: ${timeoutError.message} - force removing from pool`); try { return browserPool.deleteRemoteBrowser(id); } catch (deleteError) { logger.log('error', `Failed to force delete browser ${id} after timeout: ${deleteError}`); return false; } } }; /** * Returns the id of an active browser or null. * Wrapper around {@link browserPool.getActiveBrowserId()} function. * @returns {string | null} * @category BrowserManagement-Controller */ export const getActiveBrowserId = (userId: string): string | null => { return browserPool.getActiveBrowserId(userId); }; /** * Returns the id of an active browser with the specified state or null. * @param userId the user ID to find the browser for * @param state the browser state to filter by ("recording" or "run") * @returns {string | null} * @category BrowserManagement-Controller */ export const getActiveBrowserIdByState = (userId: string, state: "recording" | "run"): string | null => { return browserPool.getActiveBrowserId(userId, state); }; /** * Checks if there are available browser slots for a user. * Wrapper around {@link browserPool.hasAvailableBrowserSlots()} function. * If state is provided, also checks that none of their active browsers are in that state. * @param userId the user ID to check browser slots for * @param state optional state to check - if provided, ensures no browser is in this state * @returns {boolean} true if user has available slots (and no browsers in specified state if state is provided) * @category BrowserManagement-Controller */ export const canCreateBrowserInState = (userId: string, state?: "recording" | "run"): boolean => { return browserPool.hasAvailableBrowserSlots(userId, state); }; /** * Returns the url string from a remote browser if exists in the browser pool. * @param id instance id of the remote browser * @returns {string | undefined} * @category BrowserManagement-Controller */ export const getRemoteBrowserCurrentUrl = (id: string, userId: string): string | undefined => { return browserPool.getRemoteBrowser(id)?.getCurrentPage()?.url(); }; /** * Returns the array of tab strings from a remote browser if exists in the browser pool. * @param id instance id of the remote browser * @return {string[] | undefined} * @category BrowserManagement-Controller */ export const getRemoteBrowserCurrentTabs = (id: string, userId: string): string[] | undefined => { return browserPool.getRemoteBrowser(id)?.getCurrentPage()?.context().pages() .map((page) => { const parsedUrl = new URL(page.url()); const host = parsedUrl.hostname.match(/\b(?!www\.)[a-zA-Z0-9]+/g)?.join('.'); if (host) { return host; } return 'new tab'; }); }; /** * Interprets the currently generated workflow in the active browser instance. * If there is no active browser, the function logs an error. * @returns {Promise} * @category BrowserManagement-Controller */ export const interpretWholeWorkflow = async (userId: string) => { const id = getActiveBrowserIdByState(userId, "recording"); if (id) { const browser = browserPool.getRemoteBrowser(id); if (browser) { await browser.interpretCurrentRecording(); } else { logger.log('error', `No active browser with id ${id} found in the browser pool`); } } else { logger.log('error', `Cannot interpret the workflow: bad id ${id}.`); } }; /** * Stops the interpretation of the current workflow in the active browser instance. * If there is no active browser, the function logs an error. * @returns {Promise} * @category BrowserManagement-Controller */ export const stopRunningInterpretation = async (userId: string) => { const id = getActiveBrowserIdByState(userId, "recording"); if (id) { const browserSession = browserPool.getRemoteBrowser(id); await browserSession?.switchOff(); } else { logger.log('error', 'Cannot stop interpretation: No active browser or generator.'); } }; const initializeBrowserAsync = async (id: string, userId: string) => { try { const namespace = io.of(id); let clientConnected = false; let connectionTimeout: NodeJS.Timeout; const waitForConnection = new Promise((resolve) => { namespace.on('connection', (socket: Socket) => { clientConnected = true; clearTimeout(connectionTimeout); logger.log('info', `Frontend connected to browser ${id} via socket ${socket.id}`); resolve(socket); }); connectionTimeout = setTimeout(() => { if (!clientConnected) { logger.log('warn', `No client connected to browser ${id} within timeout, proceeding with dummy socket`); resolve(null); } }, 15000); }); namespace.on('error', (error: any) => { logger.log('error', `Socket namespace error for browser ${id}: ${error.message}`); clearTimeout(connectionTimeout); browserPool.failBrowserSlot(id); }); const connectWithRetry = async (maxRetries: number = 3): Promise => { let retryCount = 0; while (retryCount < maxRetries) { try { const socket = await waitForConnection; if (socket || retryCount === maxRetries - 1) { return socket; } } catch (error: any) { logger.log('warn', `Connection attempt ${retryCount + 1} failed for browser ${id}: ${error.message}`); } retryCount++; if (retryCount < maxRetries) { const delay = Math.pow(2, retryCount) * 1000; logger.log('info', `Retrying connection for browser ${id} in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); } } return null; }; const socket = await connectWithRetry(3); try { let browserSession: RemoteBrowser; if (socket) { logger.log('info', `Using real socket for browser ${id}`); browserSession = new RemoteBrowser(socket, userId, id); } else { logger.log('info', `Using dummy socket for browser ${id}`); const dummySocket = { emit: (event: string, data?: any) => { logger.log('debug', `Browser ${id} dummy socket emitted ${event}:`, data); }, on: () => {}, id: `dummy-${id}`, } as any; browserSession = new RemoteBrowser(dummySocket, userId, id); } logger.log('debug', `Starting browser initialization for ${id}`); try { const BROWSER_INIT_TIMEOUT = 45000; logger.log('info', `Browser initialization starting with ${BROWSER_INIT_TIMEOUT/1000}s timeout`); const initPromise = browserSession.initialize(userId); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Browser initialization timeout')), BROWSER_INIT_TIMEOUT); }); await Promise.race([initPromise, timeoutPromise]); } catch (initError: any) { logger.log('error', `Browser initialization failed for ${id}: ${initError.message}`); try { await browserSession.switchOff(); logger.log('info', `Cleaned up failed browser initialization for ${id}`); } catch (cleanupError: any) { logger.log('error', `Failed to cleanup browser ${id}: ${cleanupError.message}`); } throw initError; } const upgraded = browserPool.upgradeBrowserSlot(id, browserSession); if (!upgraded) { try { await browserSession.switchOff(); } catch (cleanupError: any) { logger.log('error', `Failed to cleanup browser after slot upgrade failure: ${cleanupError.message}`); } throw new Error('Failed to upgrade reserved browser slot'); } await new Promise(resolve => setTimeout(resolve, 500)); if (socket) { socket.emit('ready-for-run'); } else { setTimeout(async () => { try { logger.log('info', `Browser ${id} with dummy socket is ready for execution`); } catch (error: any) { logger.log('error', `Error with dummy socket browser ${id}: ${error.message}`); } }, 100); } logger.log('info', `Browser ${id} successfully initialized for run with ${socket ? 'real' : 'dummy'} socket`); } catch (error: any) { logger.log('error', `Error initializing browser ${id}: ${error.message}`); browserPool.failBrowserSlot(id); if (socket) { socket.emit('error', { message: error.message }); } throw error; } } catch (error: any) { logger.log('error', `Error setting up browser ${id}: ${error.message}`); browserPool.failBrowserSlot(id); throw error; } }; /** * Creates a RemoteBrowser instance specifically for SDK validation * Uses dummy socket and returns browser ID and Page for validation tasks * @param userId User ID for browser ownership * @returns Promise with browser ID and Page instance * @category BrowserManagement-Controller */ export const createRemoteBrowserForValidation = async ( userId: string ): Promise<{ browserId: string; page: Page }> => { const id = uuid(); logger.log('info', `Creating validation browser ${id} for user ${userId}`); try { const dummySocket = { emit: (event: string, data?: any) => { logger.log('debug', `Browser ${id} emitted ${event}`); }, on: () => {}, off: () => {}, id: `validation-${id}`, } as any; const browserSession = new RemoteBrowser(dummySocket, userId, id); const VALIDATION_INIT_TIMEOUT = 45000; const initPromise = browserSession.initialize(userId); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('Validation browser initialization timeout')), VALIDATION_INIT_TIMEOUT); }); await Promise.race([initPromise, timeoutPromise]); const added = browserPool.addRemoteBrowser(id, browserSession, userId, true, 'run'); if (!added) { await browserSession.switchOff(); throw new Error('Failed to add validation browser to pool'); } const page = browserSession.getCurrentPage(); if (!page) { await destroyRemoteBrowser(id, userId); throw new Error('Failed to get page from validation browser'); } logger.log('info', `Browser ${id} initialized successfully`); return { browserId: id, page }; } catch (error: any) { logger.log('error', `Failed to create validation browser ${id}: ${error.message}`); try { await destroyRemoteBrowser(id, userId); } catch (cleanupError) { logger.log('warn', `Failed to cleanup browser ${id}: ${cleanupError}`); } throw error; } };