The daemon JS files (daemon.js, server.js, browser.js, protocol.js, snapshot.js, ipc.js) are now EMBEDDED in the compiled Rust binary via include_dir! and extracted to ~/.gyandoot/daemon/ on first run. This eliminates the need to npm install -g camoufox-browser@0.1.0, which bundles an incompatible camoufox-js@0.9.3 that caused: - 'Cannot open database' errors - Re-downloading 298MB browser on every navigation - Circular install/remove/install loops Now the daemon imports camoufox-js from the global npm install (v0.11.0) which is installed by install.sh. Clean, no bundled deps, no conflicts. Also removed: is_npm_package_installed(), install_npm_package(), find_daemon_js() — all replaced by gyandoot_browser::daemon::extract_daemon()
1028 lines
51 KiB
JavaScript
1028 lines
51 KiB
JavaScript
import * as net from "node:net";
|
|
import * as fs from "node:fs";
|
|
import { cleanupIpcFiles, ensureSocketDir, getSocketDir, getPidFile, getPortFile, getPortForSession, getUnixSocketPath, isValidSessionName, isWindows } from "./ipc.js";
|
|
import { errorResponse, parseCommand, serializeResponse, successResponse } from "./protocol.js";
|
|
import { BrowserManager } from "./browser.js";
|
|
import * as path from "node:path";
|
|
import * as fsPromises from "node:fs/promises";
|
|
/**
|
|
* Backpressure-aware socket write.
|
|
* If the kernel buffer is full, waits for 'drain' before resolving.
|
|
*/
|
|
async function safeWrite(socket, payload) {
|
|
await new Promise((resolve, reject) => {
|
|
if (socket.destroyed)
|
|
return resolve();
|
|
const ok = socket.write(payload);
|
|
if (ok)
|
|
return resolve();
|
|
const cleanup = () => {
|
|
socket.removeListener("drain", onDrain);
|
|
socket.removeListener("error", onError);
|
|
socket.removeListener("close", onClose);
|
|
};
|
|
const onDrain = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
const onError = (err) => {
|
|
cleanup();
|
|
reject(err);
|
|
};
|
|
const onClose = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
socket.once("drain", onDrain);
|
|
socket.once("error", onError);
|
|
socket.once("close", onClose);
|
|
});
|
|
}
|
|
function looksLikeHttp(line) {
|
|
return /^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH|CONNECT|TRACE)\s/i.test(line);
|
|
}
|
|
function getSession() {
|
|
const raw = process.env.CAMOUFOX_BROWSER_SESSION || process.env.AGENT_BROWSER_SESSION || "default";
|
|
if (!isValidSessionName(raw)) {
|
|
throw new Error(`Invalid session name: ${raw}`);
|
|
}
|
|
return raw;
|
|
}
|
|
const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
function getIdleTimeoutMs() {
|
|
const raw = process.env.CAMOUFOX_BROWSER_IDLE_TIMEOUT_MS || process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS;
|
|
if (raw === undefined)
|
|
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
const val = parseInt(raw, 10);
|
|
if (Number.isNaN(val))
|
|
return DEFAULT_IDLE_TIMEOUT_MS;
|
|
return val;
|
|
}
|
|
export class DaemonServer {
|
|
session;
|
|
idleTimeoutMs;
|
|
exitOnShutdown;
|
|
server = null;
|
|
shuttingDown = false;
|
|
idleTimer = null;
|
|
browser = new BrowserManager();
|
|
readyResolve;
|
|
readyPromise;
|
|
constructor(opts) {
|
|
this.session = getSession();
|
|
this.idleTimeoutMs = getIdleTimeoutMs();
|
|
this.exitOnShutdown = opts?.exitOnShutdown ?? true;
|
|
this.readyPromise = new Promise((resolve) => {
|
|
this.readyResolve = resolve;
|
|
});
|
|
}
|
|
async waitUntilReady(timeoutMs = 5000) {
|
|
await Promise.race([
|
|
this.readyPromise,
|
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Daemon did not become ready")), timeoutMs))
|
|
]);
|
|
}
|
|
async start() {
|
|
ensureSocketDir();
|
|
cleanupIpcFiles(this.session);
|
|
fs.writeFileSync(getPidFile(this.session), String(process.pid));
|
|
this.server = net.createServer((socket) => this.handleConnection(socket));
|
|
this.server.on("error", (err) => {
|
|
process.stderr.write(`[camoufox-browser] Server error: ${String(err)}\n`);
|
|
this.shutdown(1).catch(() => { });
|
|
});
|
|
if (isWindows) {
|
|
const port = getPortForSession(this.session);
|
|
fs.writeFileSync(getPortFile(this.session), String(port));
|
|
this.server.listen(port, "127.0.0.1", () => this.readyResolve());
|
|
}
|
|
else {
|
|
const sockPath = getUnixSocketPath(this.session);
|
|
this.server.listen(sockPath, () => this.readyResolve());
|
|
}
|
|
this.resetIdleTimer();
|
|
if (this.exitOnShutdown) {
|
|
const shutdown = async () => {
|
|
await this.shutdown(0);
|
|
};
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
process.on("SIGHUP", shutdown);
|
|
process.on("exit", () => {
|
|
cleanupIpcFiles(this.session);
|
|
});
|
|
}
|
|
await new Promise((resolve) => {
|
|
this.server.on("close", resolve);
|
|
});
|
|
}
|
|
resetIdleTimer() {
|
|
if (this.idleTimeoutMs <= 0)
|
|
return;
|
|
if (this.idleTimer)
|
|
clearTimeout(this.idleTimer);
|
|
this.idleTimer = setTimeout(() => {
|
|
this.shutdown(0).catch(() => { });
|
|
}, this.idleTimeoutMs);
|
|
if (this.idleTimer && typeof this.idleTimer === "object" && "unref" in this.idleTimer) {
|
|
this.idleTimer.unref();
|
|
}
|
|
}
|
|
async shutdown(exitCode) {
|
|
if (this.shuttingDown)
|
|
return;
|
|
this.shuttingDown = true;
|
|
if (this.idleTimer) {
|
|
clearTimeout(this.idleTimer);
|
|
this.idleTimer = null;
|
|
}
|
|
if (this.server) {
|
|
await new Promise((resolve) => {
|
|
try {
|
|
this.server.close(() => resolve());
|
|
}
|
|
catch {
|
|
resolve();
|
|
}
|
|
});
|
|
this.server = null;
|
|
}
|
|
cleanupIpcFiles(this.session);
|
|
if (this.exitOnShutdown) {
|
|
process.exit(exitCode);
|
|
}
|
|
}
|
|
handleConnection(socket) {
|
|
let buffer = "";
|
|
let httpChecked = false;
|
|
const commandQueue = [];
|
|
let processing = false;
|
|
const processQueue = async () => {
|
|
if (processing)
|
|
return;
|
|
processing = true;
|
|
while (commandQueue.length > 0) {
|
|
const line = commandQueue.shift();
|
|
this.resetIdleTimer();
|
|
const parseResult = parseCommand(line);
|
|
if (!parseResult.success) {
|
|
const id = parseResult.id ?? "unknown";
|
|
await safeWrite(socket, serializeResponse(errorResponse(id, parseResult.error)) + "\n");
|
|
continue;
|
|
}
|
|
const cmd = parseResult.command;
|
|
const response = await this.execute(cmd);
|
|
await safeWrite(socket, serializeResponse(response) + "\n");
|
|
if (cmd.action === "close") {
|
|
setTimeout(() => {
|
|
this.shutdown(0).catch(() => { });
|
|
}, 100);
|
|
commandQueue.length = 0;
|
|
processing = false;
|
|
return;
|
|
}
|
|
}
|
|
processing = false;
|
|
};
|
|
socket.on("data", (data) => {
|
|
buffer += data.toString();
|
|
if (!httpChecked) {
|
|
httpChecked = true;
|
|
const trimmed = buffer.trimStart();
|
|
if (looksLikeHttp(trimmed)) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
}
|
|
while (buffer.includes("\n")) {
|
|
const newlineIdx = buffer.indexOf("\n");
|
|
const line = buffer.slice(0, newlineIdx).trim();
|
|
buffer = buffer.slice(newlineIdx + 1);
|
|
if (!line)
|
|
continue;
|
|
commandQueue.push(line);
|
|
}
|
|
processQueue().catch((err) => {
|
|
process.stderr.write(`[camoufox-browser] processQueue error: ${err?.message ?? String(err)}\n`);
|
|
});
|
|
});
|
|
}
|
|
async execute(cmd) {
|
|
try {
|
|
if (cmd.action === "close") {
|
|
await this.browser.close();
|
|
return successResponse(cmd.id, { closed: true });
|
|
}
|
|
const needsBrowser = cmd.action === "launch" ||
|
|
cmd.action === "navigate" ||
|
|
cmd.action === "back" ||
|
|
cmd.action === "forward" ||
|
|
cmd.action === "reload" ||
|
|
cmd.action === "tab_new" ||
|
|
cmd.action === "tab_list" ||
|
|
cmd.action === "tab_switch" ||
|
|
cmd.action === "tab_close" ||
|
|
cmd.action === "url" ||
|
|
cmd.action === "title" ||
|
|
cmd.action === "snapshot" ||
|
|
cmd.action === "evaluate" ||
|
|
cmd.action === "scroll" ||
|
|
cmd.action === "scrollintoview" ||
|
|
cmd.action === "click" ||
|
|
cmd.action === "dblclick" ||
|
|
cmd.action === "focus" ||
|
|
cmd.action === "drag" ||
|
|
cmd.action === "upload" ||
|
|
cmd.action === "download" ||
|
|
cmd.action === "frame" ||
|
|
cmd.action === "mainframe" ||
|
|
cmd.action === "dialog" ||
|
|
cmd.action === "console" ||
|
|
cmd.action === "errors" ||
|
|
cmd.action === "highlight" ||
|
|
cmd.action === "cookies_get" ||
|
|
cmd.action === "cookies_set" ||
|
|
cmd.action === "cookies_clear" ||
|
|
cmd.action === "storage_get" ||
|
|
cmd.action === "storage_set" ||
|
|
cmd.action === "storage_clear" ||
|
|
cmd.action === "fill" ||
|
|
cmd.action === "type" ||
|
|
cmd.action === "press" ||
|
|
cmd.action === "keydown" ||
|
|
cmd.action === "keyup" ||
|
|
cmd.action === "keyboard" ||
|
|
cmd.action === "hover" ||
|
|
cmd.action === "check" ||
|
|
cmd.action === "uncheck" ||
|
|
cmd.action === "select" ||
|
|
cmd.action === "gettext" ||
|
|
cmd.action === "innerhtml" ||
|
|
cmd.action === "inputvalue" ||
|
|
cmd.action === "getattribute" ||
|
|
cmd.action === "count" ||
|
|
cmd.action === "boundingbox" ||
|
|
cmd.action === "styles" ||
|
|
cmd.action === "isvisible" ||
|
|
cmd.action === "isenabled" ||
|
|
cmd.action === "ischecked" ||
|
|
cmd.action === "wait" ||
|
|
cmd.action === "waitforurl" ||
|
|
cmd.action === "waitforloadstate" ||
|
|
cmd.action === "screenshot";
|
|
const desiredHeadless = typeof cmd.headless === "boolean" ? cmd.headless : undefined;
|
|
// Auto-launch on the first browser-dependent command for drop-in ergonomics.
|
|
if (needsBrowser && !this.browser.isLaunched() && cmd.action !== "launch") {
|
|
const cfgFromEnv = getLaunchConfigFromEnv();
|
|
await this.browser.launch(desiredHeadless === undefined ? cfgFromEnv : { ...cfgFromEnv, headless: desiredHeadless });
|
|
}
|
|
// If caller explicitly requests headless/headed, enforce it.
|
|
// When a browser is already running with a different mode, we either:
|
|
// - relaunch automatically for navigate/launch (since navigation will happen anyway), or
|
|
// - return an actionable error for other commands to avoid surprising state loss.
|
|
if (needsBrowser && desiredHeadless !== undefined && this.browser.isLaunched()) {
|
|
const current = this.browser.getLaunchConfig();
|
|
if (current && current.headless !== desiredHeadless) {
|
|
if (cmd.action === "navigate" || cmd.action === "launch") {
|
|
const relaunchCfg = { ...current, headless: desiredHeadless };
|
|
await this.browser.close();
|
|
await this.browser.launch(relaunchCfg);
|
|
}
|
|
else {
|
|
if (envIsTruthy("CAMOUFOX_BROWSER_DEBUG") || envIsTruthy("AGENT_BROWSER_DEBUG")) {
|
|
const currentMode = current.headless ? "headless" : "headed";
|
|
const desiredMode = desiredHeadless ? "headless" : "headed";
|
|
process.stderr.write(`[camoufox-browser] requested ${desiredMode} but session is already ${currentMode}; keeping existing browser. (Hint: run 'camoufox-browser close --session ${this.session}' to restart.)\n`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Recover from stale state (launched but no pages).
|
|
if (needsBrowser && this.browser.isLaunched() && !this.browser.hasPages() && cmd.action !== "launch") {
|
|
await this.browser.ensurePage();
|
|
}
|
|
switch (cmd.action) {
|
|
case "launch": {
|
|
const cfgFromEnv = getLaunchConfigFromEnv();
|
|
const override = coerceLaunchConfig(cmd);
|
|
const cfg = override ? { ...cfgFromEnv, headless: override.headless } : cfgFromEnv;
|
|
await this.browser.launch(cfg);
|
|
return successResponse(cmd.id, { launched: true });
|
|
}
|
|
case "navigate": {
|
|
const url = typeof cmd.url === "string" ? cmd.url : "";
|
|
if (!url)
|
|
return errorResponse(cmd.id, "Missing url");
|
|
const waitUntil = cmd.waitUntil === "load" || cmd.waitUntil === "domcontentloaded" || cmd.waitUntil === "networkidle"
|
|
? cmd.waitUntil
|
|
: undefined;
|
|
const headers = cmd.headers && typeof cmd.headers === "object" && cmd.headers !== null ? cmd.headers : undefined;
|
|
const result = await this.browser.navigate(url, { waitUntil, headers });
|
|
return successResponse(cmd.id, result);
|
|
}
|
|
case "back": {
|
|
const page = this.browser.getPage();
|
|
await page.goBack();
|
|
return successResponse(cmd.id, { url: page.url() });
|
|
}
|
|
case "forward": {
|
|
const page = this.browser.getPage();
|
|
await page.goForward();
|
|
return successResponse(cmd.id, { url: page.url() });
|
|
}
|
|
case "reload": {
|
|
const page = this.browser.getPage();
|
|
await page.reload();
|
|
return successResponse(cmd.id, { url: page.url() });
|
|
}
|
|
case "tab_new": {
|
|
const result = await this.browser.newTabManaged();
|
|
if (typeof cmd.url === "string" && cmd.url.length > 0) {
|
|
const page = this.browser.getPage();
|
|
await page.goto(cmd.url, { waitUntil: "domcontentloaded" });
|
|
}
|
|
return successResponse(cmd.id, result);
|
|
}
|
|
case "tab_list": {
|
|
const tabs = await this.browser.listTabs();
|
|
return successResponse(cmd.id, { tabs, active: this.browser.getActiveIndex() });
|
|
}
|
|
case "tab_switch": {
|
|
const index = typeof cmd.index === "number" ? cmd.index : NaN;
|
|
if (!Number.isFinite(index))
|
|
return errorResponse(cmd.id, "Missing index");
|
|
const result = await this.browser.switchTo(index);
|
|
const page = this.browser.getPage();
|
|
return successResponse(cmd.id, { ...result, title: await page.title().catch(() => "") });
|
|
}
|
|
case "tab_close": {
|
|
const rawIndex = cmd.index;
|
|
const index = typeof rawIndex === "number" ? rawIndex : undefined;
|
|
const result = await this.browser.closeTab(index);
|
|
return successResponse(cmd.id, result);
|
|
}
|
|
case "frame": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.switchToFrame({ selector });
|
|
return successResponse(cmd.id, { switched: true });
|
|
}
|
|
case "mainframe": {
|
|
this.browser.switchToMainFrame();
|
|
return successResponse(cmd.id, { switched: true });
|
|
}
|
|
case "dialog": {
|
|
const response = cmd.response === "accept" || cmd.response === "dismiss"
|
|
? cmd.response
|
|
: null;
|
|
if (!response)
|
|
return errorResponse(cmd.id, "Missing response");
|
|
const promptText = typeof cmd.promptText === "string" ? cmd.promptText : undefined;
|
|
this.browser.setDialogHandler(response, promptText);
|
|
return successResponse(cmd.id, { handler: "set", response });
|
|
}
|
|
case "url": {
|
|
const page = this.browser.getPage();
|
|
return successResponse(cmd.id, { url: page.url() });
|
|
}
|
|
case "title": {
|
|
const page = this.browser.getPage();
|
|
return successResponse(cmd.id, { title: await page.title() });
|
|
}
|
|
case "snapshot": {
|
|
const interactive = typeof cmd.interactive === "boolean" ? cmd.interactive : undefined;
|
|
const cursor = typeof cmd.cursor === "boolean" ? cmd.cursor : undefined;
|
|
const compact = typeof cmd.compact === "boolean" ? cmd.compact : undefined;
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : undefined;
|
|
const maxDepth = typeof cmd.maxDepth === "number" ? cmd.maxDepth : undefined;
|
|
const { tree, refs } = await this.browser.getSnapshot({
|
|
interactive,
|
|
cursor,
|
|
compact,
|
|
selector,
|
|
maxDepth
|
|
});
|
|
const simpleRefs = {};
|
|
for (const [ref, data] of Object.entries(refs)) {
|
|
simpleRefs[ref] = { role: data.role, name: data.name };
|
|
}
|
|
const page = this.browser.getPage();
|
|
return successResponse(cmd.id, {
|
|
snapshot: tree || "Empty page",
|
|
refs: Object.keys(simpleRefs).length > 0 ? simpleRefs : undefined,
|
|
origin: page.url()
|
|
});
|
|
}
|
|
case "console": {
|
|
const clear = cmd.clear === true;
|
|
if (clear) {
|
|
this.browser.clearConsoleMessages();
|
|
return successResponse(cmd.id, { cleared: true });
|
|
}
|
|
const page = this.browser.getPage();
|
|
const messages = this.browser.getConsoleMessages();
|
|
return successResponse(cmd.id, { messages, origin: page.url() });
|
|
}
|
|
case "errors": {
|
|
const clear = cmd.clear === true;
|
|
if (clear) {
|
|
this.browser.clearPageErrors();
|
|
return successResponse(cmd.id, { cleared: true });
|
|
}
|
|
const errors = this.browser.getPageErrors();
|
|
return successResponse(cmd.id, { errors });
|
|
}
|
|
case "highlight": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).highlight();
|
|
return successResponse(cmd.id, { highlighted: true });
|
|
}
|
|
case "cookies_get": {
|
|
const page = this.browser.getPage();
|
|
const context = page.context();
|
|
const urlsRaw = cmd.urls;
|
|
const urls = Array.isArray(urlsRaw) && urlsRaw.every((u) => typeof u === "string") ? urlsRaw : undefined;
|
|
const cookies = urls ? await context.cookies(urls) : await context.cookies();
|
|
return successResponse(cmd.id, { cookies });
|
|
}
|
|
case "cookies_set": {
|
|
const page = this.browser.getPage();
|
|
const context = page.context();
|
|
const cookiesRaw = cmd.cookies;
|
|
if (!Array.isArray(cookiesRaw) || cookiesRaw.length === 0)
|
|
return errorResponse(cmd.id, "Missing cookies");
|
|
const pageUrl = page.url();
|
|
const cookies = cookiesRaw
|
|
.filter((c) => typeof c === "object" &&
|
|
c !== null &&
|
|
typeof c.name === "string" &&
|
|
typeof c.value === "string")
|
|
.map((cookie) => {
|
|
if (!cookie.url && !cookie.domain && !cookie.path) {
|
|
return { ...cookie, url: pageUrl };
|
|
}
|
|
return cookie;
|
|
});
|
|
if (cookies.length === 0)
|
|
return errorResponse(cmd.id, "Missing cookies");
|
|
await context.addCookies(cookies);
|
|
return successResponse(cmd.id, { set: true });
|
|
}
|
|
case "cookies_clear": {
|
|
const page = this.browser.getPage();
|
|
const context = page.context();
|
|
await context.clearCookies();
|
|
return successResponse(cmd.id, { cleared: true });
|
|
}
|
|
case "storage_get": {
|
|
const page = this.browser.getPage();
|
|
const type = typeof cmd.type === "string" ? cmd.type : "";
|
|
if (type !== "local" && type !== "session")
|
|
return errorResponse(cmd.id, "Missing storage type");
|
|
const storageType = type === "local" ? "localStorage" : "sessionStorage";
|
|
const key = typeof cmd.key === "string" ? cmd.key : "";
|
|
if (key) {
|
|
const value = await page.evaluate(`${storageType}.getItem(${JSON.stringify(key)})`);
|
|
return successResponse(cmd.id, { key, value });
|
|
}
|
|
const data = await page.evaluate(`
|
|
(() => {
|
|
const storage = ${storageType};
|
|
const result = {};
|
|
for (let i = 0; i < storage.length; i++) {
|
|
const k = storage.key(i);
|
|
if (k) result[k] = storage.getItem(k);
|
|
}
|
|
return result;
|
|
})()
|
|
`);
|
|
return successResponse(cmd.id, { data });
|
|
}
|
|
case "storage_set": {
|
|
const page = this.browser.getPage();
|
|
const type = typeof cmd.type === "string" ? cmd.type : "";
|
|
if (type !== "local" && type !== "session")
|
|
return errorResponse(cmd.id, "Missing storage type");
|
|
const storageType = type === "local" ? "localStorage" : "sessionStorage";
|
|
const key = typeof cmd.key === "string" ? cmd.key : "";
|
|
const value = typeof cmd.value === "string" ? cmd.value : "";
|
|
if (!key)
|
|
return errorResponse(cmd.id, "Missing key");
|
|
await page.evaluate(`${storageType}.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`);
|
|
return successResponse(cmd.id, { set: true });
|
|
}
|
|
case "storage_clear": {
|
|
const page = this.browser.getPage();
|
|
const type = typeof cmd.type === "string" ? cmd.type : "";
|
|
if (type !== "local" && type !== "session")
|
|
return errorResponse(cmd.id, "Missing storage type");
|
|
const storageType = type === "local" ? "localStorage" : "sessionStorage";
|
|
await page.evaluate(`${storageType}.clear()`);
|
|
return successResponse(cmd.id, { cleared: true });
|
|
}
|
|
case "evaluate": {
|
|
const page = this.browser.getPage();
|
|
const script = typeof cmd.script === "string" ? cmd.script : "";
|
|
if (!script)
|
|
return errorResponse(cmd.id, "Missing script");
|
|
const result = await page.evaluate(script);
|
|
return successResponse(cmd.id, { result, origin: page.url() });
|
|
}
|
|
case "scroll": {
|
|
const page = this.browser.getPage();
|
|
const direction = typeof cmd.direction === "string" ? cmd.direction : "down";
|
|
const amount = typeof cmd.amount === "number" ? cmd.amount : 300;
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : undefined;
|
|
let deltaX = 0;
|
|
let deltaY = 0;
|
|
switch (direction) {
|
|
case "up":
|
|
deltaY = -amount;
|
|
break;
|
|
case "down":
|
|
deltaY = amount;
|
|
break;
|
|
case "left":
|
|
deltaX = -amount;
|
|
break;
|
|
case "right":
|
|
deltaX = amount;
|
|
break;
|
|
default:
|
|
deltaY = amount;
|
|
break;
|
|
}
|
|
if (selector) {
|
|
const element = this.browser.getLocator(selector);
|
|
await element.scrollIntoViewIfNeeded();
|
|
await element.evaluate((el, { x, y }) => {
|
|
el.scrollBy(x, y);
|
|
}, { x: deltaX, y: deltaY });
|
|
}
|
|
else {
|
|
await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
|
|
}
|
|
return successResponse(cmd.id, { scrolled: true });
|
|
}
|
|
case "scrollintoview": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).scrollIntoViewIfNeeded();
|
|
return successResponse(cmd.id, { scrolled: true });
|
|
}
|
|
case "click": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const locator = this.browser.getLocator(selector);
|
|
if (cmd.newTab === true) {
|
|
const fullUrl = await locator.evaluate((el) => {
|
|
const href = el.getAttribute("href");
|
|
return href ? new globalThis.URL(href, globalThis.document.baseURI).toString() : "";
|
|
});
|
|
if (!fullUrl) {
|
|
return errorResponse(cmd.id, `Element '${selector}' does not have an href attribute. --new-tab only works on links.`);
|
|
}
|
|
await this.browser.newTab();
|
|
const newPage = this.browser.getPage();
|
|
await newPage.goto(fullUrl);
|
|
return successResponse(cmd.id, { clicked: true, newTab: true, url: fullUrl });
|
|
}
|
|
await locator.click();
|
|
return successResponse(cmd.id, { clicked: true });
|
|
}
|
|
case "dblclick": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).dblclick();
|
|
return successResponse(cmd.id, { clicked: true });
|
|
}
|
|
case "focus": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).focus();
|
|
return successResponse(cmd.id, { focused: true });
|
|
}
|
|
case "drag": {
|
|
const source = typeof cmd.source === "string" ? cmd.source : "";
|
|
const target = typeof cmd.target === "string" ? cmd.target : "";
|
|
if (!source || !target)
|
|
return errorResponse(cmd.id, "Missing source/target");
|
|
await this.browser.getLocator(source).dragTo(this.browser.getLocator(target));
|
|
return successResponse(cmd.id, { dragged: true });
|
|
}
|
|
case "upload": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const filesRaw = cmd.files;
|
|
const files = typeof filesRaw === "string"
|
|
? [filesRaw]
|
|
: Array.isArray(filesRaw) && filesRaw.every((f) => typeof f === "string")
|
|
? filesRaw
|
|
: [];
|
|
if (files.length === 0)
|
|
return errorResponse(cmd.id, "Missing files");
|
|
await this.browser.getLocator(selector).setInputFiles(files);
|
|
return successResponse(cmd.id, { uploaded: files });
|
|
}
|
|
case "download": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const outPath = typeof cmd.path === "string" ? cmd.path : "";
|
|
if (!outPath)
|
|
return errorResponse(cmd.id, "Missing path");
|
|
const page = this.browser.getPage();
|
|
const locator = this.browser.getLocator(selector);
|
|
await fsPromises.mkdir(path.dirname(outPath), { recursive: true });
|
|
const [download] = await Promise.all([page.waitForEvent("download"), locator.click()]);
|
|
await download.saveAs(outPath);
|
|
return successResponse(cmd.id, {
|
|
path: outPath,
|
|
suggestedFilename: download.suggestedFilename()
|
|
});
|
|
}
|
|
case "fill": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const value = typeof cmd.value === "string" ? cmd.value : "";
|
|
await this.browser.getLocator(selector).fill(value);
|
|
return successResponse(cmd.id, { filled: true });
|
|
}
|
|
case "type": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const text = typeof cmd.text === "string" ? cmd.text : "";
|
|
await this.browser.getLocator(selector).pressSequentially(text);
|
|
return successResponse(cmd.id, { typed: true });
|
|
}
|
|
case "press": {
|
|
const key = typeof cmd.key === "string" ? cmd.key : "";
|
|
if (!key)
|
|
return errorResponse(cmd.id, "Missing key");
|
|
if (typeof cmd.selector === "string" && cmd.selector.length > 0) {
|
|
await this.browser.getLocator(cmd.selector).press(key);
|
|
}
|
|
else {
|
|
await this.browser.getPage().keyboard.press(key);
|
|
}
|
|
return successResponse(cmd.id, { pressed: true });
|
|
}
|
|
case "keydown": {
|
|
const key = typeof cmd.key === "string" ? cmd.key : "";
|
|
if (!key)
|
|
return errorResponse(cmd.id, "Missing key");
|
|
const page = this.browser.getPage();
|
|
await page.keyboard.down(key);
|
|
return successResponse(cmd.id, { down: true, key });
|
|
}
|
|
case "keyup": {
|
|
const key = typeof cmd.key === "string" ? cmd.key : "";
|
|
if (!key)
|
|
return errorResponse(cmd.id, "Missing key");
|
|
const page = this.browser.getPage();
|
|
await page.keyboard.up(key);
|
|
return successResponse(cmd.id, { up: true, key });
|
|
}
|
|
case "keyboard": {
|
|
const page = this.browser.getPage();
|
|
const subaction = typeof cmd.subaction === "string" ? (cmd.subaction || "type") : "type";
|
|
const text = typeof cmd.text === "string" ? cmd.text : "";
|
|
if (subaction === "type") {
|
|
await page.keyboard.type(text);
|
|
return successResponse(cmd.id, { typed: true, text });
|
|
}
|
|
if (subaction === "insertText") {
|
|
await page.keyboard.insertText(text);
|
|
return successResponse(cmd.id, { inserted: true, text });
|
|
}
|
|
return errorResponse(cmd.id, `Unknown keyboard subaction: ${subaction}`);
|
|
}
|
|
case "hover": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).hover();
|
|
return successResponse(cmd.id, { hovered: true });
|
|
}
|
|
case "check": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).check();
|
|
return successResponse(cmd.id, { checked: true });
|
|
}
|
|
case "uncheck": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
await this.browser.getLocator(selector).uncheck();
|
|
return successResponse(cmd.id, { checked: false });
|
|
}
|
|
case "select": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const valuesRaw = cmd.values;
|
|
if (typeof valuesRaw === "string") {
|
|
await this.browser.getLocator(selector).selectOption(valuesRaw);
|
|
}
|
|
else if (Array.isArray(valuesRaw) && valuesRaw.every((v) => typeof v === "string")) {
|
|
await this.browser.getLocator(selector).selectOption(valuesRaw);
|
|
}
|
|
else {
|
|
return errorResponse(cmd.id, "Missing values");
|
|
}
|
|
return successResponse(cmd.id, { selected: true });
|
|
}
|
|
case "gettext": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const page = this.browser.getPage();
|
|
const locator = this.browser.getLocator(selector);
|
|
const inner = await locator.innerText();
|
|
const text = inner || (await locator.textContent()) || "";
|
|
return successResponse(cmd.id, { text, origin: page.url() });
|
|
}
|
|
case "innerhtml": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const page = this.browser.getPage();
|
|
const html = await this.browser.getLocator(selector).innerHTML();
|
|
return successResponse(cmd.id, { html, origin: page.url() });
|
|
}
|
|
case "inputvalue": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const page = this.browser.getPage();
|
|
const value = await this.browser.getLocator(selector).inputValue();
|
|
return successResponse(cmd.id, { value, origin: page.url() });
|
|
}
|
|
case "getattribute": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const attribute = typeof cmd.attribute === "string" ? cmd.attribute : "";
|
|
if (!attribute)
|
|
return errorResponse(cmd.id, "Missing attribute");
|
|
const page = this.browser.getPage();
|
|
const value = await this.browser.getLocator(selector).getAttribute(attribute);
|
|
return successResponse(cmd.id, { attribute, value, origin: page.url() });
|
|
}
|
|
case "count": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const count = await this.browser.getLocator(selector).count();
|
|
return successResponse(cmd.id, { count });
|
|
}
|
|
case "boundingbox": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const box = await this.browser.getLocator(selector).boundingBox();
|
|
return successResponse(cmd.id, { box });
|
|
}
|
|
case "styles": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const page = this.browser.getPage();
|
|
const extractStylesScript = `(function(el) {
|
|
const s = getComputedStyle(el);
|
|
const r = el.getBoundingClientRect();
|
|
return {
|
|
tag: el.tagName.toLowerCase(),
|
|
text: (el.innerText || '').trim().slice(0, 80) || null,
|
|
box: {
|
|
x: Math.round(r.x),
|
|
y: Math.round(r.y),
|
|
width: Math.round(r.width),
|
|
height: Math.round(r.height),
|
|
},
|
|
styles: {
|
|
fontSize: s.fontSize,
|
|
fontWeight: s.fontWeight,
|
|
fontFamily: (s.fontFamily || '').split(',')[0].trim().replace(/\"/g, ''),
|
|
color: s.color,
|
|
backgroundColor: s.backgroundColor,
|
|
borderRadius: s.borderRadius,
|
|
border: s.border !== 'none' && s.borderWidth !== '0px' ? s.border : null,
|
|
boxShadow: s.boxShadow !== 'none' ? s.boxShadow : null,
|
|
padding: s.padding,
|
|
},
|
|
};
|
|
})`;
|
|
const refLocator = this.browser.getLocatorFromRef(selector);
|
|
if (refLocator) {
|
|
const element = await refLocator.evaluate((el, script) => {
|
|
const fn = eval(script);
|
|
return fn(el);
|
|
}, extractStylesScript);
|
|
return successResponse(cmd.id, { elements: [element] });
|
|
}
|
|
const elements = await page.$$eval(selector, (els, script) => {
|
|
const fn = eval(script);
|
|
return els.map((el) => fn(el));
|
|
}, extractStylesScript);
|
|
return successResponse(cmd.id, { elements });
|
|
}
|
|
case "isvisible": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const visible = await this.browser.getLocator(selector).isVisible();
|
|
return successResponse(cmd.id, { visible });
|
|
}
|
|
case "isenabled": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const enabled = await this.browser.getLocator(selector).isEnabled();
|
|
return successResponse(cmd.id, { enabled });
|
|
}
|
|
case "ischecked": {
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : "";
|
|
if (!selector)
|
|
return errorResponse(cmd.id, "Missing selector");
|
|
const checked = await this.browser.getLocator(selector).isChecked();
|
|
return successResponse(cmd.id, { checked });
|
|
}
|
|
case "wait": {
|
|
const page = this.browser.getPage();
|
|
const frame = this.browser.getFrame();
|
|
const timeout = typeof cmd.timeout === "number" ? cmd.timeout : undefined;
|
|
if (typeof cmd.text === "string" && cmd.text.length > 0) {
|
|
await page.waitForFunction(`(document.body && (document.body.innerText || '')).includes(${JSON.stringify(cmd.text)})`, { timeout });
|
|
return successResponse(cmd.id, { waited: true });
|
|
}
|
|
if (typeof cmd.selector === "string" && cmd.selector.length > 0) {
|
|
const state = cmd.state === "attached" || cmd.state === "detached" || cmd.state === "visible" || cmd.state === "hidden"
|
|
? cmd.state
|
|
: "visible";
|
|
const locator = this.browser.getLocatorFromRef(cmd.selector);
|
|
if (locator) {
|
|
await locator.waitFor({ state, timeout });
|
|
}
|
|
else {
|
|
await frame.waitForSelector(cmd.selector, { state, timeout });
|
|
}
|
|
return successResponse(cmd.id, { waited: true });
|
|
}
|
|
if (timeout !== undefined) {
|
|
await page.waitForTimeout(timeout);
|
|
return successResponse(cmd.id, { waited: true });
|
|
}
|
|
await page.waitForLoadState("load");
|
|
return successResponse(cmd.id, { waited: true });
|
|
}
|
|
case "waitforurl": {
|
|
const page = this.browser.getPage();
|
|
const url = typeof cmd.url === "string" ? cmd.url : "";
|
|
if (!url)
|
|
return errorResponse(cmd.id, "Missing url");
|
|
const timeout = typeof cmd.timeout === "number" ? cmd.timeout : undefined;
|
|
await page.waitForURL(url, { timeout });
|
|
return successResponse(cmd.id, { url: page.url() });
|
|
}
|
|
case "waitforloadstate": {
|
|
const page = this.browser.getPage();
|
|
const state = typeof cmd.state === "string" ? cmd.state : "";
|
|
if (!state)
|
|
return errorResponse(cmd.id, "Missing state");
|
|
const timeout = typeof cmd.timeout === "number" ? cmd.timeout : undefined;
|
|
await page.waitForLoadState(state, { timeout });
|
|
return successResponse(cmd.id, { state });
|
|
}
|
|
case "screenshot": {
|
|
const page = this.browser.getPage();
|
|
const selector = typeof cmd.selector === "string" ? cmd.selector : undefined;
|
|
const fullPage = cmd.fullPage === true;
|
|
const requestedFormat = typeof cmd.format === "string" ? cmd.format : undefined;
|
|
if (requestedFormat === "webp") {
|
|
return errorResponse(cmd.id, "webp screenshots are not supported by this Playwright build");
|
|
}
|
|
const format = requestedFormat === "jpeg" ? "jpeg" : "png";
|
|
const quality = typeof cmd.quality === "number" ? cmd.quality : undefined;
|
|
const pathArg = typeof cmd.path === "string" ? cmd.path : undefined;
|
|
const screenshotDirRaw = cmd.screenshotDir;
|
|
const screenshotDir = typeof screenshotDirRaw === "string"
|
|
? screenshotDirRaw
|
|
: path.join(getSocketDir(), "tmp", "screenshots");
|
|
let savePath = pathArg;
|
|
if (!savePath) {
|
|
const ext = format === "jpeg" ? "jpg" : format;
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const random = Math.random().toString(36).slice(2, 8);
|
|
savePath = path.join(screenshotDir, `screenshot-${timestamp}-${random}.${ext}`);
|
|
}
|
|
await fsPromises.mkdir(path.dirname(savePath), { recursive: true });
|
|
const options = {
|
|
path: savePath,
|
|
type: format,
|
|
...(format === "jpeg" && quality !== undefined ? { quality } : {}),
|
|
...(selector ? {} : { fullPage })
|
|
};
|
|
if (selector) {
|
|
await this.browser.getLocator(selector).screenshot(options);
|
|
}
|
|
else {
|
|
await page.screenshot(options);
|
|
}
|
|
return successResponse(cmd.id, { path: savePath });
|
|
}
|
|
default:
|
|
return errorResponse(cmd.id, `Not yet implemented: ${cmd.action}`);
|
|
}
|
|
}
|
|
catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return errorResponse(cmd.id, message);
|
|
}
|
|
}
|
|
}
|
|
function envIsTruthy(name) {
|
|
const raw = process.env[name];
|
|
if (!raw)
|
|
return false;
|
|
const v = raw.trim().toLowerCase();
|
|
return v !== "" && v !== "0" && v !== "false" && v !== "no";
|
|
}
|
|
function getLaunchConfigFromEnv() {
|
|
const headed = envIsTruthy("CAMOUFOX_BROWSER_HEADED") || envIsTruthy("AGENT_BROWSER_HEADED");
|
|
const debug = envIsTruthy("CAMOUFOX_BROWSER_DEBUG") || envIsTruthy("AGENT_BROWSER_DEBUG");
|
|
const argsEnv = process.env.CAMOUFOX_BROWSER_ARGS || process.env.AGENT_BROWSER_ARGS;
|
|
const args = argsEnv && argsEnv.trim().length > 0
|
|
? argsEnv
|
|
.split(/[,\n]/)
|
|
.map((a) => a.trim())
|
|
.filter(Boolean)
|
|
: undefined;
|
|
const proxyServer = process.env.CAMOUFOX_BROWSER_PROXY || process.env.AGENT_BROWSER_PROXY;
|
|
const proxyBypass = process.env.CAMOUFOX_BROWSER_PROXY_BYPASS || process.env.AGENT_BROWSER_PROXY_BYPASS;
|
|
const proxy = proxyServer
|
|
? {
|
|
server: proxyServer,
|
|
...(proxyBypass ? { bypass: proxyBypass } : {})
|
|
}
|
|
: undefined;
|
|
const ignoreHTTPSErrors = envIsTruthy("CAMOUFOX_BROWSER_IGNORE_HTTPS_ERRORS") ||
|
|
envIsTruthy("AGENT_BROWSER_IGNORE_HTTPS_ERRORS");
|
|
const config = {
|
|
headless: !headed,
|
|
executablePath: process.env.CAMOUFOX_BROWSER_EXECUTABLE_PATH || process.env.AGENT_BROWSER_EXECUTABLE_PATH,
|
|
profile: process.env.CAMOUFOX_BROWSER_PROFILE || process.env.AGENT_BROWSER_PROFILE,
|
|
storageState: process.env.CAMOUFOX_BROWSER_STATE || process.env.AGENT_BROWSER_STATE,
|
|
args,
|
|
userAgent: process.env.CAMOUFOX_BROWSER_USER_AGENT || process.env.AGENT_BROWSER_USER_AGENT,
|
|
proxy,
|
|
ignoreHTTPSErrors
|
|
};
|
|
if (debug) {
|
|
process.stderr.write(`[camoufox-browser] launch config: ${JSON.stringify(config)}\n`);
|
|
}
|
|
return config;
|
|
}
|
|
function coerceLaunchConfig(cmd) {
|
|
const anyCmd = cmd;
|
|
const headless = typeof anyCmd.headless === "boolean" ? anyCmd.headless : undefined;
|
|
if (headless === undefined)
|
|
return null;
|
|
const proxy = (() => {
|
|
const p = anyCmd.proxy;
|
|
if (!p || typeof p !== "object")
|
|
return undefined;
|
|
const server = typeof p.server === "string" ? p.server : undefined;
|
|
if (!server)
|
|
return undefined;
|
|
const bypass = typeof p.bypass === "string" ? p.bypass : undefined;
|
|
const username = typeof p.username === "string" ? p.username : undefined;
|
|
const password = typeof p.password === "string" ? p.password : undefined;
|
|
return { server, ...(bypass ? { bypass } : {}), ...(username ? { username } : {}), ...(password ? { password } : {}) };
|
|
})();
|
|
const args = Array.isArray(anyCmd.args) && anyCmd.args.every((a) => typeof a === "string") ? anyCmd.args : undefined;
|
|
return {
|
|
headless,
|
|
executablePath: typeof anyCmd.executablePath === "string" ? anyCmd.executablePath : undefined,
|
|
profile: typeof anyCmd.profile === "string" ? anyCmd.profile : undefined,
|
|
storageState: typeof anyCmd.storageState === "string" ? anyCmd.storageState : undefined,
|
|
proxy,
|
|
args,
|
|
userAgent: typeof anyCmd.userAgent === "string" ? anyCmd.userAgent : undefined,
|
|
headers: typeof anyCmd.headers === "object" && anyCmd.headers !== null ? anyCmd.headers : undefined,
|
|
ignoreHTTPSErrors: typeof anyCmd.ignoreHTTPSErrors === "boolean" ? anyCmd.ignoreHTTPSErrors : undefined
|
|
};
|
|
}
|