|
|
import { EventEmitter } from 'events'; |
|
|
import { randomUUID } from 'crypto'; |
|
|
|
|
|
export default class BrowserPool extends EventEmitter { |
|
|
constructor(options = {}) { |
|
|
super(); |
|
|
this.chromium = options.chromium; |
|
|
if (!this.chromium) throw new Error('Chromium instance must be provided'); |
|
|
|
|
|
this.maxSize = options.maxSize || 5; |
|
|
this.minSize = options.minSize || 1; |
|
|
this.maxUsage = options.maxUsage || 100; |
|
|
this.maxIdleTime = options.maxIdleTime || 300000; |
|
|
this.createTimeout = options.createTimeout || 30000; |
|
|
this.validateInterval = options.validateInterval || 60000; |
|
|
this.getTimeout = options.getTimeout || 30000; |
|
|
|
|
|
this.availableInstances = []; |
|
|
this.busyInstances = new Set(); |
|
|
this.totalInstances = 0; |
|
|
this.waitingQueue = []; |
|
|
this.stats = { created: 0, destroyed: 0, borrowed: 0, returned: 0, timeouts: 0, errors: 0 }; |
|
|
|
|
|
this.isShuttingDown = false; |
|
|
this.healthCheckInterval = null; |
|
|
} |
|
|
|
|
|
async initialize() { |
|
|
console.log(`[BrowserPool] Initializing pool with min: ${this.minSize}, max: ${this.maxSize}`); |
|
|
const createPromises = Array.from({ length: this.minSize }, () => this.createInstance()); |
|
|
try { |
|
|
await Promise.all(createPromises); |
|
|
console.log(`[BrowserPool] Pool initialized with ${this.availableInstances.length} instances.`); |
|
|
} catch (error) { |
|
|
console.error('[BrowserPool] Failed to initialize pool:', error.message); |
|
|
await this.shutdown(); |
|
|
throw error; |
|
|
} |
|
|
this.startHealthCheck(); |
|
|
} |
|
|
|
|
|
async createInstance() { |
|
|
if (this.isShuttingDown) { throw new Error('Pool is shutting down'); } |
|
|
if (this.totalInstances >= this.maxSize) { return null; } |
|
|
|
|
|
const startTime = Date.now(); |
|
|
let browser = null; |
|
|
this.totalInstances++; |
|
|
try { |
|
|
console.log('[BrowserPool] Creating new browser instance...'); |
|
|
browser = await Promise.race([ |
|
|
this.chromium.launch({ |
|
|
headless: true, |
|
|
args: [ |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
'--no-sandbox', |
|
|
'--disable-setuid-sandbox', |
|
|
'--disable-blink-features=AutomationControlled', |
|
|
'--disable-web-security', |
|
|
'--autoplay-policy=no-user-gesture-required', |
|
|
'--disable-dev-shm-usage', |
|
|
'--disable-gpu', |
|
|
'--no-first-run', |
|
|
'--disable-default-apps' |
|
|
] |
|
|
}), |
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser creation timeout')), this.createTimeout)) |
|
|
]); |
|
|
|
|
|
const instance = { |
|
|
browser, id: `browser_${randomUUID().slice(0, 8)}`, createdAt: Date.now(), |
|
|
lastUsed: Date.now(), usageCount: 0, isHealthy: true |
|
|
}; |
|
|
|
|
|
browser.on('disconnected', () => { |
|
|
console.log(`[BrowserPool] Browser ${instance.id} disconnected unexpectedly.`); |
|
|
this.destroyInstance(instance, true); |
|
|
}); |
|
|
|
|
|
this.stats.created++; |
|
|
console.log(`[BrowserPool] Browser instance created: ${instance.id} (${Date.now() - startTime}ms)`); |
|
|
this.emit('instanceCreated', instance); |
|
|
this.availableInstances.push(instance); |
|
|
this.processWaitingQueue(); |
|
|
return instance; |
|
|
} catch (error) { |
|
|
this.totalInstances--; |
|
|
this.stats.errors++; |
|
|
console.error('[BrowserPool] Failed to create browser instance:', error.message); |
|
|
if (browser) await browser.close().catch(e => console.error(`Error closing failed browser: ${e.message}`)); |
|
|
throw error; |
|
|
} |
|
|
} |
|
|
|
|
|
get() { |
|
|
if (this.isShuttingDown) return Promise.reject(new Error('Pool is shutting down')); |
|
|
return new Promise((resolve, reject) => { |
|
|
const timeoutId = setTimeout(() => { |
|
|
this.waitingQueue = this.waitingQueue.filter(w => w.resolve !== resolve); |
|
|
this.stats.timeouts++; |
|
|
reject(new Error(`Browser acquisition timeout after ${this.getTimeout}ms`)); |
|
|
}, this.getTimeout); |
|
|
|
|
|
const tryAcquire = async () => { |
|
|
while (this.availableInstances.length > 0) { |
|
|
const instance = this.availableInstances.shift(); |
|
|
if (await this.validateInstance(instance)) { |
|
|
this.busyInstances.add(instance); |
|
|
instance.lastUsed = Date.now(); |
|
|
instance.usageCount++; |
|
|
this.stats.borrowed++; |
|
|
clearTimeout(timeoutId); |
|
|
return resolve(instance); |
|
|
} else { |
|
|
await this.destroyInstance(instance); |
|
|
} |
|
|
} |
|
|
if (this.totalInstances < this.maxSize) { |
|
|
this.createInstance().catch(err => console.error(`[BrowserPool] Background instance creation failed: ${err.message}`)); |
|
|
} |
|
|
|
|
|
this.waitingQueue.push({ resolve, reject, timeoutId }); |
|
|
console.log(`[BrowserPool] No available instances, request added to waiting queue (size: ${this.waitingQueue.length})`); |
|
|
}; |
|
|
|
|
|
tryAcquire(); |
|
|
}); |
|
|
} |
|
|
|
|
|
async release(instance) { |
|
|
if (!instance || !this.busyInstances.has(instance)) { return; } |
|
|
this.busyInstances.delete(instance); |
|
|
this.stats.returned++; |
|
|
if (this.isShuttingDown || !await this.validateInstance(instance) || instance.usageCount >= this.maxUsage) { |
|
|
await this.destroyInstance(instance); |
|
|
} else { |
|
|
try { |
|
|
const contexts = instance.browser.contexts(); |
|
|
|
|
|
for (let i = 1; i < contexts.length; i++) { |
|
|
await contexts[i].close(); |
|
|
} |
|
|
this.availableInstances.push(instance); |
|
|
} catch (e) { |
|
|
console.warn(`[BrowserPool] Error cleaning up contexts for ${instance.id}, destroying it.`, e.message); |
|
|
await this.destroyInstance(instance); |
|
|
} |
|
|
} |
|
|
this.processWaitingQueue(); |
|
|
} |
|
|
|
|
|
async destroyInstance(instance, isDisconnected = false) { |
|
|
if (!instance) return; |
|
|
this.busyInstances.delete(instance); |
|
|
const index = this.availableInstances.indexOf(instance); |
|
|
if (index > -1) this.availableInstances.splice(index, 1); |
|
|
if (!isDisconnected && instance.browser && instance.browser.isConnected()) { |
|
|
await instance.browser.close().catch(e => console.error(`[BrowserPool] Error closing browser on destroy: ${e.message}`)); |
|
|
} |
|
|
if (this.totalInstances > 0) this.totalInstances--; |
|
|
this.stats.destroyed++; |
|
|
console.log(`[BrowserPool] Instance ${instance.id} destroyed.`); |
|
|
this.emit('instanceDestroyed', instance); |
|
|
if (!this.isShuttingDown && this.totalInstances < this.minSize) { |
|
|
this.createInstance().catch(err => console.error(`[BrowserPool] Failed to replenish instance: ${err.message}`)); |
|
|
} |
|
|
} |
|
|
|
|
|
processWaitingQueue() { |
|
|
if (this.waitingQueue.length > 0 && this.availableInstances.length > 0) { |
|
|
console.log('[BrowserPool] Processing waiting queue...'); |
|
|
const waiter = this.waitingQueue.shift(); |
|
|
const instance = this.availableInstances.shift(); |
|
|
this.busyInstances.add(instance); |
|
|
instance.lastUsed = Date.now(); |
|
|
instance.usageCount++; |
|
|
this.stats.borrowed++; |
|
|
clearTimeout(waiter.timeoutId); |
|
|
waiter.resolve(instance); |
|
|
} |
|
|
} |
|
|
|
|
|
async validateInstance(instance) { |
|
|
if (!instance || !instance.browser || !instance.browser.isConnected()) return false; |
|
|
try { |
|
|
|
|
|
const context = await instance.browser.newContext(); |
|
|
await context.close(); |
|
|
return true; |
|
|
} catch (error) { |
|
|
console.warn(`[BrowserPool] Instance ${instance.id} validation failed.`); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
startHealthCheck() { |
|
|
this.healthCheckInterval = setInterval(() => this.performHealthCheck(), this.validateInterval); |
|
|
} |
|
|
|
|
|
async performHealthCheck() { |
|
|
if (this.isShuttingDown) return; |
|
|
const now = Date.now(); |
|
|
const idleInstances = this.availableInstances.slice(); |
|
|
for (const instance of idleInstances) { |
|
|
if (this.totalInstances > this.minSize && now - instance.lastUsed > this.maxIdleTime) { |
|
|
console.log(`[BrowserPool] Removing idle instance ${instance.id}`); |
|
|
await this.destroyInstance(instance); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
getStats() { |
|
|
return { |
|
|
...this.stats, |
|
|
pool: { |
|
|
available: this.availableInstances.length, |
|
|
busy: this.busyInstances.size, |
|
|
total: this.totalInstances, |
|
|
waiting: this.waitingQueue.length |
|
|
} |
|
|
}; |
|
|
} |
|
|
|
|
|
async shutdown() { |
|
|
console.log('[BrowserPool] Shutting down...'); |
|
|
this.isShuttingDown = true; |
|
|
if (this.healthCheckInterval) clearInterval(this.healthCheckInterval); |
|
|
|
|
|
for (const waiter of this.waitingQueue) { |
|
|
clearTimeout(waiter.timeoutId); |
|
|
waiter.reject(new Error('Pool is shutting down')); |
|
|
} |
|
|
this.waitingQueue = []; |
|
|
|
|
|
const allInstances = [...this.availableInstances, ...Array.from(this.busyInstances)]; |
|
|
await Promise.all(allInstances.map(instance => instance.browser.close())); |
|
|
console.log('[BrowserPool] All browser instances closed.'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|