Spaces:
Paused
Paused
| const puppeteer = require('puppeteer'); | |
| const EventEmitter = require('events'); | |
| class BrowserInstance { | |
| constructor(browser, options = {}) { | |
| this.browser = browser; | |
| this.id = this.generateId(); | |
| this.createdAt = Date.now(); | |
| this.lastUsed = Date.now(); | |
| this.isIdle = true; | |
| this.usageCount = 0; | |
| this.maxIdleTime = options.maxIdleTime || 300000; // 5 minutes | |
| this.maxLifetime = options.maxLifetime || 1800000; // 30 minutes | |
| this.maxUsageCount = options.maxUsageCount || 100; | |
| this.activeTabs = new Set(); | |
| } | |
| generateId() { | |
| return `browser_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| } | |
| async newPage() { | |
| const page = await this.browser.newPage(); | |
| const pageId = `page_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; | |
| this.activeTabs.add(pageId); | |
| this.usageCount++; | |
| this.lastUsed = Date.now(); | |
| this.isIdle = false; | |
| // Wrap page.close to clean up from our tracking | |
| const originalClose = page.close.bind(page); | |
| page.close = async () => { | |
| this.activeTabs.delete(pageId); | |
| if (this.activeTabs.size === 0) { | |
| this.isIdle = true; | |
| } | |
| return originalClose(); | |
| }; | |
| return page; | |
| } | |
| isExpired() { | |
| const now = Date.now(); | |
| const idleTime = now - this.lastUsed; | |
| const lifetime = now - this.createdAt; | |
| return ( | |
| (this.isIdle && idleTime > this.maxIdleTime) || | |
| lifetime > this.maxLifetime || | |
| this.usageCount > this.maxUsageCount || | |
| this.browser.isConnected() === false | |
| ); | |
| } | |
| async close() { | |
| try { | |
| // Close all active pages first | |
| for (const page of await this.browser.pages()) { | |
| try { | |
| await page.close(); | |
| } catch (error) { | |
| console.warn(`Error closing page: ${error.message}`); | |
| } | |
| } | |
| await this.browser.close(); | |
| console.log(`Browser ${this.id} closed successfully`); | |
| } catch (error) { | |
| console.error(`Error closing browser ${this.id}:`, error.message); | |
| } | |
| } | |
| getStatus() { | |
| const now = Date.now(); | |
| return { | |
| id: this.id, | |
| isIdle: this.isIdle, | |
| usageCount: this.usageCount, | |
| activeTabs: this.activeTabs.size, | |
| idleTime: now - this.lastUsed, | |
| lifetime: now - this.createdAt, | |
| isExpired: this.isExpired(), | |
| isConnected: this.browser.isConnected() | |
| }; | |
| } | |
| } | |
| class BrowserPool extends EventEmitter { | |
| constructor(options = {}) { | |
| super(); | |
| this.maxSize = options.maxSize || 3; | |
| this.minSize = options.minSize || 1; | |
| this.launchOptions = options.launchOptions || { | |
| headless: 'new', | |
| executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium', | |
| args: [ | |
| '--no-sandbox', | |
| '--disable-setuid-sandbox', | |
| '--disable-dev-shm-usage', | |
| '--disable-gpu', | |
| '--disable-web-security', | |
| '--single-process' | |
| ] | |
| }; | |
| this.instanceOptions = { | |
| maxIdleTime: options.maxIdleTime || 300000, | |
| maxLifetime: options.maxLifetime || 1800000, | |
| maxUsageCount: options.maxUsageCount || 100 | |
| }; | |
| this.instances = new Map(); | |
| this.queue = []; | |
| this.isShuttingDown = false; | |
| // Stats | |
| this.stats = { | |
| created: 0, | |
| destroyed: 0, | |
| acquired: 0, | |
| released: 0, | |
| errors: 0, | |
| currentSize: 0, | |
| queueSize: 0 | |
| }; | |
| // Start maintenance | |
| this.startMaintenance(); | |
| } | |
| /** | |
| * Get a browser instance from the pool | |
| */ | |
| async acquire(timeout = 30000) { | |
| if (this.isShuttingDown) { | |
| throw new Error('Browser pool is shutting down'); | |
| } | |
| return new Promise((resolve, reject) => { | |
| const timeoutId = setTimeout(() => { | |
| reject(new Error(`Browser acquisition timeout after ${timeout}ms`)); | |
| }, timeout); | |
| const handleRequest = async () => { | |
| try { | |
| clearTimeout(timeoutId); | |
| const instance = await this.getOrCreateInstance(); | |
| this.stats.acquired++; | |
| resolve(instance); | |
| } catch (error) { | |
| clearTimeout(timeoutId); | |
| this.stats.errors++; | |
| reject(error); | |
| } | |
| }; | |
| if (this.hasAvailableInstance()) { | |
| handleRequest(); | |
| } else if (this.instances.size < this.maxSize) { | |
| handleRequest(); | |
| } else { | |
| // Add to queue | |
| this.queue.push(handleRequest); | |
| this.stats.queueSize = this.queue.length; | |
| this.emit('queue', { size: this.queue.length, maxSize: this.maxSize }); | |
| } | |
| }); | |
| } | |
| /** | |
| * Release a browser instance back to the pool | |
| */ | |
| async release(instance) { | |
| if (!instance || !this.instances.has(instance.id)) { | |
| return; | |
| } | |
| instance.isIdle = true; | |
| instance.lastUsed = Date.now(); | |
| this.stats.released++; | |
| this.emit('release', { instanceId: instance.id, stats: instance.getStatus() }); | |
| // Process queue if there are waiting requests | |
| if (this.queue.length > 0) { | |
| const handleRequest = this.queue.shift(); | |
| this.stats.queueSize = this.queue.length; | |
| setImmediate(handleRequest); | |
| } | |
| } | |
| /** | |
| * Check if there's an available instance | |
| */ | |
| hasAvailableInstance() { | |
| for (const instance of this.instances.values()) { | |
| if (instance.isIdle && !instance.isExpired()) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Get an existing instance or create a new one | |
| */ | |
| async getOrCreateInstance() { | |
| // Try to find an available instance | |
| for (const instance of this.instances.values()) { | |
| if (instance.isIdle && !instance.isExpired()) { | |
| return instance; | |
| } | |
| } | |
| // Create new instance if under limit | |
| if (this.instances.size < this.maxSize) { | |
| return await this.createInstance(); | |
| } | |
| throw new Error('No available browser instances and pool is at maximum capacity'); | |
| } | |
| /** | |
| * Create a new browser instance | |
| */ | |
| async createInstance() { | |
| try { | |
| console.log('Creating new browser instance...'); | |
| const browser = await puppeteer.launch(this.launchOptions); | |
| const instance = new BrowserInstance(browser, this.instanceOptions); | |
| this.instances.set(instance.id, instance); | |
| this.stats.created++; | |
| this.stats.currentSize = this.instances.size; | |
| this.emit('create', { instanceId: instance.id, poolSize: this.instances.size }); | |
| // Set up browser disconnect handler | |
| browser.on('disconnected', () => { | |
| this.handleBrowserDisconnect(instance.id); | |
| }); | |
| console.log(`Browser instance ${instance.id} created. Pool size: ${this.instances.size}`); | |
| return instance; | |
| } catch (error) { | |
| this.stats.errors++; | |
| console.error('Failed to create browser instance:', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Handle browser disconnect | |
| */ | |
| handleBrowserDisconnect(instanceId) { | |
| const instance = this.instances.get(instanceId); | |
| if (instance) { | |
| console.warn(`Browser ${instanceId} disconnected unexpectedly`); | |
| this.instances.delete(instanceId); | |
| this.stats.destroyed++; | |
| this.stats.currentSize = this.instances.size; | |
| this.emit('disconnect', { instanceId, poolSize: this.instances.size }); | |
| } | |
| } | |
| /** | |
| * Remove expired instances | |
| */ | |
| async cleanupExpiredInstances() { | |
| const expiredInstances = []; | |
| for (const [id, instance] of this.instances.entries()) { | |
| if (instance.isExpired()) { | |
| expiredInstances.push(id); | |
| } | |
| } | |
| for (const id of expiredInstances) { | |
| await this.destroyInstance(id, 'expired'); | |
| } | |
| if (expiredInstances.length > 0) { | |
| console.log(`Cleaned up ${expiredInstances.length} expired browser instances`); | |
| } | |
| } | |
| /** | |
| * Destroy a specific instance | |
| */ | |
| async destroyInstance(instanceId, reason = 'manual') { | |
| const instance = this.instances.get(instanceId); | |
| if (!instance) { | |
| return; | |
| } | |
| try { | |
| await instance.close(); | |
| this.instances.delete(instanceId); | |
| this.stats.destroyed++; | |
| this.stats.currentSize = this.instances.size; | |
| this.emit('destroy', { | |
| instanceId, | |
| reason, | |
| poolSize: this.instances.size, | |
| stats: instance.getStatus() | |
| }); | |
| console.log(`Browser instance ${instanceId} destroyed (reason: ${reason})`); | |
| } catch (error) { | |
| console.error(`Error destroying browser instance ${instanceId}:`, error); | |
| } | |
| } | |
| /** | |
| * Ensure minimum pool size | |
| */ | |
| async ensureMinimumSize() { | |
| const currentSize = this.instances.size; | |
| const needed = this.minSize - currentSize; | |
| if (needed > 0) { | |
| console.log(`Creating ${needed} browser instances to maintain minimum pool size`); | |
| const createPromises = []; | |
| for (let i = 0; i < needed; i++) { | |
| createPromises.push(this.createInstance().catch(error => { | |
| console.error('Failed to create browser instance for minimum pool size:', error); | |
| })); | |
| } | |
| await Promise.allSettled(createPromises); | |
| } | |
| } | |
| /** | |
| * Get pool status | |
| */ | |
| getStatus() { | |
| const instances = Array.from(this.instances.values()).map(instance => instance.getStatus()); | |
| return { | |
| poolSize: this.instances.size, | |
| maxSize: this.maxSize, | |
| minSize: this.minSize, | |
| queueSize: this.queue.length, | |
| isShuttingDown: this.isShuttingDown, | |
| stats: { ...this.stats }, | |
| instances, | |
| healthy: instances.filter(i => i.isConnected && !i.isExpired).length, | |
| idle: instances.filter(i => i.isIdle).length, | |
| active: instances.filter(i => !i.isIdle).length, | |
| expired: instances.filter(i => i.isExpired).length | |
| }; | |
| } | |
| /** | |
| * Start maintenance routine | |
| */ | |
| startMaintenance() { | |
| this.maintenanceInterval = setInterval(async () => { | |
| try { | |
| await this.cleanupExpiredInstances(); | |
| await this.ensureMinimumSize(); | |
| // Log status periodically | |
| const status = this.getStatus(); | |
| if (status.poolSize > 0) { | |
| console.log(`Browser pool status: ${status.poolSize}/${status.maxSize} instances, ${status.active} active, ${status.queueSize} queued`); | |
| } | |
| } catch (error) { | |
| console.error('Browser pool maintenance error:', error); | |
| } | |
| }, 30000); // Run every 30 seconds | |
| } | |
| /** | |
| * Stop maintenance routine | |
| */ | |
| stopMaintenance() { | |
| if (this.maintenanceInterval) { | |
| clearInterval(this.maintenanceInterval); | |
| this.maintenanceInterval = null; | |
| } | |
| } | |
| /** | |
| * Shutdown the browser pool | |
| */ | |
| async shutdown(timeout = 30000) { | |
| console.log('Shutting down browser pool...'); | |
| this.isShuttingDown = true; | |
| this.stopMaintenance(); | |
| // Reject all queued requests | |
| while (this.queue.length > 0) { | |
| const handleRequest = this.queue.shift(); | |
| setImmediate(() => { | |
| handleRequest(new Error('Browser pool is shutting down')); | |
| }); | |
| } | |
| // Close all instances | |
| const shutdownPromises = Array.from(this.instances.values()).map(async (instance) => { | |
| try { | |
| await instance.close(); | |
| } catch (error) { | |
| console.error(`Error closing browser ${instance.id} during shutdown:`, error); | |
| } | |
| }); | |
| // Wait for all instances to close or timeout | |
| const timeoutPromise = new Promise((resolve) => { | |
| setTimeout(resolve, timeout); | |
| }); | |
| await Promise.race([ | |
| Promise.allSettled(shutdownPromises), | |
| timeoutPromise | |
| ]); | |
| this.instances.clear(); | |
| this.stats.currentSize = 0; | |
| console.log('Browser pool shutdown complete'); | |
| this.emit('shutdown', { totalDestroyed: shutdownPromises.length }); | |
| } | |
| /** | |
| * Health check | |
| */ | |
| async healthCheck() { | |
| const status = this.getStatus(); | |
| const healthyRatio = status.poolSize > 0 ? status.healthy / status.poolSize : 1; | |
| return { | |
| status: healthyRatio >= 0.5 ? 'healthy' : 'unhealthy', | |
| poolSize: status.poolSize, | |
| healthy: status.healthy, | |
| expired: status.expired, | |
| queueSize: status.queueSize, | |
| healthyRatio, | |
| details: status | |
| }; | |
| } | |
| } | |
| module.exports = BrowserPool; |