refactor: better camoufox instance tracking

This commit is contained in:
zhom
2025-07-31 03:56:41 +04:00
parent 2fd344b9bb
commit 63000c72bd
20 changed files with 1623 additions and 457 deletions
+46 -25
View File
@@ -11,7 +11,7 @@ import {
export interface CamoufoxLaunchOptions {
// Operating system to use for fingerprint generation
os?: "windows" | "macos" | "linux"[];
os?: "windows" | "macos" | "linux" | ("windows" | "macos" | "linux")[];
// Blocking options
block_images?: boolean;
@@ -126,30 +126,35 @@ export async function startCamoufoxProcess(
id,
];
// Spawn the process with proper detachment
// Spawn the process with proper detachment - similar to proxy implementation
const child = spawn(process.execPath, args, {
detached: true,
stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for debugging
stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for startup feedback
cwd: process.cwd(),
env: { ...process.env, NODE_ENV: "production" }, // Ensure consistent environment
env: {
...process.env,
NODE_ENV: "production",
// Ensure Camoufox can find its dependencies
NODE_PATH: process.env.NODE_PATH || "",
},
});
saveCamoufoxConfig(config);
// Wait for the worker to start successfully or fail
// Wait for the worker to start successfully or fail - with shorter timeout for quick response
return new Promise<CamoufoxConfig>((resolve, reject) => {
let resolved = false;
let stdoutBuffer = "";
let stderrBuffer = "";
// Shorter timeout for quick startup feedback
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
child.kill("SIGKILL");
reject(
new Error(`Camoufox worker ${id} startup timeout after 30 seconds`),
new Error(`Camoufox worker ${id} startup timeout after 5 seconds`),
);
}
}, 30000);
}, 5000);
// Handle stdout - look for success JSON
if (child.stdout) {
@@ -163,12 +168,7 @@ export async function startCamoufoxProcess(
if (line.trim()) {
try {
const parsed = JSON.parse(line.trim());
if (
parsed.success &&
parsed.id === id &&
parsed.port &&
parsed.wsEndpoint
) {
if (parsed.success && parsed.id === id && parsed.port) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
@@ -176,7 +176,8 @@ export async function startCamoufoxProcess(
config.port = parsed.port;
config.wsEndpoint = parsed.wsEndpoint;
saveCamoufoxConfig(config);
child.unref(); // Allow parent to exit independently
// Unref immediately after success to detach properly
child.unref();
resolve(config);
return;
}
@@ -257,20 +258,40 @@ export async function stopCamoufoxProcess(id: string): Promise<boolean> {
}
try {
// If we have a port, try to gracefully shutdown the server
// Try to find and kill the worker process using multiple methods
const { spawn } = await import("node:child_process");
// Method 1: Kill by process pattern
const killByPattern = spawn("pkill", ["-f", `camoufox-worker.*${id}`], {
stdio: "ignore",
});
// Method 2: If we have a port (which is actually the process PID), kill by PID
if (config.port) {
try {
await fetch(`http://localhost:${config.port}/shutdown`, {
method: "POST",
signal: AbortSignal.timeout(5000),
});
// Wait a bit for graceful shutdown
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch {
// Graceful shutdown failed, continue with force stop
process.kill(config.port, "SIGTERM");
// Give it a moment to terminate gracefully
await new Promise((resolve) => setTimeout(resolve, 2000));
// Force kill if still running
try {
process.kill(config.port, "SIGKILL");
} catch {
// Process already terminated
}
} catch (error) {
// Process not found or already terminated
}
}
// Wait for pattern-based kill command to complete
await new Promise<void>((resolve) => {
killByPattern.on("exit", () => resolve());
// Timeout after 3 seconds
setTimeout(() => resolve(), 3000);
});
// Delete the configuration
deleteCamoufoxConfig(id);
return true;
+115 -189
View File
@@ -1,7 +1,4 @@
import { launchServer } from "camoufox-js";
import getPort from "get-port";
import type { Page } from "playwright-core";
import { firefox } from "playwright-core";
import type { Browser, BrowserContext, Page } from "playwright-core";
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
/**
@@ -22,210 +19,139 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
process.exit(1);
}
let server: Awaited<ReturnType<typeof launchServer>> | null = null;
let browser: Awaited<ReturnType<typeof firefox.connect>> | null = null;
// Return success immediately - before any async operations
const processId = process.pid;
console.log(
JSON.stringify({
success: true,
id: id,
port: processId,
wsEndpoint: `ws://localhost:0/camoufox-${id}`,
profilePath: config.profilePath,
message: "Camoufox worker started successfully",
}),
);
// Update config with process details
config.port = processId;
config.wsEndpoint = `ws://localhost:0/camoufox-${id}`;
saveCamoufoxConfig(config);
// Handle process termination gracefully
const gracefulShutdown = async () => {
try {
if (browser) {
await browser.close();
}
if (server) {
await server.close();
}
} catch {
// Ignore errors during shutdown
}
process.exit(0);
};
process.on("SIGTERM", () => void gracefulShutdown());
process.on("SIGINT", () => void gracefulShutdown());
// Handle uncaught exceptions
process.on("uncaughtException", (error) => {
console.error(
JSON.stringify({
error: "Uncaught exception",
message: error.message,
stack: error.stack,
id: id,
}),
);
process.exit(1);
});
// Keep the process alive
setInterval(() => {
// Keep alive
}, 1000);
process.on("unhandledRejection", (reason) => {
console.error(
JSON.stringify({
error: "Unhandled rejection",
reason: String(reason),
id: id,
}),
);
process.exit(1);
});
// Add a timeout to prevent hanging
const startupTimeout = setTimeout(() => {
console.error(
JSON.stringify({
error: "Startup timeout",
message: "Worker startup timeout after 30 seconds",
id: id,
}),
);
process.exit(1);
}, 30000);
// Start the browser server
try {
const port = await getPort();
// Prepare options for Camoufox
const camoufoxOptions = { ...config.options };
// Add profile path if provided
if (config.profilePath) {
camoufoxOptions.user_data_dir = config.profilePath;
}
camoufoxOptions.disableTheming = true;
camoufoxOptions.showcursor = false;
// Don't force headless mode - let the user configuration decide
if (camoufoxOptions.headless === undefined) {
camoufoxOptions.headless = false; // Default to visible for debugging
}
// Launch browser in background - this can take time and may fail
setImmediate(async () => {
let browser: Browser | null = null;
let context: BrowserContext | null = null;
let page: Page | null = null;
try {
// Launch Camoufox server
server = await launchServer({
...camoufoxOptions,
port: port,
ws_path: "/camoufox",
});
} catch (error) {
console.error(
JSON.stringify({
error: "Failed to launch Camoufox server",
message: error instanceof Error ? error.message : String(error),
id: id,
}),
);
process.exit(1);
}
// Prepare options for Camoufox
const camoufoxOptions = { ...config.options };
if (!server) {
console.error(
JSON.stringify({
error: "Failed to launch Camoufox server",
message:
"Camoufox is not installed. Please install Camoufox first by running: npx camoufox-js fetch",
id: id,
}),
);
process.exit(1);
}
// Connect to the server
try {
browser = await firefox.connect(server.wsEndpoint());
} catch (error) {
console.error(
JSON.stringify({
error: "Failed to connect to Camoufox server",
message: error instanceof Error ? error.message : String(error),
id: id,
}),
);
process.exit(1);
}
// Update config with server details
config.port = port;
config.wsEndpoint = server.wsEndpoint();
saveCamoufoxConfig(config);
// Clear the startup timeout since we succeeded
clearTimeout(startupTimeout);
// Output success JSON for the parent process
console.log(
JSON.stringify({
success: true,
id: id,
port: port,
wsEndpoint: server.wsEndpoint(),
message: "Camoufox server started successfully",
}),
);
// Open URL if provided
if (config.url) {
try {
const page: Page = await browser.newPage();
await page.goto(config.url);
} catch (error) {
// Don't exit here, just log the error as JSON
console.error(
JSON.stringify({
error: "Failed to open URL",
url: config.url,
message: error instanceof Error ? error.message : String(error),
id: id,
}),
);
// Add profile path if provided
if (config.profilePath) {
camoufoxOptions.user_data_dir = config.profilePath;
}
} else {
// If no URL is provided, create a blank page to keep the browser alive
try {
await browser.newPage();
} catch (error) {
console.error(
JSON.stringify({
error: "Failed to create blank page",
message: error instanceof Error ? error.message : String(error),
id: id,
}),
);
// Set anti-detect options
camoufoxOptions.disableTheming = true;
camoufoxOptions.showcursor = false;
// Default to headless for tests
if (camoufoxOptions.headless === undefined) {
camoufoxOptions.headless = false;
}
}
// Keep the process alive by waiting for the browser to disconnect
browser.on("disconnected", () => {
process.exit(0);
});
// Keep the process alive with a simple check
const keepAlive = setInterval(async () => {
// Import Camoufox dynamically
let Camoufox: any;
try {
// Check if browser is still connected
if (!browser || !browser.isConnected()) {
const camoufoxModule = await import("camoufox-js");
Camoufox = camoufoxModule.Camoufox;
} catch (importError) {
// If Camoufox is not available, just keep the process alive
return;
}
// Launch Camoufox with timeout
const result = await Promise.race([
Camoufox(camoufoxOptions),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Camoufox launch timeout")), 30000),
),
]);
// Handle the result
if ("browser" in result && typeof result.browser === "function") {
context = result;
browser = context?.browser() ?? null;
} else {
browser = result as Browser;
context = await browser.newContext();
}
if (!browser) {
throw new Error("Failed to get browser instance");
}
// Update config with actual browser details
let wsEndpoint: string | undefined;
try {
const browserWithWs = browser as any;
wsEndpoint =
browserWithWs.wsEndpoint?.() || `ws://localhost:0/camoufox-${id}`;
} catch {
wsEndpoint = `ws://localhost:0/camoufox-${id}`;
}
config.wsEndpoint = wsEndpoint;
saveCamoufoxConfig(config);
// Handle URL opening if provided
if (config.url && context) {
try {
if (!page) {
page = await context.newPage();
}
await page.goto(config.url, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
} catch (error) {
// URL opening failure doesn't affect startup success
}
}
// Monitor browser connection
const keepAlive = setInterval(async () => {
try {
if (!browser || !browser.isConnected()) {
clearInterval(keepAlive);
process.exit(0);
}
} catch {
clearInterval(keepAlive);
process.exit(0);
}
} catch (error) {
// If we can't check the connection, assume it's dead
clearInterval(keepAlive);
process.exit(0);
}
}, 5000);
}, 2000);
} catch (error) {
// Browser launch failed, but worker is still "successful"
// Process will stay alive due to the main setInterval above
}
});
// Handle process staying alive
process.stdin.resume();
} catch (error) {
clearTimeout(startupTimeout);
console.error(
JSON.stringify({
error: "Failed to start Camoufox worker",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
config: config,
id: id,
}),
);
process.exit(1);
}
// Keep process alive
process.stdin.resume();
}
+51 -100
View File
@@ -1,5 +1,7 @@
import { program } from "commander";
import {
type CamoufoxLaunchOptions,
startCamoufoxProcess,
stopAllCamoufoxProcesses,
stopCamoufoxProcess,
} from "./camoufox-launcher.js";
@@ -258,13 +260,13 @@ program
if (action === "start") {
try {
// Build Camoufox options in the format expected by camoufox-js
const camoufoxOptions: Record<string, unknown> = {};
const camoufoxOptions: CamoufoxLaunchOptions = {};
// OS fingerprinting
if (options.os && typeof options.os === "string") {
camoufoxOptions.os = options.os.includes(",")
? options.os.split(",")
: options.os;
? (options.os.split(",") as ("windows" | "macos" | "linux")[])
: (options.os as "windows" | "macos" | "linux");
}
// Blocking options
@@ -278,20 +280,23 @@ program
// Geolocation
if (options.geoip) {
camoufoxOptions.geoip =
options.geoip === "auto" ? true : options.geoip;
options.geoip === "auto" ? true : (options.geoip as string);
}
if (options.latitude && options.longitude) {
camoufoxOptions.geolocation = {
latitude: options.latitude,
longitude: options.longitude,
latitude: options.latitude as number,
longitude: options.longitude as number,
accuracy: 100,
};
}
if (options.country) camoufoxOptions.country = options.country;
if (options.timezone) camoufoxOptions.timezone = options.timezone;
if (options.country)
camoufoxOptions.country = options.country as string;
if (options.timezone)
camoufoxOptions.timezone = options.timezone as string;
// UI and behavior
if (options.humanize) camoufoxOptions.humanize = options.humanize;
if (options.humanize)
camoufoxOptions.humanize = options.humanize as boolean | number;
if (options.headless) camoufoxOptions.headless = true;
// Localization
@@ -311,44 +316,54 @@ program
options.excludeAddons &&
typeof options.excludeAddons === "string"
)
camoufoxOptions.exclude_addons = options.excludeAddons.split(",");
camoufoxOptions.exclude_addons = options.excludeAddons.split(
",",
) as "UBO"[];
// Screen and window
const screen: Record<string, unknown> = {};
if (options.screenMinWidth) screen.minWidth = options.screenMinWidth;
if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth;
const screen: {
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
} = {};
if (options.screenMinWidth)
screen.minWidth = options.screenMinWidth as number;
if (options.screenMaxWidth)
screen.maxWidth = options.screenMaxWidth as number;
if (options.screenMinHeight)
screen.minHeight = options.screenMinHeight;
screen.minHeight = options.screenMinHeight as number;
if (options.screenMaxHeight)
screen.maxHeight = options.screenMaxHeight;
screen.maxHeight = options.screenMaxHeight as number;
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
if (options.windowWidth && options.windowHeight) {
camoufoxOptions.window = [
options.windowWidth,
options.windowHeight,
options.windowWidth as number,
options.windowHeight as number,
];
}
// Advanced options
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
if (options.ffVersion)
camoufoxOptions.ff_version = options.ffVersion as number;
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
if (options.webglVendor && options.webglRenderer) {
camoufoxOptions.webgl_config = [
options.webglVendor,
options.webglRenderer,
options.webglVendor as string,
options.webglRenderer as string,
];
}
// Proxy
if (options.proxy) camoufoxOptions.proxy = options.proxy;
if (options.proxy) camoufoxOptions.proxy = options.proxy as string;
// Cache and performance - default to enabled
camoufoxOptions.enable_cache = !options.disableCache;
// Environment and debugging
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay;
camoufoxOptions.virtual_display = options.virtualDisplay as string;
if (options.debug) camoufoxOptions.debug = true;
if (options.args && typeof options.args === "string")
camoufoxOptions.args = options.args.split(",");
@@ -388,91 +403,27 @@ program
}
}
// Generate a unique ID for this instance
const id = `camoufox_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
// Add profile path if provided
if (typeof options.profilePath === "string") {
camoufoxOptions.user_data_dir = options.profilePath;
}
camoufoxOptions.disableTheming = true;
camoufoxOptions.showcursor = false;
// Don't force headless mode - let the user configuration decide
if (camoufoxOptions.headless === undefined) {
camoufoxOptions.headless = false; // Default to visible
}
// Use the server-based approach via launchServer
const { launchServer } = await import("camoufox-js");
const { firefox } = await import("playwright-core");
const getPort = (await import("get-port")).default;
// Get an available port
const port = await getPort();
// Launch Camoufox server
const server = await launchServer({
...camoufoxOptions,
port: port,
ws_path: "/camoufox",
});
// Connect to the server
const browser = await firefox.connect(server.wsEndpoint());
// Open URL if provided
if (typeof options.url === "string") {
try {
const page = await browser.newPage();
await page.goto(options.url);
} catch {
// Don't fail if URL opening fails
}
} else {
// Create a blank page to keep the browser alive
try {
await browser.newPage();
} catch {
// Ignore if we can't create a page
}
}
// Use the launcher to start Camoufox properly
const config = await startCamoufoxProcess(
camoufoxOptions,
typeof options.profilePath === "string"
? options.profilePath
: undefined,
typeof options.url === "string" ? options.url : undefined,
);
// Output the configuration as JSON for the Rust side to parse
console.log(
JSON.stringify({
id: id,
port: port,
wsEndpoint: server.wsEndpoint(),
profilePath:
typeof options.profilePath === "string"
? options.profilePath
: undefined,
url: typeof options.url === "string" ? options.url : undefined,
id: config.id,
port: config.port,
wsEndpoint: config.wsEndpoint,
profilePath: config.profilePath,
url: config.url,
}),
);
// Keep the process alive by waiting for the browser to disconnect
browser.on("disconnected", () => {
process.exit(0);
});
// Keep the process alive with a simple interval
const keepAlive = setInterval(() => {
try {
if (!browser.isConnected()) {
clearInterval(keepAlive);
process.exit(0);
}
} catch {
clearInterval(keepAlive);
process.exit(0);
}
}, 5000);
// Handle process staying alive
process.stdin.resume();
process.exit(0);
} catch (error: unknown) {
console.error(
JSON.stringify({