@ulpi/browse 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BENCHMARKS.md +222 -0
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/browse.ts +2 -0
- package/package.json +54 -0
- package/skill/SKILL.md +301 -0
- package/src/browser-manager.ts +687 -0
- package/src/buffers.ts +81 -0
- package/src/bun.d.ts +47 -0
- package/src/cli.ts +442 -0
- package/src/commands/meta.ts +358 -0
- package/src/commands/read.ts +304 -0
- package/src/commands/write.ts +259 -0
- package/src/constants.ts +12 -0
- package/src/diff.d.ts +12 -0
- package/src/install-skill.ts +98 -0
- package/src/server.ts +325 -0
- package/src/session-manager.ts +121 -0
- package/src/snapshot.ts +497 -0
- package/src/types.ts +12 -0
package/src/buffers.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared buffers and types — extracted to break circular dependency
|
|
3
|
+
* between server.ts and browser-manager.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DEFAULTS } from './constants';
|
|
7
|
+
|
|
8
|
+
export interface LogEntry {
|
|
9
|
+
timestamp: number;
|
|
10
|
+
level: string;
|
|
11
|
+
text: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NetworkEntry {
|
|
15
|
+
timestamp: number;
|
|
16
|
+
method: string;
|
|
17
|
+
url: string;
|
|
18
|
+
status?: number;
|
|
19
|
+
duration?: number;
|
|
20
|
+
size?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Per-session buffer container.
|
|
25
|
+
* Each session (or parallel agent) gets its own instance so buffers
|
|
26
|
+
* don't bleed across concurrent operations.
|
|
27
|
+
*/
|
|
28
|
+
export class SessionBuffers {
|
|
29
|
+
consoleBuffer: LogEntry[] = [];
|
|
30
|
+
networkBuffer: NetworkEntry[] = [];
|
|
31
|
+
consoleTotalAdded = 0;
|
|
32
|
+
networkTotalAdded = 0;
|
|
33
|
+
// Flush cursors — used by server.ts flush logic
|
|
34
|
+
lastConsoleFlushed = 0;
|
|
35
|
+
lastNetworkFlushed = 0;
|
|
36
|
+
|
|
37
|
+
addConsoleEntry(entry: LogEntry) {
|
|
38
|
+
if (this.consoleBuffer.length >= DEFAULTS.BUFFER_HIGH_WATER_MARK) {
|
|
39
|
+
this.consoleBuffer.shift();
|
|
40
|
+
}
|
|
41
|
+
this.consoleBuffer.push(entry);
|
|
42
|
+
this.consoleTotalAdded++;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addNetworkEntry(entry: NetworkEntry) {
|
|
46
|
+
if (this.networkBuffer.length >= DEFAULTS.BUFFER_HIGH_WATER_MARK) {
|
|
47
|
+
this.networkBuffer.shift();
|
|
48
|
+
}
|
|
49
|
+
this.networkBuffer.push(entry);
|
|
50
|
+
this.networkTotalAdded++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Default (singleton) buffers — backward compatibility ────────────
|
|
55
|
+
// Existing code that imports consoleBuffer, networkBuffer, addConsoleEntry,
|
|
56
|
+
// addNetworkEntry, consoleTotalAdded, networkTotalAdded continues to work
|
|
57
|
+
// unchanged against these module-level exports.
|
|
58
|
+
|
|
59
|
+
export const consoleBuffer: LogEntry[] = [];
|
|
60
|
+
export const networkBuffer: NetworkEntry[] = [];
|
|
61
|
+
|
|
62
|
+
// Total entries ever added — used by server.ts flush logic as a cursor
|
|
63
|
+
// that keeps advancing even after the ring buffer wraps.
|
|
64
|
+
export let consoleTotalAdded = 0;
|
|
65
|
+
export let networkTotalAdded = 0;
|
|
66
|
+
|
|
67
|
+
export function addConsoleEntry(entry: LogEntry) {
|
|
68
|
+
if (consoleBuffer.length >= DEFAULTS.BUFFER_HIGH_WATER_MARK) {
|
|
69
|
+
consoleBuffer.shift();
|
|
70
|
+
}
|
|
71
|
+
consoleBuffer.push(entry);
|
|
72
|
+
consoleTotalAdded++;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function addNetworkEntry(entry: NetworkEntry) {
|
|
76
|
+
if (networkBuffer.length >= DEFAULTS.BUFFER_HIGH_WATER_MARK) {
|
|
77
|
+
networkBuffer.shift();
|
|
78
|
+
}
|
|
79
|
+
networkBuffer.push(entry);
|
|
80
|
+
networkTotalAdded++;
|
|
81
|
+
}
|
package/src/bun.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun runtime type declarations
|
|
3
|
+
*
|
|
4
|
+
* Covers the Bun globals used in this project so tsc --noEmit passes
|
|
5
|
+
* without pulling in the broken bun-types package.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
declare module 'bun' {
|
|
9
|
+
export function serve(options: {
|
|
10
|
+
port: number;
|
|
11
|
+
hostname?: string;
|
|
12
|
+
fetch: (req: Request) => Response | Promise<Response>;
|
|
13
|
+
}): BunServer;
|
|
14
|
+
|
|
15
|
+
export function spawn(cmd: string[], options?: {
|
|
16
|
+
stdio?: Array<'ignore' | 'pipe' | 'inherit'>;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
}): BunSubprocess;
|
|
19
|
+
|
|
20
|
+
export function sleep(ms: number): Promise<void>;
|
|
21
|
+
|
|
22
|
+
export const stdin: { text(): Promise<string> };
|
|
23
|
+
|
|
24
|
+
interface BunServer {
|
|
25
|
+
port: number;
|
|
26
|
+
stop(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface BunSubprocess {
|
|
30
|
+
pid: number;
|
|
31
|
+
stderr: ReadableStream<Uint8Array> | null;
|
|
32
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
33
|
+
unref(): void;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare var Bun: {
|
|
38
|
+
serve: typeof import('bun').serve;
|
|
39
|
+
spawn: typeof import('bun').spawn;
|
|
40
|
+
sleep: typeof import('bun').sleep;
|
|
41
|
+
stdin: typeof import('bun').stdin;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
interface ImportMeta {
|
|
45
|
+
dir: string;
|
|
46
|
+
main: boolean;
|
|
47
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browse CLI — thin wrapper that talks to the persistent server
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Read /tmp/browse-server.json for port + token
|
|
6
|
+
* 2. If missing or stale PID → start server in background
|
|
7
|
+
* 3. Health check
|
|
8
|
+
* 4. Send command via HTTP POST
|
|
9
|
+
* 5. Print response to stdout (or stderr for errors)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { DEFAULTS } from './constants';
|
|
15
|
+
|
|
16
|
+
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
17
|
+
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the project-local .browse/ directory for state files, logs, screenshots.
|
|
21
|
+
* Walks up from cwd looking for .git/ or .claude/ (project root markers).
|
|
22
|
+
* Creates <root>/.browse/ with a self-contained .gitignore.
|
|
23
|
+
* Falls back to /tmp/ if not found (e.g. running outside a project).
|
|
24
|
+
*/
|
|
25
|
+
function resolveLocalDir(): string {
|
|
26
|
+
if (process.env.BROWSE_LOCAL_DIR) return process.env.BROWSE_LOCAL_DIR;
|
|
27
|
+
|
|
28
|
+
let dir = process.cwd();
|
|
29
|
+
for (let i = 0; i < 20; i++) {
|
|
30
|
+
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, '.claude'))) {
|
|
31
|
+
const browseDir = path.join(dir, '.browse');
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(browseDir, { recursive: true });
|
|
34
|
+
// Self-contained .gitignore — users don't need to add .browse/ to their .gitignore
|
|
35
|
+
const gi = path.join(browseDir, '.gitignore');
|
|
36
|
+
if (!fs.existsSync(gi)) {
|
|
37
|
+
fs.writeFileSync(gi, '*\n');
|
|
38
|
+
}
|
|
39
|
+
} catch {}
|
|
40
|
+
return browseDir;
|
|
41
|
+
}
|
|
42
|
+
const parent = path.dirname(dir);
|
|
43
|
+
if (parent === dir) break;
|
|
44
|
+
dir = parent;
|
|
45
|
+
}
|
|
46
|
+
return '/tmp';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LOCAL_DIR = resolveLocalDir();
|
|
50
|
+
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(LOCAL_DIR, `browse-server${INSTANCE_SUFFIX}.json`);
|
|
51
|
+
const MAX_START_WAIT = 8000; // 8 seconds to start
|
|
52
|
+
const LOCK_FILE = STATE_FILE + '.lock';
|
|
53
|
+
const LOCK_STALE_MS = DEFAULTS.LOCK_STALE_THRESHOLD_MS;
|
|
54
|
+
|
|
55
|
+
export function resolveServerScript(
|
|
56
|
+
env: Record<string, string | undefined> = process.env,
|
|
57
|
+
metaDir: string = import.meta.dir,
|
|
58
|
+
): string {
|
|
59
|
+
// 1. Explicit env var override
|
|
60
|
+
if (env.BROWSE_SERVER_SCRIPT) {
|
|
61
|
+
return env.BROWSE_SERVER_SCRIPT;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. server.ts adjacent to cli.ts (dev mode or installed)
|
|
65
|
+
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
|
|
66
|
+
const direct = path.resolve(metaDir, 'server.ts');
|
|
67
|
+
if (fs.existsSync(direct)) {
|
|
68
|
+
return direct;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new Error(
|
|
73
|
+
'[browse] Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env var to the path of server.ts.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const SERVER_SCRIPT = resolveServerScript();
|
|
78
|
+
|
|
79
|
+
interface ServerState {
|
|
80
|
+
pid: number;
|
|
81
|
+
port: number;
|
|
82
|
+
token: string;
|
|
83
|
+
startedAt: string;
|
|
84
|
+
serverPath: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── State File ────────────────────────────────────────────────
|
|
88
|
+
function readState(): ServerState | null {
|
|
89
|
+
try {
|
|
90
|
+
const data = fs.readFileSync(STATE_FILE, 'utf-8');
|
|
91
|
+
return JSON.parse(data);
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isProcessAlive(pid: number): boolean {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(pid, 0);
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── Server Lifecycle ──────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Acquire a lock file to prevent concurrent server spawns.
|
|
110
|
+
* Uses O_EXCL (wx flag) for atomic creation.
|
|
111
|
+
* Returns true if lock acquired, false if another process holds it.
|
|
112
|
+
*/
|
|
113
|
+
function acquireLock(): boolean {
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
|
|
116
|
+
return true;
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
if (err.code === 'EEXIST') {
|
|
119
|
+
// Check if lock is stale
|
|
120
|
+
try {
|
|
121
|
+
const stat = fs.statSync(LOCK_FILE);
|
|
122
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
123
|
+
// Lock is stale — remove and retry
|
|
124
|
+
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
125
|
+
return acquireLock();
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function releaseLock() {
|
|
135
|
+
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function startServer(): Promise<ServerState> {
|
|
139
|
+
// Acquire lock to prevent concurrent spawns
|
|
140
|
+
if (!acquireLock()) {
|
|
141
|
+
// Another process is starting the server — wait for state file or lock release
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
while (Date.now() - start < MAX_START_WAIT) {
|
|
144
|
+
const state = readState();
|
|
145
|
+
if (state && isProcessAlive(state.pid)) {
|
|
146
|
+
return state;
|
|
147
|
+
}
|
|
148
|
+
// If the lock was released (first starter failed), retry acquiring it
|
|
149
|
+
// instead of waiting forever for a state file that will never appear.
|
|
150
|
+
if (acquireLock()) {
|
|
151
|
+
// We now hold the lock — fall through to the spawn logic below
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
await Bun.sleep(100);
|
|
155
|
+
}
|
|
156
|
+
// If we still don't hold the lock and no state file appeared, give up
|
|
157
|
+
if (!fs.existsSync(LOCK_FILE) || fs.readFileSync(LOCK_FILE, 'utf-8').trim() !== String(process.pid)) {
|
|
158
|
+
const state = readState();
|
|
159
|
+
if (state && isProcessAlive(state.pid)) return state;
|
|
160
|
+
throw new Error('Server failed to start (another process is starting it)');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
// Only remove state file if it belongs to a dead process
|
|
166
|
+
try {
|
|
167
|
+
const oldState = readState();
|
|
168
|
+
if (oldState && !isProcessAlive(oldState.pid)) {
|
|
169
|
+
fs.unlinkSync(STATE_FILE);
|
|
170
|
+
}
|
|
171
|
+
} catch {}
|
|
172
|
+
|
|
173
|
+
// Start server as detached background process
|
|
174
|
+
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
|
175
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
176
|
+
env: { ...process.env, BROWSE_LOCAL_DIR: LOCAL_DIR },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Don't hold the CLI open
|
|
180
|
+
proc.unref();
|
|
181
|
+
|
|
182
|
+
// Wait for state file to appear
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
while (Date.now() - start < MAX_START_WAIT) {
|
|
185
|
+
const state = readState();
|
|
186
|
+
if (state && isProcessAlive(state.pid)) {
|
|
187
|
+
return state;
|
|
188
|
+
}
|
|
189
|
+
await Bun.sleep(100);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If we get here, server didn't start in time
|
|
193
|
+
// Try to read stderr for error message
|
|
194
|
+
const stderr = proc.stderr;
|
|
195
|
+
if (stderr) {
|
|
196
|
+
const reader = stderr.getReader();
|
|
197
|
+
const { value } = await reader.read();
|
|
198
|
+
if (value) {
|
|
199
|
+
const errText = new TextDecoder().decode(value);
|
|
200
|
+
throw new Error(`Server failed to start:\n${errText}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`);
|
|
204
|
+
} finally {
|
|
205
|
+
releaseLock();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function ensureServer(): Promise<ServerState> {
|
|
210
|
+
const state = readState();
|
|
211
|
+
|
|
212
|
+
if (state && isProcessAlive(state.pid)) {
|
|
213
|
+
// Server appears alive — do a health check
|
|
214
|
+
try {
|
|
215
|
+
const resp = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
|
216
|
+
signal: AbortSignal.timeout(DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
|
|
217
|
+
});
|
|
218
|
+
if (resp.ok) {
|
|
219
|
+
const health = await resp.json() as any;
|
|
220
|
+
if (health.status === 'healthy') {
|
|
221
|
+
return state;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Health check failed — server is dead or unhealthy
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Need to (re)start
|
|
230
|
+
console.error('[browse] Starting server...');
|
|
231
|
+
return startServer();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Command Dispatch ──────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
// Commands that are safe to retry after a transport failure.
|
|
237
|
+
// Write commands (click, fill, goto, etc.) may have already executed
|
|
238
|
+
// before the connection dropped — retrying them could duplicate side effects.
|
|
239
|
+
// NOTE: 'js' and 'eval' excluded — page.evaluate() can run arbitrary side effects
|
|
240
|
+
// NOTE: 'storage' excluded — 'storage set' mutates localStorage
|
|
241
|
+
export const SAFE_TO_RETRY = new Set([
|
|
242
|
+
// Read commands — no side effects
|
|
243
|
+
'text', 'html', 'links', 'forms', 'accessibility',
|
|
244
|
+
'css', 'attrs', 'state', 'dialog',
|
|
245
|
+
'console', 'network', 'cookies', 'perf',
|
|
246
|
+
// Meta commands that are read-only
|
|
247
|
+
'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions',
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
// Commands that return static data independent of page state.
|
|
251
|
+
// Safe to retry even after a server restart (no "blank page" issue).
|
|
252
|
+
const PAGE_INDEPENDENT = new Set(['devices', 'status']);
|
|
253
|
+
|
|
254
|
+
async function sendCommand(state: ServerState, command: string, args: string[], retries = 0, sessionId?: string): Promise<void> {
|
|
255
|
+
const body = JSON.stringify({ command, args });
|
|
256
|
+
|
|
257
|
+
const headers: Record<string, string> = {
|
|
258
|
+
'Content-Type': 'application/json',
|
|
259
|
+
'Authorization': `Bearer ${state.token}`,
|
|
260
|
+
};
|
|
261
|
+
if (sessionId) {
|
|
262
|
+
headers['X-Browse-Session'] = sessionId;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers,
|
|
269
|
+
body,
|
|
270
|
+
signal: AbortSignal.timeout(30000),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (resp.status === 401) {
|
|
274
|
+
// Token mismatch — server may have restarted
|
|
275
|
+
console.error('[browse] Auth failed — server may have restarted. Retrying...');
|
|
276
|
+
const newState = readState();
|
|
277
|
+
if (newState && newState.token !== state.token) {
|
|
278
|
+
return sendCommand(newState, command, args, 0, sessionId);
|
|
279
|
+
}
|
|
280
|
+
throw new Error('Authentication failed');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const text = await resp.text();
|
|
284
|
+
|
|
285
|
+
if (resp.ok) {
|
|
286
|
+
process.stdout.write(text);
|
|
287
|
+
if (!text.endsWith('\n')) process.stdout.write('\n');
|
|
288
|
+
|
|
289
|
+
// After restart succeeds, wait for old server to actually die, then start fresh
|
|
290
|
+
if (command === 'restart') {
|
|
291
|
+
const oldPid = state.pid;
|
|
292
|
+
// Wait up to 5s for graceful shutdown
|
|
293
|
+
const deadline = Date.now() + 5000;
|
|
294
|
+
while (Date.now() < deadline && isProcessAlive(oldPid)) {
|
|
295
|
+
await Bun.sleep(100);
|
|
296
|
+
}
|
|
297
|
+
// If still alive (e.g. browserManager.close() stalled), force-kill
|
|
298
|
+
if (isProcessAlive(oldPid)) {
|
|
299
|
+
try { process.kill(oldPid, 'SIGKILL'); } catch {}
|
|
300
|
+
// Brief wait for OS to reclaim the process and release the port
|
|
301
|
+
await Bun.sleep(300);
|
|
302
|
+
}
|
|
303
|
+
const newState = await startServer();
|
|
304
|
+
console.error(`[browse] Server restarted (PID: ${newState.pid})`);
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
// Try to parse as JSON error
|
|
308
|
+
try {
|
|
309
|
+
const err = JSON.parse(text);
|
|
310
|
+
console.error(err.error || text);
|
|
311
|
+
if (err.hint) console.error(err.hint);
|
|
312
|
+
} catch {
|
|
313
|
+
console.error(text);
|
|
314
|
+
}
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
} catch (err: any) {
|
|
318
|
+
if (err.name === 'AbortError') {
|
|
319
|
+
console.error('[browse] Command timed out after 30s');
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}
|
|
322
|
+
// Connection error — server may have crashed
|
|
323
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) {
|
|
324
|
+
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
|
325
|
+
|
|
326
|
+
// ECONNREFUSED = server not listening, command never executed → safe to retry anything.
|
|
327
|
+
// ECONNRESET/fetch failed = connection dropped mid-request, command may have executed.
|
|
328
|
+
// Only retry read-only commands to avoid duplicating side effects (e.g., form submits).
|
|
329
|
+
if (err.code !== 'ECONNREFUSED' && !SAFE_TO_RETRY.has(command)) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`[browse] Connection lost during '${command}'. ` +
|
|
332
|
+
`The action may have already executed — not retrying to avoid duplicating side effects.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
console.error('[browse] Server connection lost. Restarting...');
|
|
337
|
+
const newState = await startServer();
|
|
338
|
+
|
|
339
|
+
// After a restart the new server has a fresh browser (blank page).
|
|
340
|
+
// Read-only commands would silently return data from that blank page,
|
|
341
|
+
// which is worse than an error. Only retry navigation commands that
|
|
342
|
+
// will establish session state on the new server.
|
|
343
|
+
// Exception: page-independent commands (devices, status) return static
|
|
344
|
+
// data that doesn't depend on page state — safe to retry on blank page.
|
|
345
|
+
if (SAFE_TO_RETRY.has(command) && !PAGE_INDEPENDENT.has(command)) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`[browse] Server restarted but '${command}' would return data from a blank page. ` +
|
|
348
|
+
`Re-navigate with 'goto' first, then retry.`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return sendCommand(newState, command, args, retries + 1, sessionId);
|
|
353
|
+
}
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─── Main ──────────────────────────────────────────────────────
|
|
359
|
+
async function main() {
|
|
360
|
+
const args = process.argv.slice(2);
|
|
361
|
+
|
|
362
|
+
// Extract --session flag before command parsing
|
|
363
|
+
let sessionId: string | undefined;
|
|
364
|
+
const sessionIdx = args.indexOf('--session');
|
|
365
|
+
if (sessionIdx !== -1) {
|
|
366
|
+
sessionId = args[sessionIdx + 1];
|
|
367
|
+
if (!sessionId || sessionId.startsWith('-')) {
|
|
368
|
+
console.error('Usage: browse --session <id> <command> [args...]');
|
|
369
|
+
process.exit(1);
|
|
370
|
+
}
|
|
371
|
+
args.splice(sessionIdx, 2); // remove --session and its value
|
|
372
|
+
}
|
|
373
|
+
sessionId = sessionId || process.env.BROWSE_SESSION || undefined;
|
|
374
|
+
|
|
375
|
+
// ─── Local commands (no server needed) ─────────────────────
|
|
376
|
+
if (args[0] === 'install-skill') {
|
|
377
|
+
const { installSkill } = await import('./install-skill');
|
|
378
|
+
installSkill(args[1]);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
383
|
+
console.log(`browse — Fast headless browser for AI coding agents
|
|
384
|
+
|
|
385
|
+
Usage: browse [--session <id>] <command> [args...]
|
|
386
|
+
|
|
387
|
+
Navigation: goto <url> | back | forward | reload | url
|
|
388
|
+
Content: text | html [sel] | links | forms | accessibility
|
|
389
|
+
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
|
390
|
+
hover <sel> | type <text> | press <key>
|
|
391
|
+
scroll [sel] | wait <sel> | viewport <WxH>
|
|
392
|
+
Device: emulate <device> | emulate reset | devices [filter]
|
|
393
|
+
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
394
|
+
console [--clear] | network [--clear]
|
|
395
|
+
cookies | storage [set <k> <v>] | perf
|
|
396
|
+
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
397
|
+
Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
|
|
398
|
+
Compare: diff <url1> <url2>
|
|
399
|
+
Multi-step: chain (reads JSON from stdin)
|
|
400
|
+
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
|
401
|
+
Sessions: sessions | session-close <id>
|
|
402
|
+
Server: status | cookie <n>=<v> | header <n>:<v>
|
|
403
|
+
useragent <str> | stop | restart
|
|
404
|
+
Setup: install-skill [path]
|
|
405
|
+
|
|
406
|
+
Options:
|
|
407
|
+
--session <id> Use a named session (isolates tabs, refs, cookies).
|
|
408
|
+
Multiple agents can share one server with different sessions.
|
|
409
|
+
Also settable via BROWSE_SESSION env var.
|
|
410
|
+
|
|
411
|
+
Snapshot flags:
|
|
412
|
+
-i Interactive elements only (buttons, links, inputs)
|
|
413
|
+
-c Compact — remove empty structural elements
|
|
414
|
+
-C Cursor-interactive — detect divs with cursor:pointer,
|
|
415
|
+
onclick, tabindex, data-action (missed by ARIA tree)
|
|
416
|
+
-d N Limit tree depth to N levels
|
|
417
|
+
-s <sel> Scope to CSS selector
|
|
418
|
+
|
|
419
|
+
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|
420
|
+
click @e3 | fill @e4 "value" | hover @e1`);
|
|
421
|
+
process.exit(0);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const command = args[0];
|
|
425
|
+
const commandArgs = args.slice(1);
|
|
426
|
+
|
|
427
|
+
// Special case: chain reads from stdin
|
|
428
|
+
if (command === 'chain' && commandArgs.length === 0) {
|
|
429
|
+
const stdin = await Bun.stdin.text();
|
|
430
|
+
commandArgs.push(stdin.trim());
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const state = await ensureServer();
|
|
434
|
+
await sendCommand(state, command, commandArgs, 0, sessionId);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (import.meta.main) {
|
|
438
|
+
main().catch((err) => {
|
|
439
|
+
console.error(`[browse] ${err.message}`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
});
|
|
442
|
+
}
|