From 6a605c4fbc62994c109332ef0f5d3f92d3ee7029 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:23:57 +0530 Subject: [PATCH 01/23] feat: unified api call for normal and queued runs --- src/pages/MainPage.tsx | 130 +++++++++++++++++++++++++++++++---------- 1 file changed, 98 insertions(+), 32 deletions(-) diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index 8d1a623c..02dd503c 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { MainMenu } from "../components/dashboard/MainMenu"; import { Stack } from "@mui/material"; @@ -7,13 +7,14 @@ import { Runs } from "../components/run/Runs"; import ProxyForm from '../components/proxy/ProxyForm'; import ApiKey from '../components/api/ApiKey'; import { useGlobalInfoStore } from "../context/globalInfo"; -import { createRunForStoredRecording, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage"; +import { createAndRunRecording, createRunForStoredRecording, CreateRunResponseWithQueue, interpretStoredRecording, notifyAboutAbort, scheduleStoredRecording } from "../api/storage"; import { io, Socket } from "socket.io-client"; import { stopRecording } from "../api/recording"; import { RunSettings } from "../components/run/RunSettings"; import { ScheduleSettings } from "../components/robot/ScheduleSettings"; import { apiUrl } from "../apiConfig"; import { useNavigate } from 'react-router-dom'; +import { AuthContext } from '../context/auth'; interface MainPageProps { handleEditRecording: (id: string, fileName: string) => void; @@ -43,12 +44,16 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) runId: '', robotMetaId: '' }); + const [queuedRuns, setQueuedRuns] = React.useState>(new Set()); let aborted = false; const { notify, setRerenderRuns, setRecordingId } = useGlobalInfoStore(); const navigate = useNavigate(); + const { state } = useContext(AuthContext); + const { user } = state; + const abortRunHandler = (runId: string) => { aborted = true; notifyAboutAbort(runId).then(async (response) => { @@ -90,48 +95,109 @@ export const MainPage = ({ handleEditRecording, initialContent }: MainPageProps) }, [currentInterpretationLog]) const handleRunRecording = useCallback((settings: RunSettings) => { - createRunForStoredRecording(runningRecordingId, settings).then(({ browserId, runId, robotMetaId }: CreateRunResponse) => { + createAndRunRecording(runningRecordingId, settings).then((response: CreateRunResponseWithQueue) => { + const { browserId, runId, robotMetaId, queued } = response; + setIds({ browserId, runId, robotMetaId }); navigate(`/runs/${robotMetaId}/run/${runId}`); - const socket = - io(`${apiUrl}/${browserId}`, { + + if (queued) { + console.log('Creating queue socket for queued run:', runId); + + setQueuedRuns(prev => new Set([...prev, runId])); + + const queueSocket = io(`${apiUrl}/queued-run`, { + transports: ["websocket"], + rejectUnauthorized: false, + query: { userId: user?.id } + }); + + queueSocket.on('connect', () => { + console.log('Queue socket connected for user:', user?.id); + }); + + queueSocket.on('connect_error', (error) => { + console.log('Queue socket connection error:', error); + }); + + queueSocket.on('run-completed', (completionData) => { + if (completionData.runId === runId) { + setRunningRecordingName(''); + setCurrentInterpretationLog(''); + setRerenderRuns(true); + + setQueuedRuns(prev => { + const newSet = new Set(prev); + newSet.delete(runId); + return newSet; + }); + + const robotName = completionData.robotName || runningRecordingName; + + if (completionData.status === 'success') { + notify('success', t('main_page.notifications.interpretation_success', { name: robotName })); + } else { + notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); + } + + queueSocket.disconnect(); + } + }); + + setSockets(sockets => [...sockets, queueSocket]); + + notify('info', `Run queued: ${runningRecordingName}`); + } else { + const socket = io(`${apiUrl}/${browserId}`, { transports: ["websocket"], rejectUnauthorized: false }); - setSockets(sockets => [...sockets, socket]); - socket.on('ready-for-run', () => readyForRunHandler(browserId, runId)); - socket.on('debugMessage', debugMessageHandler); - socket.on('run-completed', (data) => { - setRerenderRuns(true); - const robotName = data.robotName; + setSockets(sockets => [...sockets, socket]); - if (data.status === 'success') { - notify('success', t('main_page.notifications.interpretation_success', { name: robotName })); + socket.on('debugMessage', debugMessageHandler); + socket.on('run-completed', (data) => { + setRunningRecordingName(''); + setCurrentInterpretationLog(''); + setRerenderRuns(true); + + const robotName = data.robotName; + + if (data.status === 'success') { + notify('success', t('main_page.notifications.interpretation_success', { name: robotName })); + } else { + notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); + } + }); + + socket.on('connect_error', (error) => { + console.log('error', `Failed to connect to browser ${browserId}: ${error}`); + notify('error', t('main_page.notifications.connection_failed', { name: runningRecordingName })); + }); + + socket.on('disconnect', (reason) => { + console.log('warn', `Disconnected from browser ${browserId}: ${reason}`); + }); + + if (runId) { + notify('info', t('main_page.notifications.run_started', { name: runningRecordingName })); } else { - notify('error', t('main_page.notifications.interpretation_failed', { name: robotName })); + notify('error', t('main_page.notifications.run_start_failed', { name: runningRecordingName })); } - }); - - socket.on('run-aborted', (data) => { - setRerenderRuns(true); - - const abortedRobotName = data.robotName; - notify('success', t('main_page.notifications.abort_success', { name: abortedRobotName })); - }); - - setContent('runs'); - if (browserId) { - notify('info', t('main_page.notifications.run_started', { name: runningRecordingName })); - } else { - notify('error', t('main_page.notifications.run_start_failed', { name: runningRecordingName })); } - }) - return (socket: Socket, browserId: string, runId: string) => { - socket.off('ready-for-run', () => readyForRunHandler(browserId, runId)); + + setContent('runs'); + }).catch((error: any) => { + console.error('Error in createAndRunRecording:', error); // ✅ Debug log + }); + + return (socket: Socket) => { socket.off('debugMessage', debugMessageHandler); + socket.off('run-completed'); + socket.off('connect_error'); + socket.off('disconnect'); } - }, [runningRecordingName, sockets, ids, readyForRunHandler, debugMessageHandler]) + }, [runningRecordingName, sockets, ids, debugMessageHandler, user?.id, t, notify, setRerenderRuns, setQueuedRuns, navigate, setContent, setIds]); const handleScheduleRecording = (settings: ScheduleSettings) => { scheduleStoredRecording(runningRecordingId, settings) From 50a7ab3c383c0653c3e93712651bf5b191814031 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:27:58 +0530 Subject: [PATCH 02/23] feat: add create and run api call --- src/api/storage.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/api/storage.ts b/src/api/storage.ts index 295f340c..074e3e2d 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -154,6 +154,27 @@ export const editRecordingFromStorage = async (browserId: string, id: string): P } }; +export interface CreateRunResponseWithQueue extends CreateRunResponse { + queued?: boolean; +} + +export const createAndRunRecording = async (id: string, settings: RunSettings): Promise => { + try { + const response = await axios.put( + `${apiUrl}/robot/runs/${id}`, + { ...settings, withCredentials: true } + ); + if (response.status === 200) { + return response.data; + } else { + throw new Error(`Couldn't create and run recording ${id}`); + } + } catch (error: any) { + console.log(error); + return { browserId: '', runId: '', robotMetaId: '', queued: false }; + } +} + export const createRunForStoredRecording = async (id: string, settings: RunSettings): Promise => { try { const response = await axios.put( From ce9faae050434c9e71fc7c437ce286f7eb7d12be Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:35:12 +0530 Subject: [PATCH 03/23] feat: add has browser slot logic --- .../browser-management/classes/BrowserPool.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/src/browser-management/classes/BrowserPool.ts b/server/src/browser-management/classes/BrowserPool.ts index c1f0f557..e97d9fc3 100644 --- a/server/src/browser-management/classes/BrowserPool.ts +++ b/server/src/browser-management/classes/BrowserPool.ts @@ -506,6 +506,29 @@ export class BrowserPool { return browserIds.length > 0 ? browserIds[0] : null; }; + /** + * Checks if there are available browser slots for a user. + * Returns true if user has available slots AND none of their active browsers are in "recording" state. + * @param userId the user ID to check browser slots for + * @returns {boolean} true if user has available slots and no recording browsers, false otherwise + */ + public hasAvailableBrowserSlots = (userId: string, state?: BrowserState): boolean => { + const userBrowserIds = this.userToBrowserMap.get(userId) || []; + + if (userBrowserIds.length >= 2) { + return false; + } + + if (state === "recording") { + const hasBrowserInState = userBrowserIds.some(browserId => + this.pool[browserId] && this.pool[browserId].state === "recording" + ); + return !hasBrowserInState; + } + + return true; + }; + /** * Returns the first active browser's instance id from the pool. * If there is no active browser, it returns null. From df4b4902cf42c64eec6e63ce1271994105a45f76 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:36:25 +0530 Subject: [PATCH 04/23] feat: add can create browser wrapper --- server/src/browser-management/controller.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/browser-management/controller.ts b/server/src/browser-management/controller.ts index 3da388a0..b020f711 100644 --- a/server/src/browser-management/controller.ts +++ b/server/src/browser-management/controller.ts @@ -135,6 +135,19 @@ export const getActiveBrowserIdByState = (userId: string, state: "recording" | " 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 From 7117c93f7893f681527bc44017a1ff09bd6bbe42 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:42:11 +0530 Subject: [PATCH 05/23] feat: unified create and run robot route --- server/src/routes/storage.ts | 177 +++++++++++++++++++++-------------- 1 file changed, 105 insertions(+), 72 deletions(-) diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index b4e8cdfd..92807d31 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import logger from "../logger"; -import { createRemoteBrowserForRun, getActiveBrowserIdByState } from "../browser-management/controller"; +import { createRemoteBrowserForRun, destroyRemoteBrowser, getActiveBrowserIdByState } from "../browser-management/controller"; import { chromium } from 'playwright-extra'; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; import { browserPool } from "../server"; @@ -517,98 +517,131 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) => return res.status(401).send({ error: 'Unauthorized' }); } - const proxyConfig = await getDecryptedProxyConfig(req.user.id); - let proxyOptions: any = {}; - - if (proxyConfig.proxy_url) { - proxyOptions = { - server: proxyConfig.proxy_url, - ...(proxyConfig.proxy_username && proxyConfig.proxy_password && { - username: proxyConfig.proxy_username, - password: proxyConfig.proxy_password, - }), - }; - } - - console.log(`Proxy config for run: ${JSON.stringify(proxyOptions)}`); - // Generate runId first const runId = uuid(); - // Check if user has reached browser limit - const userBrowserIds = browserPool.getAllBrowserIdsForUser(req.user.id); - const canCreateBrowser = userBrowserIds.length < 2; - - if (canCreateBrowser) { - // User has available browser slots, create it directly - const id = createRemoteBrowserForRun(req.user.id); + const canCreateBrowser = await browserPool.hasAvailableBrowserSlots(req.user.id, "run"); - const run = await Run.create({ - status: 'running', + if (canCreateBrowser) { + let browserId: string; + + try { + browserId = await createRemoteBrowserForRun(req.user.id); + + if (!browserId || browserId.trim() === '') { + throw new Error('Failed to generate valid browser ID'); + } + + logger.log('info', `Created browser ${browserId} for run ${runId}`); + + } catch (browserError: any) { + logger.log('error', `Failed to create browser: ${browserError.message}`); + return res.status(500).send({ error: 'Failed to create browser instance' }); + } + + try { + await Run.create({ + status: 'running', + name: recording.recording_meta.name, + robotId: recording.id, + robotMetaId: recording.recording_meta.id, + startedAt: new Date().toLocaleString(), + finishedAt: '', + browserId: browserId, + interpreterSettings: req.body, + log: '', + runId, + runByUserId: req.user.id, + serializableOutput: {}, + binaryOutput: {}, + }); + + logger.log('info', `Created run ${runId} with browser ${browserId}`); + + } catch (dbError: any) { + logger.log('error', `Database error creating run: ${dbError.message}`); + + try { + await destroyRemoteBrowser(browserId, req.user.id); + } catch (cleanupError: any) { + logger.log('warn', `Failed to cleanup browser after run creation failure: ${cleanupError.message}`); + } + + return res.status(500).send({ error: 'Failed to create run record' }); + } + + try { + const userQueueName = `execute-run-user-${req.user.id}`; + await pgBoss.createQueue(userQueueName); + + const jobId = await pgBoss.send(userQueueName, { + emailId: req.user.email, + userId: req.user.id, + runId: runId, + browserId: browserId, + interpreterSettings: req.body + }); + + logger.log('info', `Queued run execution job with ID: ${jobId} for run: ${runId}`); + } catch (queueError: any) { + logger.log('error', `Failed to queue run execution: ${queueError.message}`); + + try { + await Run.update({ + status: 'failed', + finishedAt: new Date().toLocaleString(), + log: 'Failed to queue execution job' + }, { where: { runId: runId } }); + + await destroyRemoteBrowser(browserId, req.user.id); + } catch (cleanupError: any) { + logger.log('warn', `Failed to cleanup after queue error: ${cleanupError.message}`); + } + + return res.status(503).send({ error: 'Unable to queue run, please try again later' }); + } + + return res.send({ + browserId: browserId, + runId: runId, + robotMetaId: recording.recording_meta.id, + queued: false + }); + } else { + const browserId = uuid(); + + await Run.create({ + status: 'queued', name: recording.recording_meta.name, robotId: recording.id, robotMetaId: recording.recording_meta.id, startedAt: new Date().toLocaleString(), finishedAt: '', - browserId: id, + browserId, interpreterSettings: req.body, - log: '', + log: 'Run queued - waiting for available browser slot', runId, runByUserId: req.user.id, serializableOutput: {}, binaryOutput: {}, }); - - const plainRun = run.toJSON(); - + return res.send({ - browserId: id, - runId: plainRun.runId, + browserId: browserId, + runId: runId, robotMetaId: recording.recording_meta.id, - queued: false + queued: true }); - } else { - const browserId = getActiveBrowserIdByState(req.user.id, "run") - - if (browserId) { - // User has reached the browser limit, queue the run - try { - // Create the run record with 'queued' status - await Run.create({ - status: 'queued', - name: recording.recording_meta.name, - robotId: recording.id, - robotMetaId: recording.recording_meta.id, - startedAt: new Date().toLocaleString(), - finishedAt: '', - browserId: browserId, // Random will be updated later - interpreterSettings: req.body, - log: 'Run queued - waiting for available browser slot', - runId, - runByUserId: req.user.id, - serializableOutput: {}, - binaryOutput: {}, - }); - - return res.send({ - browserId: browserId, - runId: runId, - robotMetaId: recording.recording_meta.id, - queued: true, - }); - } catch (queueError: any) { - logger.log('error', `Failed to queue run job: ${queueError.message}`); - return res.status(503).send({ error: 'Unable to queue run, please try again later' }); - } - } else { - logger.log('info', "Browser id does not exist"); - return res.send(''); - } } } catch (e) { const { message } = e as Error; - logger.log('info', `Error while creating a run with robot id: ${req.params.id} - ${message}`); - return res.send(''); + logger.log('error', `Error while creating a run with robot id: ${req.params.id} - ${message}`); + + if (message.includes('invalid input syntax for type uuid')) { + return res.status(400).send({ error: 'Invalid UUID format detected' }); + } + + return res.status(500).send({ error: 'Internal server error' }); } }); From 50937eaa8d41472d2cb245e613da550b5bb8c38e Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:43:46 +0530 Subject: [PATCH 06/23] feat: modify api endpoint --- src/api/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/storage.ts b/src/api/storage.ts index 074e3e2d..02558c72 100644 --- a/src/api/storage.ts +++ b/src/api/storage.ts @@ -161,7 +161,7 @@ export interface CreateRunResponseWithQueue extends CreateRunResponse { export const createAndRunRecording = async (id: string, settings: RunSettings): Promise => { try { const response = await axios.put( - `${apiUrl}/robot/runs/${id}`, + `${apiUrl}/storage/runs/${id}`, { ...settings, withCredentials: true } ); if (response.status === 200) { From 309a8953aa201999bb7fcdcd4b2d82b94aea8277 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:50:40 +0530 Subject: [PATCH 07/23] feat: add browser slot logic --- .../browser-management/classes/BrowserPool.ts | 88 ++++++++++++++++++- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/server/src/browser-management/classes/BrowserPool.ts b/server/src/browser-management/classes/BrowserPool.ts index e97d9fc3..e6dcf6b8 100644 --- a/server/src/browser-management/classes/BrowserPool.ts +++ b/server/src/browser-management/classes/BrowserPool.ts @@ -14,7 +14,7 @@ interface BrowserPoolInfo { /** * The instance of remote browser. */ - browser: RemoteBrowser, + browser: RemoteBrowser | null, /** * States if the browser's instance is being actively used. * Helps to persist the progress on the frontend when the application has been reloaded. @@ -31,6 +31,11 @@ interface BrowserPoolInfo { * @default "recording" */ state: BrowserState, + /** + * The status of the browser instance. + * Can be "reserved", "initializing", "ready" or "failed". + */ + status?: "reserved" | "initializing" | "ready" | "failed", } /** @@ -205,8 +210,18 @@ export class BrowserPool { * @returns remote browser instance or undefined if it does not exist in the pool */ public getRemoteBrowser = (id: string): RemoteBrowser | undefined => { - logger.log('debug', `Remote browser with id: ${id} retrieved from the pool`); - return this.pool[id]?.browser; + const poolInfo = this.pool[id]; + if (!poolInfo) { + return undefined; + } + + // Return undefined for reserved slots (browser is null) + if (poolInfo.status === "reserved") { + logger.log('debug', `Browser ${id} is reserved but not yet ready`); + return undefined; + } + + return poolInfo.browser || undefined; }; /** @@ -547,4 +562,71 @@ export class BrowserPool { // logger.log('warn', `No active browser in the pool`); return null; }; + + /** + * Reserves a browser slot immediately without creating the actual browser. + * This ensures slot counting is accurate for rapid successive requests. + * + * @param id browser ID to reserve + * @param userId user ID that owns this reservation + * @param state browser state ("recording" or "run") + * @returns true if slot was reserved, false if user has reached limit + */ + public reserveBrowserSlot = (id: string, userId: string, state: BrowserState = "run"): boolean => { + // Check if user has available slots first + if (!this.hasAvailableBrowserSlots(userId, state)) { + logger.log('debug', `Cannot reserve slot for user ${userId}: no available slots`); + return false; + } + + // Reserve the slot with null browser + this.pool[id] = { + browser: null, + active: false, + userId, + state, + status: "reserved" + }; + + // Update the user-to-browser mapping + let userBrowserIds = this.userToBrowserMap.get(userId) || []; + if (!userBrowserIds.includes(id)) { + userBrowserIds.push(id); + this.userToBrowserMap.set(userId, userBrowserIds); + } + + logger.log('info', `Reserved browser slot ${id} for user ${userId} in state ${state}`); + return true; + }; + + /** + * Upgrades a reserved slot to an actual browser instance. + * + * @param id browser ID that was previously reserved + * @param browser the actual RemoteBrowser instance + * @returns true if successful, false if slot wasn't reserved + */ + public upgradeBrowserSlot = (id: string, browser: RemoteBrowser): boolean => { + if (!this.pool[id] || this.pool[id].status !== "reserved") { + logger.log('warn', `Cannot upgrade browser ${id}: slot not reserved`); + return false; + } + + this.pool[id].browser = browser; + this.pool[id].status = "ready"; + logger.log('info', `Upgraded browser slot ${id} to ready state`); + return true; + }; + + /** + * Marks a reserved slot as failed and removes it. + * + * @param id browser ID to mark as failed + */ + public failBrowserSlot = (id: string): void => { + if (this.pool[id]) { + logger.log('info', `Marking browser slot ${id} as failed`); + this.deleteRemoteBrowser(id); + } + }; } \ No newline at end of file From 8ff8d0a684a2cdc532900e57b913bf84e0050e22 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:56:58 +0530 Subject: [PATCH 08/23] feat: add fallback dummy socket run logic --- server/src/browser-management/controller.ts | 113 +++++++++++++++++--- 1 file changed, 100 insertions(+), 13 deletions(-) diff --git a/server/src/browser-management/controller.ts b/server/src/browser-management/controller.ts index b020f711..0589b68c 100644 --- a/server/src/browser-management/controller.ts +++ b/server/src/browser-management/controller.ts @@ -54,20 +54,23 @@ export const initializeRemoteBrowserForRecording = (userId: string): string => { * @category BrowserManagement-Controller */ export const createRemoteBrowserForRun = (userId: string): string => { - const id = uuid(); + if (!userId) { + logger.log('error', 'createRemoteBrowserForRun: Missing required parameter userId'); + throw new Error('userId is required'); + } + + const id = uuid(); + + const slotReserved = browserPool.reserveBrowserSlot(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); - createSocketConnectionForRun( - io.of(id), - async (socket: Socket) => { - try { - const browserSession = new RemoteBrowser(socket, userId, id); - await browserSession.initialize(userId); - browserPool.addRemoteBrowser(id, browserSession, userId, false, "run"); - socket.emit('ready-for-run'); - } catch (error: any) { - logger.error(`Error initializing browser: ${error.message}`); - } - }); return id; }; @@ -211,3 +214,87 @@ export const stopRunningInterpretation = async (userId: string) => { 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); + } + }, 10000); + }); + + namespace.on('error', (error: any) => { + logger.log('error', `Socket namespace error for browser ${id}: ${error.message}`); + clearTimeout(connectionTimeout); + browserPool.failBrowserSlot(id); + }); + + const socket = await waitForConnection; + + 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); + } + + await browserSession.initialize(userId); + + const upgraded = browserPool.upgradeBrowserSlot(id, browserSession); + if (!upgraded) { + throw new Error('Failed to upgrade reserved browser slot'); + } + + if (socket) { + socket.emit('ready-for-run'); + } else { + setTimeout(async () => { + try { + logger.log('info', `Starting execution for browser ${id} with dummy socket`); + } catch (error: any) { + logger.log('error', `Error executing run for 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 }); + } + } + + } catch (error: any) { + logger.log('error', `Error setting up browser ${id}: ${error.message}`); + browserPool.failBrowserSlot(id); + } +}; From 659180b23861d226ba7e5f8013d634e3fb59e878 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 18:59:27 +0530 Subject: [PATCH 09/23] feat: rm reset browser state --- server/src/pgboss-worker.ts | 45 ------------------------------------- 1 file changed, 45 deletions(-) diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts index 1a32f79b..cfa7eac0 100644 --- a/server/src/pgboss-worker.ts +++ b/server/src/pgboss-worker.ts @@ -82,41 +82,6 @@ function AddGeneratedFlags(workflow: WorkflowFile) { return copy; }; -/** - * Function to reset browser state without creating a new browser - */ -async function resetBrowserState(browser: RemoteBrowser): Promise { - try { - const currentPage = browser.getCurrentPage(); - if (!currentPage) { - logger.log('error', 'No current page available to reset browser state'); - return false; - } - - // Navigate to blank page to reset state - await currentPage.goto('about:blank', { waitUntil: 'networkidle', timeout: 10000 }); - - // Clear browser storage - await currentPage.evaluate(() => { - try { - localStorage.clear(); - sessionStorage.clear(); - } catch (e) { - // Ignore errors in cleanup - } - }); - - // Clear cookies - const context = currentPage.context(); - await context.clearCookies(); - - return true; - } catch (error) { - logger.log('error', `Failed to reset browser state`); - return false; - } -} - /** * Modified checkAndProcessQueuedRun function - only changes browser reset logic */ @@ -137,13 +102,6 @@ async function checkAndProcessQueuedRun(userId: string, browserId: string): Prom return false; } - // Reset the browser state before next run - const browser = browserPool.getRemoteBrowser(browserId); - if (browser) { - logger.log('info', `Resetting browser state for browser ${browserId} before next run`); - await resetBrowserState(browser); - } - // Update the queued run to running status await queuedRun.update({ status: 'running', @@ -251,9 +209,6 @@ async function processRunExecution(job: Job) { } try { - // Reset the browser state before executing this run - await resetBrowserState(browser); - const isRunAborted = async (): Promise => { const currentRun = await Run.findOne({ where: { runId: data.runId } }); return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false; From b45a18fdcd62ed1664a37782e6f377ed4bfc6453 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:01:46 +0530 Subject: [PATCH 10/23] feat: rm queued run logic --- server/src/pgboss-worker.ts | 107 +++++------------------------------- 1 file changed, 15 insertions(+), 92 deletions(-) diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts index cfa7eac0..8e5da8d1 100644 --- a/server/src/pgboss-worker.ts +++ b/server/src/pgboss-worker.ts @@ -82,52 +82,6 @@ function AddGeneratedFlags(workflow: WorkflowFile) { return copy; }; -/** - * Modified checkAndProcessQueuedRun function - only changes browser reset logic - */ -async function checkAndProcessQueuedRun(userId: string, browserId: string): Promise { - try { - // Find the oldest queued run for this specific browser - const queuedRun = await Run.findOne({ - where: { - browserId: browserId, - runByUserId: userId, - status: 'queued' - }, - order: [['startedAt', 'ASC']] - }); - - if (!queuedRun) { - logger.log('info', `No queued runs found for browser ${browserId}`); - return false; - } - - // Update the queued run to running status - await queuedRun.update({ - status: 'running', - log: 'Run started - using browser from previous run' - }); - - // Use user-specific queue - const userQueueName = `execute-run-user-${userId}`; - - // Schedule the run execution - await pgBoss.createQueue(userQueueName); - const executeJobId = await pgBoss.send(userQueueName, { - userId: userId, - runId: queuedRun.runId, - browserId: browserId - }); - - logger.log('info', `Scheduled queued run ${queuedRun.runId} to use browser ${browserId}, job ID: ${executeJobId}`); - return true; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.log('error', `Error checking for queued runs: ${errorMessage}`); - return false; - } -} - /** * Modified processRunExecution function - only add browser reset */ @@ -189,9 +143,6 @@ async function processRunExecution(job: Job) { } } - // Check for queued runs even if this one failed - await checkAndProcessQueuedRun(data.userId, data.browserId); - return { success: false }; } @@ -202,9 +153,6 @@ async function processRunExecution(job: Job) { if (!browser || !currentPage) { logger.log('error', `Browser or page not available for run ${data.runId}`); - // Even if this run failed, check for queued runs - await checkAndProcessQueuedRun(data.userId, data.browserId); - return { success: false }; } @@ -226,12 +174,7 @@ async function processRunExecution(job: Job) { if (await isRunAborted()) { logger.log('info', `Run ${data.runId} was aborted during execution, not updating status`); - const queuedRunProcessed = await checkAndProcessQueuedRun(data.userId, plainRun.browserId); - - if (!queuedRunProcessed) { - await destroyRemoteBrowser(plainRun.browserId, data.userId); - logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); - } + await destroyRemoteBrowser(plainRun.browserId, data.userId); return { success: true }; } @@ -370,14 +313,9 @@ async function processRunExecution(job: Job) { finishedAt: new Date().toLocaleString() }); - // Check for and process queued runs before destroying the browser - const queuedRunProcessed = await checkAndProcessQueuedRun(data.userId, plainRun.browserId); - // Only destroy the browser if no queued run was found - if (!queuedRunProcessed) { - await destroyRemoteBrowser(plainRun.browserId, data.userId); - logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); - } + await destroyRemoteBrowser(plainRun.browserId, data.userId); + logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); return { success: true }; } catch (executionError: any) { @@ -432,17 +370,11 @@ async function processRunExecution(job: Job) { logger.log('info', `Run ${data.runId} was aborted, not updating status to failed`); } - // Check for queued runs before destroying the browser - const queuedRunProcessed = await checkAndProcessQueuedRun(data.userId, plainRun.browserId); - - // Only destroy the browser if no queued run was found - if (!queuedRunProcessed) { - try { - await destroyRemoteBrowser(plainRun.browserId, data.userId); - logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); - } catch (cleanupError: any) { - logger.log('warn', `Failed to clean up browser for failed run ${data.runId}: ${cleanupError.message}`); - } + try { + await destroyRemoteBrowser(plainRun.browserId, data.userId); + logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); + } catch (cleanupError: any) { + logger.log('warn', `Failed to clean up browser for failed run ${data.runId}: ${cleanupError.message}`); } return { success: false }; @@ -562,23 +494,14 @@ async function abortRun(runId: string, userId: string): Promise { } catch (socketError) { logger.log('warn', `Failed to emit run-aborted event: ${socketError}`); } - - let queuedRunProcessed = false; - try { - queuedRunProcessed = await checkAndProcessQueuedRun(userId, plainRun.browserId); - } catch (queueError) { - logger.log('warn', `Error checking queued runs: ${queueError}`); - } - if (!queuedRunProcessed) { - try { - await new Promise(resolve => setTimeout(resolve, 500)); - - await destroyRemoteBrowser(plainRun.browserId, userId); - logger.log('info', `Browser ${plainRun.browserId} destroyed successfully after abort`); - } catch (cleanupError) { - logger.log('warn', `Failed to clean up browser for aborted run ${runId}: ${cleanupError}`); - } + try { + await new Promise(resolve => setTimeout(resolve, 500)); + + await destroyRemoteBrowser(plainRun.browserId, userId); + logger.log('info', `Browser ${plainRun.browserId} destroyed successfully after abort`); + } catch (cleanupError) { + logger.log('warn', `Failed to clean up browser for aborted run ${runId}: ${cleanupError}`); } return true; From 612fee7196a7c4f89bf1231d18749b41fcdf67bf Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:20:13 +0530 Subject: [PATCH 11/23] feat: add queued runs execution logic --- server/src/routes/storage.ts | 79 ++++++++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/server/src/routes/storage.ts b/server/src/routes/storage.ts index 92807d31..0ebe8492 100644 --- a/server/src/routes/storage.ts +++ b/server/src/routes/storage.ts @@ -575,11 +575,9 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) => await pgBoss.createQueue(userQueueName); const jobId = await pgBoss.send(userQueueName, { - emailId: req.user.email, userId: req.user.id, runId: runId, browserId: browserId, - interpreterSettings: req.body }); logger.log('info', `Queued run execution job with ID: ${jobId} for run: ${runId}`); @@ -635,12 +633,7 @@ router.put('/runs/:id', requireSignIn, async (req: AuthenticatedRequest, res) => } } catch (e) { const { message } = e as Error; - logger.log('error', `Error while creating a run with robot id: ${req.params.id} - ${message}`); - - if (message.includes('invalid input syntax for type uuid')) { - return res.status(400).send({ error: 'Invalid UUID format detected' }); - } - + logger.log('error', `Error while creating a run with robot id: ${req.params.id} - ${message}`); return res.status(500).send({ error: 'Internal server error' }); } }); @@ -952,3 +945,73 @@ router.post('/runs/abort/:id', requireSignIn, async (req: AuthenticatedRequest, return res.send(false); } }); + +async function processQueuedRuns() { + try { + const queuedRun = await Run.findOne({ + where: { status: 'queued' }, + order: [['startedAt', 'ASC']] + }); + + if (!queuedRun) return; + + const userId = queuedRun.runByUserId; + + const canCreateBrowser = await browserPool.hasAvailableBrowserSlots(userId, "run"); + + if (canCreateBrowser) { + logger.log('info', `Processing queued run ${queuedRun.runId} for user ${userId}`); + + const recording = await Robot.findOne({ + where: { + 'recording_meta.id': queuedRun.robotMetaId + }, + raw: true + }); + + if (!recording) { + await queuedRun.update({ + status: 'failed', + finishedAt: new Date().toLocaleString(), + log: 'Recording not found' + }); + return; + } + + try { + const newBrowserId = await createRemoteBrowserForRun(userId); + + logger.log('info', `Created and initialized browser ${newBrowserId} for queued run ${queuedRun.runId}`); + + await queuedRun.update({ + status: 'running', + browserId: newBrowserId, + log: 'Browser created and ready for execution' + }); + + const userQueueName = `execute-run-user-${userId}`; + await pgBoss.createQueue(userQueueName); + + const jobId = await pgBoss.send(userQueueName, { + userId: userId, + runId: queuedRun.runId, + browserId: newBrowserId, + }); + + logger.log('info', `Queued execution for run ${queuedRun.runId} with ready browser ${newBrowserId}, job ID: ${jobId}`); + + } catch (browserError: any) { + logger.log('error', `Failed to create browser for queued run: ${browserError.message}`); + await queuedRun.update({ + status: 'failed', + finishedAt: new Date().toLocaleString(), + log: `Failed to create browser: ${browserError.message}` + }); + } + } + } catch (error: any) { + logger.log('error', `Error processing queued runs: ${error.message}`); + } +} + +export { processQueuedRuns }; From 029483edcfe7121cf7428baa0d1f410ae4986580 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:22:23 +0530 Subject: [PATCH 12/23] feat: revamp process run execution --- server/src/pgboss-worker.ts | 61 +++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/server/src/pgboss-worker.ts b/server/src/pgboss-worker.ts index 8e5da8d1..374c24fa 100644 --- a/server/src/pgboss-worker.ts +++ b/server/src/pgboss-worker.ts @@ -82,14 +82,13 @@ function AddGeneratedFlags(workflow: WorkflowFile) { return copy; }; -/** - * Modified processRunExecution function - only add browser reset - */ async function processRunExecution(job: Job) { - try { - const data = job.data; - logger.log('info', `Processing run execution job for runId: ${data.runId}, browserId: ${data.browserId}`); - + const BROWSER_INIT_TIMEOUT = 30000; + + const data = job.data; + logger.log('info', `Processing run execution job for runId: ${data.runId}`); + + try { // Find the run const run = await Run.findOne({ where: { runId: data.runId } }); if (!run) { @@ -103,6 +102,11 @@ async function processRunExecution(job: Job) { } const plainRun = run.toJSON(); + const browserId = data.browserId || plainRun.browserId; + + if (!browserId) { + throw new Error(`No browser ID available for run ${data.runId}`); + } // Find the recording const recording = await Robot.findOne({ where: { 'recording_meta.id': plainRun.robotMetaId }, raw: true }); @@ -146,21 +150,44 @@ async function processRunExecution(job: Job) { return { success: false }; } + logger.log('info', `Looking for browser ${browserId} for run ${data.runId}`); + // Get the browser and execute the run - const browser = browserPool.getRemoteBrowser(plainRun.browserId); - let currentPage = browser?.getCurrentPage(); + let browser = browserPool.getRemoteBrowser(browserId); + const browserWaitStart = Date.now(); - if (!browser || !currentPage) { - logger.log('error', `Browser or page not available for run ${data.runId}`); - - return { success: false }; + while (!browser && (Date.now() - browserWaitStart) < BROWSER_INIT_TIMEOUT) { + logger.log('debug', `Browser ${browserId} not ready yet, waiting...`); + await new Promise(resolve => setTimeout(resolve, 1000)); + browser = browserPool.getRemoteBrowser(browserId); } + if (!browser) { + throw new Error(`Browser ${browserId} not found in pool after timeout`); + } + + logger.log('info', `Browser ${browserId} found and ready for execution`); + try { const isRunAborted = async (): Promise => { const currentRun = await Run.findOne({ where: { runId: data.runId } }); return currentRun ? (currentRun.status === 'aborted' || currentRun.status === 'aborting') : false; }; + + let currentPage = browser.getCurrentPage(); + + const pageWaitStart = Date.now(); + while (!currentPage && (Date.now() - pageWaitStart) < 30000) { + logger.log('debug', `Page not ready for browser ${browserId}, waiting...`); + await new Promise(resolve => setTimeout(resolve, 1000)); + currentPage = browser.getCurrentPage(); + } + + if (!currentPage) { + throw new Error(`No current page available for browser ${browserId} after timeout`); + } + + logger.log('info', `Starting workflow execution for run ${data.runId}`); // Execute the workflow const workflow = AddGeneratedFlags(recording.recording); @@ -313,7 +340,6 @@ async function processRunExecution(job: Job) { finishedAt: new Date().toLocaleString() }); - // Only destroy the browser if no queued run was found await destroyRemoteBrowser(plainRun.browserId, data.userId); logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); @@ -370,12 +396,7 @@ async function processRunExecution(job: Job) { logger.log('info', `Run ${data.runId} was aborted, not updating status to failed`); } - try { - await destroyRemoteBrowser(plainRun.browserId, data.userId); - logger.log('info', `No queued runs found for browser ${plainRun.browserId}, browser destroyed`); - } catch (cleanupError: any) { - logger.log('warn', `Failed to clean up browser for failed run ${data.runId}: ${cleanupError.message}`); - } + await destroyRemoteBrowser(plainRun.browserId, data.userId); return { success: false }; } From 7a0ae617cf05ab336e3c48690f96ca11c3881c39 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:25:04 +0530 Subject: [PATCH 13/23] feat: execute queued runs at 5s interval --- server/src/server.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/src/server.ts b/server/src/server.ts index bd4a1697..c03aaaa7 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -20,6 +20,7 @@ import connectPgSimple from 'connect-pg-simple'; import pg from 'pg'; import session from 'express-session'; import Run from './models/Run'; +import { processQueuedRuns } from './routes/storage'; const app = express(); app.use(cors({ @@ -163,6 +164,10 @@ app.use((req, res, next) => { next(); }); +setInterval(() => { + processQueuedRuns(); +}, 5000); + server.listen(SERVER_PORT, '0.0.0.0', async () => { try { await connectDB(); From caa39a062e0109e195be712cc7612dee6520b78d Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:27:56 +0530 Subject: [PATCH 14/23] feat: add can create browser api --- src/api/recording.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/api/recording.ts b/src/api/recording.ts index 6b816001..ce07753a 100644 --- a/src/api/recording.ts +++ b/src/api/recording.ts @@ -35,6 +35,19 @@ export const getActiveBrowserId = async(): Promise => { } }; +export const canCreateBrowserInState = async(state: "recording" | "run"): Promise => { + try { + const response = await axios.get(`${apiUrl}/record/can-create/${state}`, { withCredentials: true }); + if (response.status === 200) { + return response.data.canCreate; + } else { + return false; + } + } catch(error: any) { + return false; + } +}; + export const interpretCurrentRecording = async(): Promise => { try { const response = await axios.get(`${apiUrl}/record/interpret`); From 03e45c1dcbaa2861a86667be950264c3648a6378 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:28:18 +0530 Subject: [PATCH 15/23] feat: add can create browser route --- server/src/routes/record.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/src/routes/record.ts b/server/src/routes/record.ts index 374f837a..8a589811 100644 --- a/server/src/routes/record.ts +++ b/server/src/routes/record.ts @@ -11,6 +11,7 @@ import { getRemoteBrowserCurrentTabs, getActiveBrowserIdByState, destroyRemoteBrowser, + canCreateBrowserInState, } from '../browser-management/controller'; import { chromium } from 'playwright-extra'; import stealthPlugin from 'puppeteer-extra-plugin-stealth'; @@ -181,6 +182,18 @@ router.get('/active', requireSignIn, (req: AuthenticatedRequest, res) => { return res.send(id); }); +/** + * GET endpoint for checking if the user can create a new remote browser. + */ +router.get('/can-create/:state', requireSignIn, (req: AuthenticatedRequest, res) => { + if (!req.user) { + return res.status(401).send('User not authenticated'); + } + const state = req.params.state as "recording" | "run"; + const canCreate = canCreateBrowserInState(req.user.id, state); + return res.json({ canCreate }); +}); + /** * GET endpoint for getting the current url of the active remote browser. */ From bb56eadc5576cf0f03855c2698b484f73d914c55 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:31:04 +0530 Subject: [PATCH 16/23] feat: check can create browser for recording and retrain --- src/components/robot/RecordingsTable.tsx | 31 ++++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/robot/RecordingsTable.tsx b/src/components/robot/RecordingsTable.tsx index 6a517a13..6ea86f74 100644 --- a/src/components/robot/RecordingsTable.tsx +++ b/src/components/robot/RecordingsTable.tsx @@ -39,7 +39,7 @@ import { useGlobalInfoStore } from "../../context/globalInfo"; import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage"; import { Add } from "@mui/icons-material"; import { useNavigate } from 'react-router-dom'; -import { getActiveBrowserId, stopRecording } from "../../api/recording"; +import { canCreateBrowserInState, getActiveBrowserId, stopRecording } from "../../api/recording"; import { GenericModal } from '../ui/GenericModal'; declare global { @@ -274,11 +274,16 @@ export const RecordingsTable = ({ }, [setRecordings, notify, t]); const handleNewRecording = useCallback(async () => { - const activeBrowserId = await getActiveBrowserId(); + const canCreateRecording = await canCreateBrowserInState("recording"); - if (activeBrowserId) { - setActiveBrowserId(activeBrowserId); - setWarningModalOpen(true); + if (!canCreateRecording) { + const activeBrowserId = await getActiveBrowserId(); + if (activeBrowserId) { + setActiveBrowserId(activeBrowserId); + setWarningModalOpen(true); + } else { + notify('warning', t('recordingtable.notifications.browser_limit_warning')); + } } else { setModalOpen(true); } @@ -314,7 +319,6 @@ export const RecordingsTable = ({ }; const handleRetrainRobot = useCallback(async (id: string, name: string) => { - const activeBrowserId = await getActiveBrowserId(); const robot = rows.find(row => row.id === id); let targetUrl; @@ -340,11 +344,18 @@ export const RecordingsTable = ({ window.sessionStorage.setItem('initialUrl', targetUrl); } - if (activeBrowserId) { - setActiveBrowserId(activeBrowserId); - setWarningModalOpen(true); + const canCreateRecording = await canCreateBrowserInState("recording"); + + if (!canCreateRecording) { + const activeBrowserId = await getActiveBrowserId(); + if (activeBrowserId) { + setActiveBrowserId(activeBrowserId); + setWarningModalOpen(true); + } else { + notify('warning', t('recordingtable.notifications.browser_limit_warning')); + } } else { - startRetrainRecording(id, name, targetUrl); + startRetrainRecording(id, name, targetUrl); } }, [rows, setInitialUrl, setRecordingUrl]); From 9d329fbf099e6453712a19d166d78a70c42a4b10 Mon Sep 17 00:00:00 2001 From: Rohit Date: Tue, 3 Jun 2025 19:51:30 +0530 Subject: [PATCH 17/23] feat: add translatio for browser limit warning --- public/locales/de.json | 3 ++- public/locales/en.json | 3 ++- public/locales/es.json | 3 ++- public/locales/ja.json | 3 ++- public/locales/zh.json | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/public/locales/de.json b/public/locales/de.json index 4e26dc35..8141d873 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -61,7 +61,8 @@ "notifications": { "delete_warning": "Der Roboter hat zugehörige Ausführungen. Löschen Sie zuerst die Ausführungen, um den Roboter zu löschen", "delete_success": "Roboter erfolgreich gelöscht", - "auth_success": "Roboter erfolgreich authentifiziert" + "auth_success": "Roboter erfolgreich authentifiziert", + "browser_limit_warning": "Remote-Browser sind derzeit ausgelastet. Bitte warten Sie einige Minuten und versuchen Sie es erneut" } }, "mainmenu": { diff --git a/public/locales/en.json b/public/locales/en.json index e4f15c52..5e0925a1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -68,7 +68,8 @@ "notifications": { "delete_warning": "The robot has associated runs. First delete runs to delete the robot", "delete_success": "Robot deleted successfully", - "auth_success": "Robot successfully authenticated" + "auth_success": "Robot successfully authenticated", + "browser_limit_warning": "Remote browsers are currently busy. Please wait for a few minutes and try again" } }, "mainmenu":{ diff --git a/public/locales/es.json b/public/locales/es.json index 35b1fba1..e6953f38 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -62,7 +62,8 @@ "notifications": { "delete_warning": "El robot tiene ejecuciones asociadas. Primero elimine las ejecuciones para eliminar el robot", "delete_success": "Robot eliminado exitosamente", - "auth_success": "Robot autenticado exitosamente" + "auth_success": "Robot autenticado exitosamente", + "browser_limit_warning": "Los navegadores remotos están ocupados actualmente. Por favor, espere unos minutos e inténtelo de nuevo" } }, "mainmenu": { diff --git a/public/locales/ja.json b/public/locales/ja.json index b9e1174a..9d237674 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -62,7 +62,8 @@ "notifications": { "delete_warning": "ロボットには関連する実行があります。ロボットを削除するには、まず実行を削除してください", "delete_success": "ロボットが正常に削除されました", - "auth_success": "ロボットの認証に成功しました" + "auth_success": "ロボットの認証に成功しました", + "browser_limit_warning": "リモートブラウザは現在ビジー状態です。数分お待ちいただいてから再度お試しください" } }, "mainmenu": { diff --git a/public/locales/zh.json b/public/locales/zh.json index 6ac76ed9..949faf4d 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -62,7 +62,8 @@ "notifications": { "delete_warning": "该机器人有关联的运行记录。请先删除运行记录才能删除机器人", "delete_success": "机器人删除成功", - "auth_success": "机器人认证成功" + "auth_success": "机器人认证成功", + "browser_limit_warning": "远程浏览器当前繁忙。请稍等几分钟后重试" } }, "mainmenu": { From cced2258144eaa907a5f07022ebe5681ba1c5de4 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Fri, 6 Jun 2025 23:18:29 +0530 Subject: [PATCH 18/23] chore: archive bullmq worker --- {server/src => legacy/server}/worker.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {server/src => legacy/server}/worker.ts (100%) diff --git a/server/src/worker.ts b/legacy/server/worker.ts similarity index 100% rename from server/src/worker.ts rename to legacy/server/worker.ts From 0311109d8693f88d9fcd52dc91e8db3fc7b0d841 Mon Sep 17 00:00:00 2001 From: amhsirak Date: Mon, 9 Jun 2025 00:38:28 +0530 Subject: [PATCH 19/23] chore: bump @axios to 1.9.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9652ed87..5d9cc347 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@types/react-dom": "^18.0.1", "@types/uuid": "^8.3.4", "airtable": "^0.12.2", - "axios": "^0.26.0", + "axios": "^1.9.0", "bcrypt": "^5.1.1", "body-parser": "^1.20.3", "buffer": "^6.0.3", From a5ea68655b1a4eb9e9ba4f417d1369613454ed95 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 12 Jun 2025 14:07:18 +0530 Subject: [PATCH 20/23] feat: throttle highlighter, request animation frame --- src/components/browser/BrowserWindow.tsx | 31 +++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/components/browser/BrowserWindow.tsx b/src/components/browser/BrowserWindow.tsx index 8ce6e0f9..a0065e7b 100644 --- a/src/components/browser/BrowserWindow.tsx +++ b/src/components/browser/BrowserWindow.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useSocketStore } from '../../context/socket'; import { Button } from '@mui/material'; import Canvas from "../recorder/canvas"; @@ -84,6 +84,8 @@ export const BrowserWindow = () => { const [fields, setFields] = useState>({}); const [paginationSelector, setPaginationSelector] = useState(''); + const highlighterUpdateRef = useRef(0); + const { socket } = useSocketStore(); const { notify, currentTextActionId, currentListActionId } = useGlobalInfoStore(); const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext(); @@ -103,12 +105,12 @@ export const BrowserWindow = () => { useEffect(() => { if (listSelector) { - window.sessionStorage.setItem('recordingListSelector', listSelector); + sessionStorage.setItem('recordingListSelector', listSelector); } }, [listSelector]); useEffect(() => { - const storedListSelector = window.sessionStorage.getItem('recordingListSelector'); + const storedListSelector = sessionStorage.getItem('recordingListSelector'); // Only restore state if it exists in sessionStorage if (storedListSelector && !listSelector) { @@ -172,6 +174,12 @@ export const BrowserWindow = () => { }, [screenShot, canvasRef, socket, screencastHandler]); const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => { + const now = performance.now(); + if (now - highlighterUpdateRef.current < 16) { + return; + } + highlighterUpdateRef.current = now; + // Map the incoming DOMRect from browser coordinates to canvas coordinates const mappedRect = new DOMRect( data.rect.x, @@ -573,17 +581,22 @@ export const BrowserWindow = () => { }; const drawImage = (image: string, canvas: HTMLCanvasElement): void => { - const ctx = canvas.getContext('2d'); + if (!ctx) return; const img = new Image(); - - img.src = image; img.onload = () => { - URL.revokeObjectURL(img.src); - ctx?.drawImage(img, 0, 0, canvas.width, canvas.height); + requestAnimationFrame(() => { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + }); + if (image.startsWith('blob:')) { + URL.revokeObjectURL(image); + } }; - + img.onerror = () => { + console.warn('Failed to load image'); + }; + img.src = image; }; const modalStyle = { From 50575d90e7deb61dad2d9f48dfcf608e7ba7dd65 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 12 Jun 2025 14:14:55 +0530 Subject: [PATCH 21/23] feat: optimize canvas perf --- src/components/recorder/canvas.tsx | 252 ++++++++++++++++------------- 1 file changed, 138 insertions(+), 114 deletions(-) diff --git a/src/components/recorder/canvas.tsx b/src/components/recorder/canvas.tsx index 52d29b00..c8c7502c 100644 --- a/src/components/recorder/canvas.tsx +++ b/src/components/recorder/canvas.tsx @@ -29,13 +29,17 @@ export interface Coordinates { const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { const canvasRef = useRef(null); + const contextRef = useRef(null); + const imageDataRef = useRef(null); + const animationFrameRef = useRef(null); + const { socket } = useSocketStore(); const { setLastAction, lastAction } = useGlobalInfoStore(); const { getText, getList } = useActionContext(); const getTextRef = useRef(getText); const getListRef = useRef(getList); - const MOUSE_MOVE_THROTTLE = 16; // ~60fps + const MOUSE_MOVE_THROTTLE = 8; const lastMouseMoveTime = useRef(0); const [datePickerInfo, setDatePickerInfo] = React.useState<{ @@ -72,6 +76,22 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { const lastMousePosition = useRef({ x: 0, y: 0 }); + useEffect(() => { + if (canvasRef.current && !contextRef.current) { + const ctx = canvasRef.current.getContext('2d', { + alpha: false, + desynchronized: true, + willReadFrequently: false + }); + + if (ctx) { + contextRef.current = ctx; + + imageDataRef.current = ctx.createImageData(width, height); + } + } + }, [width, height]); + useEffect(() => { getTextRef.current = getText; getListRef.current = getList; @@ -79,115 +99,100 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { useEffect(() => { if (socket) { - socket.on('showDatePicker', (info: { coordinates: Coordinates, selector: string }) => { + const handleDatePicker = (info: { coordinates: Coordinates, selector: string }) => { const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates); - setDatePickerInfo({ - ...info, - coordinates: canvasCoords - }); - }); + setDatePickerInfo({ ...info, coordinates: canvasCoords }); + }; - socket.on('showDropdown', (info: { + const handleDropdown = (info: { coordinates: Coordinates, selector: string, - options: Array<{ - value: string; - text: string; - disabled: boolean; - selected: boolean; - }>; + options: Array<{ value: string; text: string; disabled: boolean; selected: boolean; }>; }) => { const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates); - setDropdownInfo({ - ...info, - coordinates: canvasCoords - }); - }); + setDropdownInfo({ ...info, coordinates: canvasCoords }); + }; - socket.on('showTimePicker', (info: { coordinates: Coordinates, selector: string }) => { + const handleTimePicker = (info: { coordinates: Coordinates, selector: string }) => { const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates); - setTimePickerInfo({ - ...info, - coordinates: canvasCoords - }); - }); + setTimePickerInfo({ ...info, coordinates: canvasCoords }); + }; - socket.on('showDateTimePicker', (info: { coordinates: Coordinates, selector: string }) => { + const handleDateTimePicker = (info: { coordinates: Coordinates, selector: string }) => { const canvasCoords = coordinateMapper.mapBrowserToCanvas(info.coordinates); - setDateTimeLocalInfo({ - ...info, - coordinates: canvasCoords - }); - }); + setDateTimeLocalInfo({ ...info, coordinates: canvasCoords }); + }; + + socket.on('showDatePicker', handleDatePicker); + socket.on('showDropdown', handleDropdown); + socket.on('showTimePicker', handleTimePicker); + socket.on('showDateTimePicker', handleDateTimePicker); return () => { - socket.off('showDatePicker'); - socket.off('showDropdown'); - socket.off('showTimePicker'); - socket.off('showDateTimePicker'); + socket.off('showDatePicker', handleDatePicker); + socket.off('showDropdown', handleDropdown); + socket.off('showTimePicker', handleTimePicker); + socket.off('showDateTimePicker', handleDateTimePicker); }; } }, [socket]); const onMouseEvent = useCallback((event: MouseEvent) => { - if (socket && canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - const clickCoordinates = { - x: event.clientX - rect.left, // Use relative x coordinate - y: event.clientY - rect.top, // Use relative y coordinate - }; + if (!socket || !canvasRef.current) return; - const browserCoordinates = coordinateMapper.mapCanvasToBrowser(clickCoordinates); + const rect = canvasRef.current.getBoundingClientRect(); + const clickCoordinates = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; - switch (event.type) { - case 'mousedown': - if (getTextRef.current === true) { - console.log('Capturing Text...'); - } else if (getListRef.current === true) { - console.log('Capturing List...'); - } else { - socket.emit('input:mousedown', browserCoordinates); - } - notifyLastAction('click'); - break; - case 'mousemove': { - const now = performance.now(); - if (now - lastMouseMoveTime.current < MOUSE_MOVE_THROTTLE) { - return; - } - lastMouseMoveTime.current = now; - - const dx = Math.abs(lastMousePosition.current.x - clickCoordinates.x); - const dy = Math.abs(lastMousePosition.current.y - clickCoordinates.y); - if (dx > 1 || dy > 1) { - lastMousePosition.current = { - x: clickCoordinates.x, - y: clickCoordinates.y, - }; - socket.emit('input:mousemove', browserCoordinates); - notifyLastAction('move'); - } - break; + const browserCoordinates = coordinateMapper.mapCanvasToBrowser(clickCoordinates); + + switch (event.type) { + case 'mousedown': + if (getTextRef.current === true) { + console.log('Capturing Text...'); + } else if (getListRef.current === true) { + console.log('Capturing List...'); + } else { + socket.emit('input:mousedown', browserCoordinates); } + notifyLastAction('click'); + break; - // Optimize wheel events - case 'wheel': { - const wheelEvent = event as WheelEvent; - const deltaX = Math.round(wheelEvent.deltaX / 10) * 10; - const deltaY = Math.round(wheelEvent.deltaY / 10) * 10; - - if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) { - socket.emit('input:wheel', { deltaX, deltaY }); - notifyLastAction('scroll'); - } - break; + case 'mousemove': { + const now = performance.now(); + if (now - lastMouseMoveTime.current < MOUSE_MOVE_THROTTLE) { + return; } - default: - console.log('Default mouseEvent registered'); - return; + lastMouseMoveTime.current = now; + + const dx = Math.abs(lastMousePosition.current.x - clickCoordinates.x); + const dy = Math.abs(lastMousePosition.current.y - clickCoordinates.y); + + if (dx > 0.5 || dy > 0.5) { + lastMousePosition.current = clickCoordinates; + socket.emit('input:mousemove', browserCoordinates); + notifyLastAction('move'); + } + break; } + + case 'wheel': { + const wheelEvent = event as WheelEvent; + const deltaX = Math.round(wheelEvent.deltaX / 5) * 5; + const deltaY = Math.round(wheelEvent.deltaY / 5) * 5; + + if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) { + socket.emit('input:wheel', { deltaX, deltaY }); + notifyLastAction('scroll'); + } + break; + } + default: + return; } - }, [socket]); + }, [socket, notifyLastAction]); const onKeyboardEvent = useCallback((event: KeyboardEvent) => { if (socket) { @@ -206,47 +211,66 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => { return; } } - }, [socket]); + }, [socket, notifyLastAction]); useEffect(() => { - if (canvasRef.current) { - onCreateRef(canvasRef); - canvasRef.current.addEventListener('mousedown', onMouseEvent); - canvasRef.current.addEventListener('mousemove', onMouseEvent); - canvasRef.current.addEventListener('wheel', onMouseEvent, { passive: true }); - canvasRef.current.addEventListener('keydown', onKeyboardEvent); - canvasRef.current.addEventListener('keyup', onKeyboardEvent); + const canvas = canvasRef.current; + if (!canvas) return; - return () => { - if (canvasRef.current) { - canvasRef.current.removeEventListener('mousedown', onMouseEvent); - canvasRef.current.removeEventListener('mousemove', onMouseEvent); - canvasRef.current.removeEventListener('wheel', onMouseEvent); - canvasRef.current.removeEventListener('keydown', onKeyboardEvent); - canvasRef.current.removeEventListener('keyup', onKeyboardEvent); - } + onCreateRef(canvasRef); - }; - } else { - console.log('Canvas not initialized'); - } + const options = { passive: true }; + + canvas.addEventListener('mousedown', onMouseEvent, options); + canvas.addEventListener('mousemove', onMouseEvent, options); + canvas.addEventListener('wheel', onMouseEvent, options); + canvas.addEventListener('keydown', onKeyboardEvent); + canvas.addEventListener('keyup', onKeyboardEvent); - }, [onMouseEvent]); + return () => { + canvas.removeEventListener('mousedown', onMouseEvent); + canvas.removeEventListener('mousemove', onMouseEvent); + canvas.removeEventListener('wheel', onMouseEvent); + canvas.removeEventListener('keydown', onKeyboardEvent); + canvas.removeEventListener('keyup', onKeyboardEvent); + }; + }, [onMouseEvent, onKeyboardEvent, onCreateRef]); + + useEffect(() => { + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + const containerStyle = React.useMemo(() => ({ + borderRadius: '0px 0px 5px 5px', + overflow: 'hidden', + backgroundColor: 'white', + contain: 'layout style paint', + isolation: 'isolate' as React.CSSProperties['isolation'] + }), []); + + const canvasStyle = React.useMemo(() => ({ + display: 'block', + imageRendering: 'crisp-edges' as const, + willChange: 'contents', + backfaceVisibility: 'hidden' as const, + transform: 'translateZ(0)', + maxWidth: '100%', + maxHeight: '100%' + }), []); return ( -
+
{datePickerInfo && ( Date: Thu, 12 Jun 2025 14:15:57 +0530 Subject: [PATCH 22/23] feat: memoize highlighter rect calc --- src/components/recorder/Highlighter.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/components/recorder/Highlighter.tsx b/src/components/recorder/Highlighter.tsx index a81c9b37..fd4763e7 100644 --- a/src/components/recorder/Highlighter.tsx +++ b/src/components/recorder/Highlighter.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from "styled-components"; import { coordinateMapper } from '../../helpers/coordinateMapper'; @@ -14,16 +14,15 @@ const HighlighterComponent = ({ unmodifiedRect, displayedSelector = '', width, h if (!unmodifiedRect) { return null; } else { - const mappedRect = coordinateMapper.mapBrowserRectToCanvas(unmodifiedRect); - - const rect = { - top: mappedRect.top + canvasRect.top + window.scrollY, - left: mappedRect.left + canvasRect.left + window.scrollX, - right: mappedRect.right + canvasRect.left, - bottom: mappedRect.bottom + canvasRect.top, - width: mappedRect.width, - height: mappedRect.height, - }; + const rect = useMemo(() => { + const mappedRect = coordinateMapper.mapBrowserRectToCanvas(unmodifiedRect); + return { + top: mappedRect.top + canvasRect.top + window.scrollY, + left: mappedRect.left + canvasRect.left + window.scrollX, + width: mappedRect.width, + height: mappedRect.height, + }; + }, [unmodifiedRect, canvasRect.top, canvasRect.left]); return ( From 7633c4730d4121cfb8109e7370fe430d56f7e6c7 Mon Sep 17 00:00:00 2001 From: Rohit Date: Thu, 12 Jun 2025 14:30:43 +0530 Subject: [PATCH 23/23] feat: optimize remote browser perf --- .../classes/RemoteBrowser.ts | 69 +++++++++++++------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/server/src/browser-management/classes/RemoteBrowser.ts b/server/src/browser-management/classes/RemoteBrowser.ts index ddfa9ab5..d04dcefe 100644 --- a/server/src/browser-management/classes/RemoteBrowser.ts +++ b/server/src/browser-management/classes/RemoteBrowser.ts @@ -25,6 +25,13 @@ const MEMORY_CONFIG = { heapUsageThreshold: 0.7 // 70% (reduced threshold to react earlier) }; +const DEFAULT_VIEWPORT = { + width: 1280, + height: 720, + deviceScaleFactor: 1, + mobile: false +}; + const SCREENCAST_CONFIG: { format: "jpeg" | "png"; maxWidth: number; @@ -32,13 +39,17 @@ const SCREENCAST_CONFIG: { targetFPS: number; compressionQuality: number; maxQueueSize: number; + skipFrameThreshold: number, + enableAdaptiveQuality: boolean, } = { - format: 'png', - maxWidth: 1280, - maxHeight: 720, - targetFPS: 15, - compressionQuality: 0.95, - maxQueueSize: 1 + format: 'jpeg', + maxWidth: DEFAULT_VIEWPORT.width, + maxHeight: DEFAULT_VIEWPORT.height, + targetFPS: 30, + compressionQuality: 0.8, + maxQueueSize: 2, + skipFrameThreshold: 100, + enableAdaptiveQuality: true, }; /** @@ -125,6 +136,18 @@ export class RemoteBrowser { this.generator = new WorkflowGenerator(socket, poolId); } + private cleanupMemory(): void { + if (this.screenshotQueue.length > 10) { + this.screenshotQueue = this.screenshotQueue.slice(-3); // Keep only last 3 + } + } + + private setupMemoryCleanup(): void { + setInterval(() => { + this.cleanupMemory(); + }, 30000); // Every 30 seconds + } + private initializeMemoryManagement(): void { setInterval(() => { const memoryUsage = process.memoryUsage(); @@ -412,6 +435,7 @@ export class RemoteBrowser { } } + this.setupMemoryCleanup(); // this.initializeMemoryManagement(); }; @@ -1399,7 +1423,7 @@ export class RemoteBrowser { */ private emitScreenshot = async (payload: Buffer, viewportSize?: { width: number, height: number }): Promise => { if (this.screenshotQueue.length > SCREENCAST_CONFIG.maxQueueSize) { - this.screenshotQueue = this.screenshotQueue.slice(-SCREENCAST_CONFIG.maxQueueSize); + this.screenshotQueue = this.screenshotQueue.slice(-1); } if (this.isProcessingScreenshot) { @@ -1414,7 +1438,7 @@ export class RemoteBrowser { try { const optimizationPromise = this.optimizeScreenshot(payload); const timeoutPromise = new Promise((resolve) => { - setTimeout(() => resolve(payload), 150); + setTimeout(() => resolve(payload), 100); }); const optimizedScreenshot = await Promise.race([optimizationPromise, timeoutPromise]); @@ -1423,10 +1447,12 @@ export class RemoteBrowser { payload = null as any; - this.socket.emit('screencast', { + setImmediate(async () => { + this.socket.emit('screencast', { image: dataWithMimeType, userId: this.userId, viewport: viewportSize || await this.currentPage?.viewportSize() || null + }); }); } catch (error) { logger.error('Screenshot emission failed:', error); @@ -1434,24 +1460,27 @@ export class RemoteBrowser { const base64Data = payload.toString('base64'); const dataWithMimeType = `data:image/png;base64,${base64Data}`; - this.socket.emit('screencast', { - image: dataWithMimeType, - userId: this.userId, - viewport: viewportSize || await this.currentPage?.viewportSize() || null + setImmediate(async () => { + this.socket.emit('screencast', { + image: dataWithMimeType, + userId: this.userId, + viewport: viewportSize || await this.currentPage?.viewportSize() || null + }); }); } catch (e) { logger.error('Fallback screenshot emission also failed:', e); } } finally { this.isProcessingScreenshot = false; - + if (this.screenshotQueue.length > 0) { - const nextScreenshot = this.screenshotQueue.shift(); - if (nextScreenshot) { - setTimeout(() => { - this.emitScreenshot(nextScreenshot); - }, 1000 / SCREENCAST_CONFIG.targetFPS); - } + const nextScreenshot = this.screenshotQueue.shift(); + if (nextScreenshot) { + const delay = this.screenshotQueue.length > 0 ? 16 : 33; + setTimeout(() => { + this.emitScreenshot(nextScreenshot); + }, delay); + } } } };