Files
parcer/server/src/browser-management/classes/BrowserPool.ts

632 lines
23 KiB
TypeScript
Raw Normal View History

import { RemoteBrowser } from "./RemoteBrowser";
import logger from "../../logger";
2024-06-01 08:56:52 +05:30
/**
* @category Types
*/
2025-03-12 16:56:04 +05:30
/**
* Represents the possible states of a remote browser.
* @category Types
*/
type BrowserState = "recording" | "run";
interface BrowserPoolInfo {
2024-06-01 08:56:52 +05:30
/**
* The instance of remote browser.
*/
2025-06-03 18:50:40 +05:30
browser: RemoteBrowser | null,
2024-06-01 08:56:52 +05:30
/**
* States if the browser's instance is being actively used.
* Helps to persist the progress on the frontend when the application has been reloaded.
* @default false
*/
active: boolean,
2024-06-01 10:24:20 +05:30
/**
2025-03-07 22:40:07 +05:30
* The user ID that owns this browser instance.
2024-06-01 10:24:20 +05:30
*/
2025-03-07 22:40:07 +05:30
userId: string,
2025-03-12 16:56:04 +05:30
/**
* The current state of the browser.
* Can be "recording" or "run".
* @default "recording"
*/
state: BrowserState,
2025-06-03 18:50:40 +05:30
/**
* The status of the browser instance.
* Can be "reserved", "initializing", "ready" or "failed".
*/
status?: "reserved" | "initializing" | "ready" | "failed",
2024-06-01 10:24:20 +05:30
}
2025-03-07 22:40:07 +05:30
2025-03-07 22:40:34 +05:30
/**
* Dictionary of all the active remote browser's instances indexed by their id.
* The value in this dictionary is of type BrowserPoolInfo,
* which provides additional information about the browser's usage.
* @category Types
*/
interface PoolDictionary {
[key: string]: BrowserPoolInfo,
}
2025-03-07 22:41:03 +05:30
/**
* A browser pool is a collection of remote browsers that are initialized and ready to be used.
2025-03-12 16:56:04 +05:30
* Enforces a "1 User - 2 Browser" policy, while allowing multiple users to have their own browser instances.
2025-03-07 22:41:03 +05:30
* Adds the possibility to add, remove and retrieve remote browsers from the pool.
* @category BrowserManagement
*/
export class BrowserPool {
/**
* Holds all the instances of remote browsers.
*/
private pool: PoolDictionary = {};
/**
* Maps user IDs to their browser IDs.
2025-03-12 16:56:04 +05:30
* A user can have up to 2 browsers.
2025-03-07 22:41:03 +05:30
*/
2025-03-12 16:56:04 +05:30
private userToBrowserMap: Map<string, string[]> = new Map();
2025-03-07 22:41:03 +05:30
/**
* Adds a remote browser instance to the pool for a specific user.
2025-03-12 16:56:04 +05:30
* If the user already has two browsers, the oldest browser will be closed and replaced.
2025-03-07 22:41:03 +05:30
*
* @param id remote browser instance's id
* @param browser remote browser instance
* @param userId the user ID that owns this browser instance
* @param active states if the browser's instance is being actively used
* @returns true if a new browser was added, false if an existing browser was replaced
*/
2025-03-07 22:41:25 +05:30
public addRemoteBrowser = (
id: string,
browser: RemoteBrowser,
userId: string,
2025-03-12 16:56:04 +05:30
active: boolean = false,
state: BrowserState = "recording"
2025-03-07 22:41:25 +05:30
): boolean => {
2025-03-12 16:56:04 +05:30
// Check if browser with this ID already exists and belongs to this user
if (this.pool[id] && this.pool[id].userId === userId) {
// Just update the existing browser
this.pool[id] = {
browser,
active,
userId,
state: this.pool[id].state || state,
};
logger.log('debug', `Updated existing browser with id: ${id} for user: ${userId}`);
return false;
}
// Get existing browsers for this user
let userBrowserIds = this.userToBrowserMap.get(userId) || [];
2025-03-07 22:41:25 +05:30
let replaced = false;
2025-03-12 16:56:04 +05:30
// If trying to add a "recording" browser, check if one already exists
if (state === "recording") {
// Check if user already has a recording browser
const hasRecordingBrowser = userBrowserIds.some(browserId =>
this.pool[browserId] && this.pool[browserId].state === "recording"
);
if (hasRecordingBrowser) {
logger.log('debug', `User ${userId} already has a browser in "recording" state`);
2025-03-07 22:41:25 +05:30
return false;
}
}
2025-03-12 16:56:04 +05:30
// For "run" state, check if the user already has the maximum number of browsers (2)
if (userBrowserIds.length >= 2 && !userBrowserIds.includes(id)) {
logger.log('debug', "User already has the maximum number of browsers (2)");
return false;
}
2025-03-07 22:41:25 +05:30
// Add the new browser to the pool
this.pool[id] = {
browser,
active,
userId,
2025-03-12 16:56:04 +05:30
state,
2025-03-07 22:41:25 +05:30
};
// Update the user-to-browser mapping
2025-03-12 16:56:04 +05:30
if (!userBrowserIds.includes(id)) {
userBrowserIds.push(id);
}
this.userToBrowserMap.set(userId, userBrowserIds);
2025-03-07 22:41:25 +05:30
logger.log('debug', `Remote browser with id: ${id} added to the pool for user: ${userId}`);
return !replaced;
};
2025-03-07 22:41:47 +05:30
/**
* Removes the remote browser instance from the pool.
* Note: This doesn't handle browser closing as RemoteBrowser doesn't expose a close method.
* The caller should ensure the browser is properly closed before calling this method.
*
* @param id remote browser instance's id
* @returns true if the browser was removed successfully, false otherwise
*/
public closeAndDeleteBrowser = (id: string): boolean => {
if (!this.pool[id]) {
logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`);
return false;
}
// Remove the user-to-browser mapping
const userId = this.pool[id].userId;
2025-03-12 16:56:04 +05:30
const userBrowserIds = this.userToBrowserMap.get(userId) || [];
if (userBrowserIds.includes(id)) {
const updatedBrowserIds = userBrowserIds.filter(bid => bid !== id);
if (updatedBrowserIds.length === 0) {
this.userToBrowserMap.delete(userId);
} else {
this.userToBrowserMap.set(userId, updatedBrowserIds);
}
2025-03-07 22:41:47 +05:30
}
// Remove from pool
delete this.pool[id];
logger.log('debug', `Remote browser with id: ${id} removed from the pool`);
return true;
};
2025-03-07 22:42:07 +05:30
/**
* Removes the remote browser instance from the pool without attempting to close it.
*
* @param id remote browser instance's id
* @returns true if the browser was removed successfully, false otherwise
*/
public deleteRemoteBrowser = (id: string): boolean => {
if (!this.pool[id]) {
logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`);
return false;
}
// Remove the user-to-browser mapping
const userId = this.pool[id].userId;
2025-03-12 16:56:04 +05:30
const userBrowserIds = this.userToBrowserMap.get(userId) || [];
if (userBrowserIds.includes(id)) {
const updatedBrowserIds = userBrowserIds.filter(bid => bid !== id);
if (updatedBrowserIds.length === 0) {
this.userToBrowserMap.delete(userId);
} else {
this.userToBrowserMap.set(userId, updatedBrowserIds);
}
2025-03-07 22:42:07 +05:30
}
// Remove from pool
delete this.pool[id];
logger.log('debug', `Remote browser with id: ${id} deleted from the pool`);
return true;
};
2025-03-07 22:42:23 +05:30
/**
* Returns the remote browser instance from the pool.
*
* @param id remote browser instance's id
* @returns remote browser instance or undefined if it does not exist in the pool
*/
public getRemoteBrowser = (id: string): RemoteBrowser | undefined => {
2025-06-03 18:50:40 +05:30
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;
2025-03-07 22:42:23 +05:30
};
2025-03-07 22:42:49 +05:30
/**
* Returns the active browser's instance id for a specific user.
2025-03-12 16:56:04 +05:30
* If state is specified, only returns a browser with that exact state.
2025-03-07 22:42:49 +05:30
*
* @param userId the user ID to find the browser for
2025-03-12 16:56:04 +05:30
* @param state optional browser state filter ("recording" or "run")
* @returns the browser ID for the user, or null if no browser exists with the required state
2025-03-07 22:42:49 +05:30
*/
2025-03-12 16:56:04 +05:30
public getActiveBrowserId = (userId: string, state?: BrowserState): string | null => {
const browserIds = this.userToBrowserMap.get(userId);
if (!browserIds || browserIds.length === 0) {
2025-03-07 22:42:49 +05:30
logger.log('debug', `No browser found for user: ${userId}`);
return null;
}
2025-03-12 16:56:04 +05:30
// If state is specified, only return browsers with that exact state
if (state) {
// Check browsers in reverse order (newest first) to find one with the specified state
for (let i = browserIds.length - 1; i >= 0; i--) {
const browserId = browserIds[i];
// Verify the browser still exists in the pool
if (!this.pool[browserId]) {
browserIds.splice(i, 1);
continue;
}
// Check if browser matches state filter
if (this.pool[browserId].state === state) {
return browserId;
}
}
// If no browser with matching state, return null
logger.log('debug', `No browser with state ${state} found for user: ${userId}`);
2025-03-07 22:42:49 +05:30
return null;
}
2025-03-12 16:56:04 +05:30
// If no state specified, return any browser
for (let i = browserIds.length - 1; i >= 0; i--) {
const browserId = browserIds[i];
// Verify the browser still exists in the pool
if (!this.pool[browserId]) {
browserIds.splice(i, 1);
continue;
}
// Return the first browser found
if (this.pool[browserId]) {
console.log(`Active browser Id ${browserId} found for user: ${userId}`);
return browserId;
}
}
// If no active browser, return the most recent one
if (browserIds.length > 0) {
const mostRecentId = browserIds[browserIds.length - 1];
console.log(`No active browser found, returning most recent browser Id ${mostRecentId} for user: ${userId}`);
return mostRecentId;
}
// Clean up the mapping if all browsers were invalid
if (browserIds.length === 0) {
this.userToBrowserMap.delete(userId);
}
logger.log('warn', `Browser mapping found for user: ${userId}, but no valid browsers exist in pool`);
return null;
2025-03-07 22:42:49 +05:30
};
2025-03-07 22:43:06 +05:30
/**
* Returns the user ID associated with a browser ID.
*
* @param browserId the browser ID to find the user for
* @returns the user ID for the browser, or null if the browser doesn't exist
*/
public getUserForBrowser = (browserId: string): string | null => {
if (!this.pool[browserId]) {
return null;
}
return this.pool[browserId].userId;
};
2025-03-07 22:43:45 +05:30
/**
* Sets the active state of a browser.
*
* @param id the browser ID
* @param active the new active state
* @returns true if successful, false if the browser wasn't found
*/
public setActiveBrowser = (id: string, active: boolean): boolean => {
if (!this.pool[id]) {
logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`);
return false;
}
this.pool[id].active = active;
logger.log('debug', `Remote browser with id: ${id} set to ${active ? 'active' : 'inactive'}`);
return true;
};
2025-03-12 16:56:04 +05:30
/**
* Sets the state of a browser.
* Only allows one browser in "recording" state per user.
*
* @param id the browser ID
* @param state the new state ("recording" or "run")
* @returns true if successful, false if the browser wasn't found or state change not allowed
*/
public setBrowserState = (id: string, state: BrowserState): boolean => {
if (!this.pool[id]) {
logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`);
return false;
}
// If trying to set to "recording" state, check if another browser is already recording
if (state === "recording") {
const userId = this.pool[id].userId;
const userBrowserIds = this.userToBrowserMap.get(userId) || [];
// Check if any other browser for this user is already in recording state
const hasAnotherRecordingBrowser = userBrowserIds.some(browserId =>
browserId !== id &&
this.pool[browserId] &&
this.pool[browserId].state === "recording"
);
if (hasAnotherRecordingBrowser) {
logger.log('warn', `Cannot set browser ${id} to "recording" state: User ${userId} already has a browser in recording state`);
return false;
}
}
this.pool[id].state = state;
logger.log('debug', `Remote browser with id: ${id} state set to ${state}`);
return true;
};
/**
* Gets the current state of a browser.
*
* @param id the browser ID
* @returns the current state or null if the browser wasn't found
*/
public getBrowserState = (id: string): BrowserState | null => {
if (!this.pool[id]) {
logger.log('warn', `Remote browser with id: ${id} does not exist in the pool`);
return null;
}
return this.pool[id].state;
};
2025-03-07 22:43:45 +05:30
/**
* Returns all browser instances for a specific user.
2025-03-12 16:56:04 +05:30
* With the "1 User - 2 Browser" policy, this can return up to 2 browsers.
*
* @param userId the user ID to find browsers for
* @returns an array of browser IDs belonging to the user
*/
public getAllBrowserIdsForUser = (userId: string): string[] => {
const browserIds: string[] = [];
2025-03-12 16:56:04 +05:30
// Get browser IDs from the map
const mappedBrowserIds = this.userToBrowserMap.get(userId) || [];
// Filter to only include IDs that exist in the pool
for (const id of mappedBrowserIds) {
if (this.pool[id]) {
browserIds.push(id);
}
}
2025-03-12 16:56:04 +05:30
// As a safeguard, also check the entire pool for any browsers assigned to this user
// This helps detect and fix any inconsistencies in the maps
for (const [id, info] of Object.entries(this.pool)) {
if (info.userId === userId && !browserIds.includes(id)) {
browserIds.push(id);
}
}
2025-03-12 16:56:04 +05:30
// Update the map if inconsistencies were found
if (browserIds.length > 0 && JSON.stringify(browserIds) !== JSON.stringify(mappedBrowserIds)) {
// Limit to 2 browsers if more were found
const limitedBrowserIds = browserIds.slice(-2);
this.userToBrowserMap.set(userId, limitedBrowserIds);
}
return browserIds;
};
2025-03-07 22:44:34 +05:30
/**
* Returns the total number of browsers in the pool.
*/
public getPoolSize = (): number => {
return Object.keys(this.pool).length;
};
2025-03-07 22:44:50 +05:30
/**
* Returns the total number of active users (users with browsers).
*/
public getActiveUserCount = (): number => {
return this.userToBrowserMap.size;
};
/**
* Gets the current active browser for the system if there's only one active user.
* This is a migration helper to support code that hasn't been updated to the user-browser model yet.
*
* @param currentUserId The ID of the current user, which will be prioritized if multiple browsers exist
2025-03-12 16:56:04 +05:30
* @param state Optional state filter to find browsers in a specific state
* @returns A browser ID if one can be determined, or null
*/
2025-03-12 16:56:04 +05:30
public getActiveBrowserForMigration = (currentUserId?: string, state?: BrowserState): string | null => {
// If a current user ID is provided and they have a browser, return that
if (currentUserId) {
2025-03-12 16:56:04 +05:30
const browserForUser = this.getActiveBrowserId(currentUserId, state);
if (browserForUser) {
return browserForUser;
}
2025-03-12 16:56:04 +05:30
// If state is specified and no matching browser was found, return null
if (state) {
return null;
}
}
2025-03-12 16:56:04 +05:30
// If only one user has a browser, try to find a matching browser
if (this.userToBrowserMap.size === 1) {
const userId = Array.from(this.userToBrowserMap.keys())[0];
2025-03-12 16:56:04 +05:30
const browserIds = this.userToBrowserMap.get(userId) || [];
// If state is specified, only look for that state
if (state) {
// Return the active browser that matches the state
for (let i = browserIds.length - 1; i >= 0; i--) {
const bid = browserIds[i];
if (this.pool[bid]?.active && this.pool[bid].state === state) {
return bid;
}
}
// If no active browser with matching state, try to find any browser with matching state
for (let i = browserIds.length - 1; i >= 0; i--) {
const bid = browserIds[i];
if (this.pool[bid] && this.pool[bid].state === state) {
return bid;
}
}
// If still no matching browser, return null
return null;
}
// If no state filter, find any active browser
for (let i = browserIds.length - 1; i >= 0; i--) {
if (this.pool[browserIds[i]]?.active) {
return browserIds[i];
}
}
return browserIds.length > 0 ? browserIds[browserIds.length - 1] : null;
}
// Fall back to checking all browsers if no user was specified
if (state) {
// Look for active browsers with the specific state
for (const id of Object.keys(this.pool)) {
if (this.pool[id].active && this.pool[id].state === state) {
return id;
}
}
// Then look for any browser with the specific state
for (const id of Object.keys(this.pool)) {
if (this.pool[id].state === state) {
return id;
}
}
// If no browser with the requested state is found, return null
return null;
}
2025-03-12 16:56:04 +05:30
// If no state filter, find any active browser
for (const id of Object.keys(this.pool)) {
if (this.pool[id].active) {
return id;
}
}
// If all else fails, return the first browser in the pool
const browserIds = Object.keys(this.pool);
return browserIds.length > 0 ? browserIds[0] : null;
};
2025-06-03 18:35:12 +05:30
/**
* 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.
* If there are multiple active browsers, it returns the first one.
*
* @returns the first remote active browser instance's id from the pool
2025-03-12 16:56:04 +05:30
* @deprecated Use getBrowserIdForUser instead to enforce the 1 User - 2 Browser policy
*/
public getActiveBrowserIdLegacy = (): string | null => {
for (const id of Object.keys(this.pool)) {
if (this.pool[id].active) {
return id;
}
}
// Don't log a warning since this behavior is expected in the user-browser model
// logger.log('warn', `No active browser in the pool`);
return null;
};
2025-06-03 18:50:40 +05:30
/**
* 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);
}
};
2025-03-07 22:41:03 +05:30
}