Files
gnome-jarvis/browser-use/client.js
T
snxraven aa06c71fe1
Rolling release / release (push) Failing after 1m46s
obsidian
2026-09-14 11:24:10 -04:00

223 lines
8.0 KiB
JavaScript

import { spawn } from 'node:child_process';
import { existsSync, readdirSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const HELPER = path.resolve(new URL('./helper.js', import.meta.url).pathname);
const MISSING_NODE = 'Node.js is not available to jarvisd. Install Node, run npm run browser:install, set JARVIS_BROWSER_NODE to that node binary, and restart Jarvis.';
const MISSING_HELPER = 'The Jarvis browser helper is missing. Reinstall Jarvis so browser-use/helper.js is next to the daemon, then run npm run browser:install.';
function stateRoot() {
return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local/state'), 'jarvis', 'browser');
}
function existingFile(file) {
const value = String(file || '').trim();
if (!value) return '';
try { return existsSync(value) ? value : ''; } catch { return ''; }
}
function addNodeCandidates(candidates, seen, file) {
const value = String(file || '').trim();
if (!value || seen.has(value)) return;
seen.add(value);
candidates.push(value);
}
export function resolveNodeBinary(explicit = process.env.JARVIS_BROWSER_NODE || '') {
const home = os.homedir();
const seen = new Set();
const candidates = [];
addNodeCandidates(candidates, seen, explicit);
addNodeCandidates(candidates, seen, process.env.JARVIS_BROWSER_NODE);
addNodeCandidates(candidates, seen, '/usr/bin/node');
addNodeCandidates(candidates, seen, '/usr/local/bin/node');
addNodeCandidates(candidates, seen, path.join(home, '.local/bin/node'));
addNodeCandidates(candidates, seen, path.join(home, '.volta/bin/node'));
for (const dir of String(process.env.PATH || '').split(':')) {
if (dir) addNodeCandidates(candidates, seen, path.join(dir, 'node'));
}
try {
const nvm = path.join(home, '.nvm/versions/node');
for (const version of readdirSync(nvm).sort().reverse()) {
addNodeCandidates(candidates, seen, path.join(nvm, version, 'bin/node'));
}
} catch {}
for (const file of candidates) {
const found = existingFile(file);
if (found) return found;
}
return '';
}
function spawnMessage(error, node, helper) {
const raw = String(error?.message || error || '');
if (/enoent|no such file or directory/i.test(raw)) {
if (!existingFile(node)) return MISSING_NODE;
if (!existingFile(helper)) return MISSING_HELPER;
return `${MISSING_NODE} (${raw})`;
}
return raw || 'Jarvis browser helper unavailable';
}
/** JSON-lines client for the Node Playwright helper. Safe to import from Bare. */
export class BrowserClient {
constructor({
node = process.env.JARVIS_BROWSER_NODE || 'node',
command = process.env.JARVIS_BROWSER_HELPER || HELPER,
spawnImpl = spawn,
timeoutMs = 45_000,
lookupBin = spawnImpl === spawn,
} = {}) {
this.node = node;
this.command = command;
this.spawnImpl = spawnImpl;
this.timeoutMs = timeoutMs;
this.lookupBin = lookupBin;
this.process = null;
this._ready = null;
this._pending = new Map();
this._seq = 0;
this._queue = Promise.resolve();
this._buffer = '';
this._closing = false;
this._lastUrl = '';
this._reopens = 0;
}
async ensure() {
if (this._ready) return this._ready;
const node = this.lookupBin ? resolveNodeBinary(this.node) : this.node;
const helper = this.command;
if (this.lookupBin && !node) {
this._ready = Promise.reject(new Error(MISSING_NODE));
this._ready.catch(() => {});
return this._ready;
}
if (this.lookupBin && !existingFile(helper)) {
this._ready = Promise.reject(new Error(MISSING_HELPER));
this._ready.catch(() => {});
return this._ready;
}
const child = this.process = this.spawnImpl(node || this.node, [helper], {
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
PATH: [node ? path.dirname(node) : '', process.env.PATH || '/usr/bin'].filter(Boolean).join(':'),
PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(stateRoot(), 'ms-playwright'),
JARVIS_BROWSER_PROFILE: process.env.JARVIS_BROWSER_PROFILE || path.join(stateRoot(), 'profile'),
},
});
this._ready = new Promise((resolve, reject) => {
let settled = false;
let stderr = '';
const timer = setTimeout(() => fail(new Error('Jarvis browser helper timed out while starting')), this.timeoutMs);
const fail = (error) => {
clearTimeout(timer);
if (this.process === child) this.process = null;
this._ready = null;
const wrapped = error instanceof Error ? error : new Error(String(error || 'Jarvis browser helper unavailable'));
wrapped.message = spawnMessage(wrapped, node || this.node, helper);
this._rejectAll(wrapped);
if (!settled) { settled = true; reject(wrapped); }
try { child.kill('SIGTERM'); } catch {}
};
child.on('error', fail);
child.once('close', () => {
fail(new Error(stderr.trim() || 'Jarvis browser helper exited'));
if (!this._closing) this._reopenWindow();
});
child.stderr?.on('data', (chunk) => { stderr = `${stderr}${chunk}`.slice(-4000); });
child.stdout.on('data', (chunk) => this._onData(chunk, () => {
if (settled) return;
clearTimeout(timer);
settled = true;
resolve(this);
}, fail));
});
return this._ready;
}
_onData(chunk, onReady, onError) {
this._buffer += String(chunk);
let index;
while ((index = this._buffer.indexOf('\n')) >= 0) {
const line = this._buffer.slice(0, index);
this._buffer = this._buffer.slice(index + 1);
let event;
try { event = JSON.parse(line); } catch { continue; }
if (event.type === 'error') {
onError(new Error(event.reason || 'Jarvis browser helper unavailable'));
continue;
}
if (event.type === 'ready') {
onReady();
continue;
}
const pending = this._pending.get(event.id);
if (!pending) continue;
this._pending.delete(event.id);
if (event.ok === false) pending.reject(new Error(event.error || 'browser action failed'));
else pending.resolve(event.result);
}
}
_rejectAll(error) {
for (const pending of this._pending.values()) pending.reject(error);
this._pending.clear();
}
async call(action, payload = {}, timeoutMs) {
const run = this._queue.then(() => this._send(action, payload, timeoutMs));
this._queue = run.catch(() => {});
try {
const result = await run;
if (result && result.url) this._lastUrl = String(result.url);
this._reopens = 0;
return result;
} catch (error) {
return { error: error?.message || 'Jarvis browser helper unavailable' };
}
}
_reopenWindow() {
if (this._closing || this._reopens >= 2) return;
this._reopens += 1;
const url = this._lastUrl;
setTimeout(() => {
if (this._closing) return;
this.ensure().then(() => (url ? this.call('navigate', { url }) : null)).catch(() => {});
}, 600);
}
async _send(action, payload, timeoutMs) {
await this.ensure();
const child = this.process;
if (!child?.stdin?.writable) throw new Error('Jarvis browser helper unavailable');
const id = ++this._seq;
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : this.timeoutMs;
const result = new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this._pending.delete(id);
reject(new Error(`timed out after ${ms}ms`));
}, ms);
this._pending.set(id, {
resolve: (value) => { clearTimeout(timer); resolve(value); },
reject: (error) => { clearTimeout(timer); reject(error); },
});
});
child.stdin.write(`${JSON.stringify({ id, action, timeoutMs: ms, ...payload })}\n`);
return result;
}
close() {
this._closing = true;
const child = this.process;
this._rejectAll(new Error('Jarvis browser helper closed'));
this.process = null;
this._ready = null;
this._queue = Promise.resolve();
try { child?.kill?.('SIGTERM'); } catch {}
}
}