bycf / lib /browser.js
lordofc's picture
Update lib/browser.js
1ea5d45 verified
const { Logger } = require("./logger");
const { connect } = require("puppeteer-real-browser");
const os = require("os");
class BrowserService {
constructor() {
this.logger = new Logger("Browser");
this.browser = null;
this.browserContexts = new Set();
this.cleanupTimer = null;
this.isShuttingDown = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.cleanupInterval = 30000;
this.contextTimeout = 300000;
this.contextCreationTimes = new Map();
this.stats = {
totalContexts: 0,
activeContexts: 0,
memoryUsage: 0,
cpuUsage: 0,
lastCleanup: Date.now(),
};
const cpuCores = os.cpus().length;
this.contextLimit = Math.max(cpuCores * 4, 16);
this.logger.info(`Browser service initialized with context limit: ${this.contextLimit}`);
this.setupGracefulShutdown();
this.startPeriodicCleanup();
}
async initialize(options = {}) {
if (this.isShuttingDown) return;
try {
await this.closeBrowser();
this.logger.info("Launching browser...");
const defaultWidth = 1024;
const defaultHeight = 768;
const width = options.width || defaultWidth;
const height = options.height || defaultHeight;
const { browser } = await connect({
headless: false,
turnstile: true,
connectOption: {
defaultViewport: { width, height },
timeout: 120000,
protocolTimeout: 300000,
args: [
`--window-size=${width},${height}`,
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-background-networking',
'--disable-default-apps',
'--disable-extensions',
'--disable-sync',
'--disable-translate',
'--disable-web-security',
'--disable-features=VizDisplayCompositor',
'--single-process',
'--no-zygote',
'--no-first-run'
],
},
disableXvfb: false,
});
if (!browser) throw new Error("Failed to connect to browser");
this.browser = browser;
this.reconnectAttempts = 0;
this.setupBrowserEventHandlers();
this.wrapBrowserMethods();
this.logger.success("Browser launched successfully");
} catch (error) {
this.logger.error("Browser initialization failed:", error);
if (this.reconnectAttempts < this.maxReconnectAttempts && !this.isShuttingDown) {
this.reconnectAttempts++;
this.logger.warn(`Retrying browser initialization (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
await new Promise((resolve) => setTimeout(resolve, 5000 * this.reconnectAttempts));
return this.initialize(options);
}
throw error;
}
}
setupBrowserEventHandlers() {
if (!this.browser) return;
this.browser.on("disconnected", async () => {
if (this.isShuttingDown) return;
this.logger.warn("Browser disconnected, attempting to reconnect...");
await this.handleBrowserDisconnection();
});
this.browser.on("targetcreated", () => this.updateStats());
this.browser.on("targetdestroyed", () => this.updateStats());
}
wrapBrowserMethods() {
if (!this.browser) return;
const originalCreateContext = this.browser.createBrowserContext.bind(this.browser);
this.browser.createBrowserContext = async (...args) => {
if (this.browserContexts.size >= this.contextLimit) {
await this.forceCleanupOldContexts();
if (this.browserContexts.size >= this.contextLimit) {
throw new Error(`Browser context limit reached (${this.contextLimit})`);
}
}
const context = await originalCreateContext(...args);
if (context) {
this.browserContexts.add(context);
this.contextCreationTimes.set(context, Date.now());
this.stats.totalContexts++;
const originalClose = context.close.bind(context);
context.close = async () => {
try {
await originalClose();
} catch (error) {
this.logger.warn("Error closing context:", error.message);
} finally {
this.browserContexts.delete(context);
this.contextCreationTimes.delete(context);
this.updateStats();
}
};
setTimeout(async () => {
if (this.browserContexts.has(context)) {
this.logger.debug("Force closing expired context");
try {
await context.close();
} catch { }
}
}, this.contextTimeout);
}
this.updateStats();
return context;
};
}
async handleBrowserDisconnection() {
try {
const cleanupPromises = Array.from(this.browserContexts).map((context) => context.close().catch(() => { }));
await Promise.allSettled(cleanupPromises);
this.browserContexts.clear();
this.contextCreationTimes.clear();
if (this.reconnectAttempts < this.maxReconnectAttempts) {
await new Promise((resolve) => setTimeout(resolve, 5000));
await this.initialize();
} else {
this.logger.error("Max reconnection attempts reached");
}
} catch (error) {
this.logger.error("Error handling browser disconnection:", error);
}
}
startPeriodicCleanup() {
this.cleanupTimer = setInterval(async () => {
if (this.isShuttingDown) return;
try {
await this.performCleanup();
this.updateStats();
} catch (error) {
this.logger.error("Periodic cleanup error:", error);
}
}, this.cleanupInterval);
}
async performCleanup() {
const now = Date.now();
const contextsToCleanup = [];
for (const [context, creationTime] of this.contextCreationTimes.entries()) {
if (now - creationTime > this.contextTimeout) {
contextsToCleanup.push(context);
}
}
if (contextsToCleanup.length > 0) {
this.logger.debug(`Cleaning up ${contextsToCleanup.length} expired contexts`);
const cleanupPromises = contextsToCleanup.map((context) => context.close().catch(() => { }));
await Promise.allSettled(cleanupPromises);
}
if (this.browserContexts.size > this.contextLimit * 0.8) await this.forceCleanupOldContexts();
this.stats.lastCleanup = now;
}
async forceCleanupOldContexts() {
const contextsArray = Array.from(this.browserContexts);
const sortedContexts = contextsArray.sort((a, b) => {
const timeA = this.contextCreationTimes.get(a) || 0;
const timeB = this.contextCreationTimes.get(b) || 0;
return timeA - timeB;
});
const toCleanup = sortedContexts.slice(0, Math.floor(sortedContexts.length * 0.3));
if (toCleanup.length > 0) {
this.logger.warn(`Force cleaning up ${toCleanup.length} contexts due to limit`);
const cleanupPromises = toCleanup.map((context) => context.close().catch(() => { }));
await Promise.allSettled(cleanupPromises);
}
}
updateStats() {
this.stats.activeContexts = this.browserContexts.size;
this.stats.memoryUsage = process.memoryUsage().heapUsed;
const usage = process.cpuUsage();
this.stats.cpuUsage = (usage.user + usage.system) / 1000000;
}
async createContext(options = {}) {
if (!this.browser) await this.initialize();
if (!this.browser) throw new Error("Browser not available");
return await this.browser.createBrowserContext({
...options,
ignoreHTTPSErrors: true,
});
}
async withBrowserContext(callback) {
let context = null;
try {
context = await this.createContext();
return await callback(context);
} finally {
if (context) {
try {
await context.close();
} catch (error) {
this.logger.warn(`Failed to close context: ${error.message}`);
}
}
}
}
getBrowserStats() {
return { ...this.stats };
}
isReady() {
return this.browser !== null && !this.isShuttingDown;
}
async closeBrowser() {
if (this.browser) {
try {
const cleanupPromises = Array.from(this.browserContexts).map((context) => context.close().catch(() => { }));
await Promise.allSettled(cleanupPromises);
this.browserContexts.clear();
this.contextCreationTimes.clear();
await this.browser.close();
this.logger.info("Browser closed successfully");
} catch (error) {
this.logger.error("Error closing browser:", error);
} finally {
this.browser = null;
}
}
}
setupGracefulShutdown() {
const gracefulShutdown = async (signal) => {
this.logger.warn(`Received ${signal}, shutting down browser service...`);
this.isShuttingDown = true;
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
await this.closeBrowser();
this.logger.success("Browser service shutdown complete");
};
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
}
async shutdown() {
this.isShuttingDown = true;
if (this.cleanupTimer) clearInterval(this.cleanupTimer);
await this.closeBrowser();
}
}
module.exports = { BrowserService };