223 lines
8.0 KiB
JavaScript
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 {}
|
|
}
|
|
}
|