refactor: partially migrate from launching camoufox directly to launching via playwright

This commit is contained in:
zhom
2025-07-28 06:09:40 +04:00
parent fe843e14f1
commit fcae0623c0
11 changed files with 1641 additions and 1684 deletions
+207 -77
View File
@@ -1,8 +1,17 @@
import { launchOptions } from "camoufox-js";
import { spawn } from "node:child_process";
import path from "node:path";
import {
type CamoufoxConfig,
deleteCamoufoxConfig,
generateCamoufoxId,
getCamoufoxConfig,
listCamoufoxConfigs,
saveCamoufoxConfig,
} from "./camoufox-storage.js";
export interface CamoufoxLaunchOptions {
// Operating system to use for fingerprint generation
os?: "windows" | "macos" | "linux" | string[];
os?: "windows" | "macos" | "linux"[];
// Blocking options
block_images?: boolean;
@@ -25,7 +34,7 @@ export interface CamoufoxLaunchOptions {
addons?: string[];
fonts?: string[];
custom_fonts_only?: boolean;
exclude_addons?: string[];
exclude_addons?: "UBO"[];
// Screen and window
screen?: {
@@ -36,8 +45,9 @@ export interface CamoufoxLaunchOptions {
};
window?: [number, number];
// Fingerprint
fingerprint?: any;
disableTheming?: boolean;
showcursor?: boolean;
// Version and mode
ff_version?: number;
@@ -48,7 +58,8 @@ export interface CamoufoxLaunchOptions {
executable_path?: string;
// Firefox preferences
firefox_user_prefs?: Record<string, any>;
firefox_user_prefs?: Record<string, unknown>;
user_data_dir?: string;
// Proxy settings
proxy?:
@@ -81,83 +92,202 @@ export interface CamoufoxLaunchOptions {
}
/**
* Generate Camoufox configuration using camoufox-js-lsd
* Start a Camoufox instance in a separate process
* @param options Camoufox launch options
* @param profilePath Profile directory path
* @param url Optional URL to open
* @returns Promise resolving to the Camoufox configuration
*/
export async function generateCamoufoxConfig(
export async function startCamoufoxProcess(
options: CamoufoxLaunchOptions = {},
): Promise<any> {
profilePath?: string,
url?: string,
): Promise<CamoufoxConfig> {
// Generate a unique ID for this instance
const id = generateCamoufoxId();
// Create the Camoufox configuration
const config: CamoufoxConfig = {
id,
options,
profilePath,
url,
};
// Save the configuration before starting the process
saveCamoufoxConfig(config);
// Build the command arguments
const args = [
path.join(__dirname, "index.js"),
"camoufox-worker",
"start",
"--id",
id,
];
// Spawn the process with proper detachment
const child = spawn(process.execPath, args, {
detached: true,
stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for debugging
cwd: process.cwd(),
env: { ...process.env, NODE_ENV: "production" }, // Ensure consistent environment
});
saveCamoufoxConfig(config);
// Wait for the worker to start successfully or fail
return new Promise<CamoufoxConfig>((resolve, reject) => {
let resolved = false;
let stdoutBuffer = "";
let stderrBuffer = "";
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
reject(
new Error(`Camoufox worker ${id} startup timeout after 30 seconds`),
);
}
}, 30000);
// Handle stdout - look for success JSON
if (child.stdout) {
child.stdout.on("data", (data) => {
const output = data.toString();
stdoutBuffer += output;
// Look for success JSON message
const lines = stdoutBuffer.split("\n");
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line.trim());
if (
parsed.success &&
parsed.id === id &&
parsed.port &&
parsed.wsEndpoint
) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
// Update config with server details
config.port = parsed.port;
config.wsEndpoint = parsed.wsEndpoint;
saveCamoufoxConfig(config);
child.unref(); // Allow parent to exit independently
resolve(config);
return;
}
}
} catch {
// Not JSON, continue
}
}
}
});
}
// Handle stderr - look for error JSON
if (child.stderr) {
child.stderr.on("data", (data) => {
const output = data.toString();
stderrBuffer += output;
// Look for error JSON message
const lines = stderrBuffer.split("\n");
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line.trim());
if (parsed.error && parsed.id === id) {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(
new Error(
`Camoufox worker failed: ${parsed.message || parsed.error}`,
),
);
return;
}
}
} catch {
// Not JSON, continue
}
}
}
});
}
child.on("exit", (code, signal) => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
if (code !== 0) {
reject(
new Error(
`Camoufox worker ${id} exited with code ${code} and signal ${signal}. Stderr: ${stderrBuffer}`,
),
);
} else {
// Process exited successfully but we didn't get success message
reject(
new Error(
`Camoufox worker ${id} exited without success confirmation`,
),
);
}
}
});
});
}
/**
* Stop a Camoufox process
* @param id The Camoufox ID to stop
* @returns Promise resolving to true if stopped, false if not found
*/
export async function stopCamoufoxProcess(id: string): Promise<boolean> {
const config = getCamoufoxConfig(id);
if (!config) {
return false;
}
try {
// Convert our options to camoufox-js-lsd format
const camoufoxOptions: any = {};
// Map our options to camoufox-js-lsd format
if (options.os) camoufoxOptions.os = options.os;
if (options.block_images !== undefined)
camoufoxOptions.block_images = options.block_images;
if (options.block_webrtc !== undefined)
camoufoxOptions.block_webrtc = options.block_webrtc;
if (options.block_webgl !== undefined)
camoufoxOptions.block_webgl = options.block_webgl;
if (options.disable_coop !== undefined)
camoufoxOptions.disable_coop = options.disable_coop;
if (options.geoip !== undefined) camoufoxOptions.geoip = options.geoip;
if (options.humanize !== undefined)
camoufoxOptions.humanize = options.humanize;
if (options.locale) camoufoxOptions.locale = options.locale;
if (options.addons) camoufoxOptions.addons = options.addons;
if (options.fonts) camoufoxOptions.fonts = options.fonts;
if (options.custom_fonts_only !== undefined)
camoufoxOptions.custom_fonts_only = options.custom_fonts_only;
if (options.exclude_addons)
camoufoxOptions.exclude_addons = options.exclude_addons;
if (options.screen) camoufoxOptions.screen = options.screen;
if (options.window) camoufoxOptions.window = options.window;
if (options.fingerprint) camoufoxOptions.fingerprint = options.fingerprint;
if (options.ff_version !== undefined)
camoufoxOptions.ff_version = options.ff_version;
if (options.headless !== undefined)
camoufoxOptions.headless = options.headless;
if (options.main_world_eval !== undefined)
camoufoxOptions.main_world_eval = options.main_world_eval;
if (options.executable_path)
camoufoxOptions.executable_path = options.executable_path;
if (options.firefox_user_prefs)
camoufoxOptions.firefox_user_prefs = options.firefox_user_prefs;
if (options.proxy) camoufoxOptions.proxy = options.proxy;
if (options.enable_cache !== undefined)
camoufoxOptions.enable_cache = options.enable_cache;
if (options.args) camoufoxOptions.args = options.args;
if (options.env) camoufoxOptions.env = options.env;
if (options.debug !== undefined) camoufoxOptions.debug = options.debug;
if (options.virtual_display)
camoufoxOptions.virtual_display = options.virtual_display;
if (options.webgl_config)
camoufoxOptions.webgl_config = options.webgl_config;
// Handle custom options that might need mapping
if (options.timezone) {
// If timezone is provided directly, we can set it in the generated config
// This will be handled after generation
}
if (options.country) {
// Similar for country
}
if (options.geolocation) {
// Handle geolocation coordinates
// If we have a port, try to gracefully shutdown the server
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
}
}
// Generate the configuration using camoufox-js-lsd
const generatedConfig = await launchOptions(camoufoxOptions);
// Apply any custom overrides
if (options.timezone) {
generatedConfig.env = generatedConfig.env || {};
// The timezone will be handled in the CAMOU_CONFIG environment variable
}
return generatedConfig;
// Delete the configuration
deleteCamoufoxConfig(id);
return true;
} catch (error) {
console.error(`Failed to generate Camoufox config: ${error}`);
throw error;
// Delete the configuration even if stopping failed
deleteCamoufoxConfig(id);
return false;
}
}
/**
* Stop all Camoufox processes
* @returns Promise resolving when all instances are stopped
*/
export async function stopAllCamoufoxProcesses(): Promise<void> {
const configs = listCamoufoxConfigs();
const stopPromises = configs.map((config) => stopCamoufoxProcess(config.id));
await Promise.all(stopPromises);
}
+152
View File
@@ -0,0 +1,152 @@
import fs from "node:fs";
import path from "node:path";
import tmp from "tmp";
import type { CamoufoxLaunchOptions } from "./camoufox-launcher.js";
export interface CamoufoxConfig {
id: string;
options: CamoufoxLaunchOptions;
profilePath?: string;
url?: string;
port?: number;
wsEndpoint?: string;
}
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox");
if (!fs.existsSync(STORAGE_DIR)) {
fs.mkdirSync(STORAGE_DIR, { recursive: true });
}
/**
* Save a Camoufox configuration to disk
* @param config The Camoufox configuration to save
*/
export function saveCamoufoxConfig(config: CamoufoxConfig): void {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
}
/**
* Get a Camoufox configuration by ID
* @param id The Camoufox ID
* @returns The Camoufox configuration or null if not found
*/
export function getCamoufoxConfig(id: string): CamoufoxConfig | null {
const filePath = path.join(STORAGE_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return null;
}
try {
const content = fs.readFileSync(filePath, "utf-8");
return JSON.parse(content) as CamoufoxConfig;
} catch (error) {
console.error(`Error reading Camoufox config ${id}:`, error);
return null;
}
}
/**
* Delete a Camoufox configuration
* @param id The Camoufox ID to delete
* @returns True if deleted, false if not found
*/
export function deleteCamoufoxConfig(id: string): boolean {
const filePath = path.join(STORAGE_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) {
return false;
}
try {
fs.unlinkSync(filePath);
return true;
} catch (error) {
console.error(`Error deleting Camoufox config ${id}:`, error);
return false;
}
}
/**
* List all saved Camoufox configurations
* @returns Array of Camoufox configurations
*/
export function listCamoufoxConfigs(): CamoufoxConfig[] {
if (!fs.existsSync(STORAGE_DIR)) {
return [];
}
try {
return fs
.readdirSync(STORAGE_DIR)
.filter((file) => file.endsWith(".json"))
.map((file) => {
try {
const content = fs.readFileSync(
path.join(STORAGE_DIR, file),
"utf-8",
);
return JSON.parse(content) as CamoufoxConfig;
} catch (error) {
console.error(`Error reading Camoufox config ${file}:`, error);
return null;
}
})
.filter((config): config is CamoufoxConfig => config !== null);
} catch (error) {
console.error("Error listing Camoufox configs:", error);
return [];
}
}
/**
* Update a Camoufox configuration
* @param config The Camoufox configuration to update
* @returns True if updated, false if not found
*/
export function updateCamoufoxConfig(config: CamoufoxConfig): boolean {
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
try {
fs.readFileSync(filePath, "utf-8");
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
console.error(
`Config ${config.id} was deleted while the app was running`,
);
return false;
}
console.error(`Error updating Camoufox config ${config.id}:`, error);
return false;
}
}
/**
* Check if a Camoufox server is running
* @param port The port to check
* @returns True if running, false otherwise
*/
export async function isServerRunning(port: number): Promise<boolean> {
try {
const response = await fetch(`http://localhost:${port}/json/version`, {
method: "GET",
signal: AbortSignal.timeout(1000),
});
return response.ok;
} catch {
return false;
}
}
/**
* Generate a unique ID for a Camoufox instance
* @returns A unique ID string
*/
export function generateCamoufoxId(): string {
return `camoufox_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
}
+231
View File
@@ -0,0 +1,231 @@
import { launchServer } from "camoufox-js";
import getPort from "get-port";
import type { Page } from "playwright-core";
import { firefox } from "playwright-core";
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
/**
* Run a Camoufox browser server as a worker process
* @param id The Camoufox configuration ID
*/
export async function runCamoufoxWorker(id: string): Promise<void> {
// Get the Camoufox configuration
const config = getCamoufoxConfig(id);
if (!config) {
console.error(
JSON.stringify({
error: "Configuration not found",
id: id,
}),
);
process.exit(1);
}
let server: Awaited<ReturnType<typeof launchServer>> | null = null;
let browser: Awaited<ReturnType<typeof firefox.connect>> | null = null;
// 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);
});
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
}
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);
}
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,
}),
);
}
} 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,
}),
);
}
}
// 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 () => {
try {
// Check if browser is still connected
if (!browser || !browser.isConnected()) {
clearInterval(keepAlive);
process.exit(0);
}
} catch (error) {
// If we can't check the connection, assume it's dead
clearInterval(keepAlive);
process.exit(0);
}
}, 5000);
// 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);
}
}
+276 -118
View File
@@ -1,5 +1,10 @@
import { program } from "commander";
import { generateCamoufoxConfig } from "./camoufox-launcher.js";
import {
stopAllCamoufoxProcesses,
stopCamoufoxProcess,
} from "./camoufox-launcher.js";
import { listCamoufoxConfigs } from "./camoufox-storage.js";
import { runCamoufoxWorker } from "./camoufox-worker.js";
import {
startProxyProcess,
stopAllProxyProcesses,
@@ -150,10 +155,13 @@ program
}
});
// Command for generating Camoufox configuration
// Command for Camoufox management
program
.command("camoufox-config")
.argument("<action>", "generate Camoufox configuration")
.command("camoufox")
.argument("<action>", "start, stop, or list Camoufox instances")
.option("--id <id>", "Camoufox ID for stop command")
.option("--profile-path <path>", "profile directory path")
.option("--url <url>", "URL to open")
// Operating system fingerprinting
.option(
@@ -231,130 +239,280 @@ program
// Firefox preferences
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
.description("generate Camoufox configuration using camoufox-js")
.action(async (action: string, options: any) => {
try {
if (action === "generate") {
// Build Camoufox options
const camoufoxOptions: any = {
enable_cache: !options.disableCache, // Cache enabled by default
};
// Anti-detect options
.option(
"--disable-theming",
"disable Firefox theming (required for anti-detect)",
)
.option(
"--no-showcursor",
"disable cursor display (required for anti-detect)",
)
// OS fingerprinting
if (options.os) {
camoufoxOptions.os = options.os.includes(",")
? options.os.split(",")
: options.os;
}
.description("manage Camoufox browser instances")
.action(
async (
action: string,
options: Record<string, string | number | boolean | undefined>,
) => {
if (action === "start") {
try {
// Build Camoufox options in the format expected by camoufox-js
const camoufoxOptions: Record<string, unknown> = {};
// Blocking options
if (options.blockImages) camoufoxOptions.block_images = true;
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
// Security options
if (options.disableCoop) camoufoxOptions.disable_coop = true;
// Geolocation
if (options.geoip) {
camoufoxOptions.geoip =
options.geoip === "auto" ? true : options.geoip;
}
if (options.latitude && options.longitude) {
camoufoxOptions.geolocation = {
latitude: options.latitude,
longitude: options.longitude,
accuracy: 100,
};
}
if (options.country) camoufoxOptions.country = options.country;
if (options.timezone) camoufoxOptions.timezone = options.timezone;
// UI and behavior
if (options.humanize) camoufoxOptions.humanize = options.humanize;
if (options.headless) camoufoxOptions.headless = true;
// Localization
if (options.locale) {
camoufoxOptions.locale = options.locale.includes(",")
? options.locale.split(",")
: options.locale;
}
// Extensions and fonts
if (options.addons) camoufoxOptions.addons = options.addons.split(",");
if (options.fonts) camoufoxOptions.fonts = options.fonts.split(",");
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
if (options.excludeAddons)
camoufoxOptions.exclude_addons = options.excludeAddons.split(",");
// Screen and window
const screen: any = {};
if (options.screenMinWidth) screen.minWidth = options.screenMinWidth;
if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth;
if (options.screenMinHeight) screen.minHeight = options.screenMinHeight;
if (options.screenMaxHeight) screen.maxHeight = options.screenMaxHeight;
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
if (options.windowWidth && options.windowHeight) {
camoufoxOptions.window = [options.windowWidth, options.windowHeight];
}
// Advanced options
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
if (options.webglVendor && options.webglRenderer) {
camoufoxOptions.webgl_config = [
options.webglVendor,
options.webglRenderer,
];
}
// Proxy
if (options.proxy) camoufoxOptions.proxy = options.proxy;
// Environment and debugging
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay;
if (options.debug) camoufoxOptions.debug = true;
if (options.args) camoufoxOptions.args = options.args.split(",");
if (options.env) {
try {
camoufoxOptions.env = JSON.parse(options.env);
} catch (e) {
console.error("Invalid JSON for --env option");
process.exit(1);
return;
// OS fingerprinting
if (options.os && typeof options.os === "string") {
camoufoxOptions.os = options.os.includes(",")
? options.os.split(",")
: options.os;
}
}
// Firefox preferences
if (options.firefoxPrefs) {
try {
camoufoxOptions.firefox_user_prefs = JSON.parse(
options.firefoxPrefs,
);
} catch (e) {
console.error("Invalid JSON for --firefox-prefs option");
process.exit(1);
return;
// Blocking options
if (options.blockImages) camoufoxOptions.block_images = true;
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
// Security options
if (options.disableCoop) camoufoxOptions.disable_coop = true;
// Geolocation
if (options.geoip) {
camoufoxOptions.geoip =
options.geoip === "auto" ? true : options.geoip;
}
if (options.latitude && options.longitude) {
camoufoxOptions.geolocation = {
latitude: options.latitude,
longitude: options.longitude,
accuracy: 100,
};
}
if (options.country) camoufoxOptions.country = options.country;
if (options.timezone) camoufoxOptions.timezone = options.timezone;
// UI and behavior
if (options.humanize) camoufoxOptions.humanize = options.humanize;
if (options.headless) camoufoxOptions.headless = true;
// Localization
if (options.locale && typeof options.locale === "string") {
camoufoxOptions.locale = options.locale.includes(",")
? options.locale.split(",")
: options.locale;
}
// Extensions and fonts
if (options.addons && typeof options.addons === "string")
camoufoxOptions.addons = options.addons.split(",");
if (options.fonts && typeof options.fonts === "string")
camoufoxOptions.fonts = options.fonts.split(",");
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
if (
options.excludeAddons &&
typeof options.excludeAddons === "string"
)
camoufoxOptions.exclude_addons = options.excludeAddons.split(",");
// Screen and window
const screen: Record<string, unknown> = {};
if (options.screenMinWidth) screen.minWidth = options.screenMinWidth;
if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth;
if (options.screenMinHeight)
screen.minHeight = options.screenMinHeight;
if (options.screenMaxHeight)
screen.maxHeight = options.screenMaxHeight;
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
if (options.windowWidth && options.windowHeight) {
camoufoxOptions.window = [
options.windowWidth,
options.windowHeight,
];
}
// Advanced options
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
if (options.webglVendor && options.webglRenderer) {
camoufoxOptions.webgl_config = [
options.webglVendor,
options.webglRenderer,
];
}
// Proxy
if (options.proxy) camoufoxOptions.proxy = options.proxy;
// Cache and performance - default to enabled
camoufoxOptions.enable_cache = !options.disableCache;
// Environment and debugging
if (options.virtualDisplay)
camoufoxOptions.virtual_display = options.virtualDisplay;
if (options.debug) camoufoxOptions.debug = true;
if (options.args && typeof options.args === "string")
camoufoxOptions.args = options.args.split(",");
if (options.env && typeof options.env === "string") {
try {
camoufoxOptions.env = JSON.parse(options.env);
} catch (e) {
console.error(
JSON.stringify({
error: "Invalid JSON for --env option",
message: String(e),
}),
);
process.exit(1);
return;
}
}
// Firefox preferences
if (
options.firefoxPrefs &&
typeof options.firefoxPrefs === "string"
) {
try {
camoufoxOptions.firefox_user_prefs = JSON.parse(
options.firefoxPrefs,
);
} catch (e) {
console.error(
JSON.stringify({
error: "Invalid JSON for --firefox-prefs option",
message: String(e),
}),
);
process.exit(1);
return;
}
}
// 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
}
}
// 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,
}),
);
// 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();
} catch (error: unknown) {
console.error(
JSON.stringify({
error: "Failed to start Camoufox",
message: error instanceof Error ? error.message : String(error),
}),
);
process.exit(1);
}
// Generate configuration
const config = await generateCamoufoxConfig(camoufoxOptions);
// Output the configuration as JSON
console.log(JSON.stringify(config, null, 2));
} else if (action === "stop") {
if (options.id && typeof options.id === "string") {
const stopped = await stopCamoufoxProcess(options.id);
console.log(JSON.stringify({ success: stopped }));
} else {
await stopAllCamoufoxProcesses();
console.log(JSON.stringify({ success: true }));
}
process.exit(0);
} else if (action === "list") {
const configs = listCamoufoxConfigs();
console.log(JSON.stringify(configs));
process.exit(0);
} else {
console.error("Invalid action. Use 'generate'");
console.error("Invalid action. Use 'start', 'stop', or 'list'");
process.exit(1);
}
} catch (error: unknown) {
console.error(
`Camoufox config generation failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
},
);
// Command for Camoufox worker (internal use)
program
.command("camoufox-worker")
.argument("<action>", "start a Camoufox worker")
.requiredOption("--id <id>", "Camoufox configuration ID")
.description("run a Camoufox worker process")
.action(async (action: string, options: { id: string }) => {
if (action === "start") {
await runCamoufoxWorker(options.id);
} else {
console.error("Invalid action for camoufox-worker. Use 'start'");
process.exit(1);
}
});