@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/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
+ }