@ulpi/browse 0.1.0 → 0.2.1
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/README.md +9 -3
- package/bin/browse.ts +10 -1
- package/package.json +9 -8
- package/skill/SKILL.md +163 -26
- package/src/auth-vault.ts +244 -0
- package/src/browser-manager.ts +177 -2
- package/src/cli.ts +159 -25
- package/src/commands/meta.ts +176 -5
- package/src/commands/read.ts +39 -13
- package/src/commands/write.ts +200 -6
- package/src/config.ts +44 -0
- package/src/constants.ts +4 -2
- package/src/domain-filter.ts +134 -0
- package/src/har.ts +66 -0
- package/src/policy.ts +94 -0
- package/src/sanitize.ts +11 -0
- package/src/server.ts +196 -56
- package/src/session-manager.ts +65 -2
- package/src/snapshot.ts +18 -13
package/src/browser-manager.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* We do NOT try to self-heal — don't hide failure.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Request as PlaywrightRequest } from 'playwright';
|
|
10
|
+
import { chromium, devices as playwrightDevices, type Browser, type BrowserContext, type Page, type Locator, type Frame, type FrameLocator, type Request as PlaywrightRequest } from 'playwright';
|
|
11
11
|
import { SessionBuffers, type LogEntry, type NetworkEntry } from './buffers';
|
|
12
|
+
import type { HarRecording } from './har';
|
|
13
|
+
import type { DomainFilter } from './domain-filter';
|
|
12
14
|
|
|
13
15
|
/** Shorthand aliases for common devices → Playwright device names */
|
|
14
16
|
const DEVICE_ALIASES: Record<string, string> = {
|
|
@@ -136,6 +138,9 @@ export class BrowserManager {
|
|
|
136
138
|
private customUserAgent: string | null = null;
|
|
137
139
|
private currentDevice: DeviceDescriptor | null = null;
|
|
138
140
|
|
|
141
|
+
// ─── iframe targeting ─────────────────────────────────────
|
|
142
|
+
private activeFrameSelector: string | null = null;
|
|
143
|
+
|
|
139
144
|
// ─── Per-session buffers ──────────────────────────────────
|
|
140
145
|
private buffers: SessionBuffers;
|
|
141
146
|
|
|
@@ -156,6 +161,21 @@ export class BrowserManager {
|
|
|
156
161
|
// ─── Network Correlation ────────────────────────────────────
|
|
157
162
|
private requestEntryMap = new WeakMap<PlaywrightRequest, NetworkEntry>();
|
|
158
163
|
|
|
164
|
+
// ─── Offline Mode ─────────────────────────────────────────
|
|
165
|
+
private offline = false;
|
|
166
|
+
|
|
167
|
+
// ─── HAR Recording ────────────────────────────────────────
|
|
168
|
+
private harRecording: HarRecording | null = null;
|
|
169
|
+
|
|
170
|
+
// ─── Init Script (domain filter JS injection) ─────────────
|
|
171
|
+
private initScript: string | null = null;
|
|
172
|
+
|
|
173
|
+
// ─── User Routes (survive context recreation) ─────────────
|
|
174
|
+
private userRoutes: Array<{pattern: string; action: 'block' | 'fulfill'; status?: number; body?: string}> = [];
|
|
175
|
+
|
|
176
|
+
// ─── Domain Filter (survive context recreation) ───────────
|
|
177
|
+
private domainFilter: DomainFilter | null = null;
|
|
178
|
+
|
|
159
179
|
// Whether this instance owns (and should close) the Browser process
|
|
160
180
|
private ownsBrowser = false;
|
|
161
181
|
|
|
@@ -167,6 +187,10 @@ export class BrowserManager {
|
|
|
167
187
|
return this.buffers;
|
|
168
188
|
}
|
|
169
189
|
|
|
190
|
+
getContext(): BrowserContext | null {
|
|
191
|
+
return this.context;
|
|
192
|
+
}
|
|
193
|
+
|
|
170
194
|
/**
|
|
171
195
|
* Launch a new Chromium browser (single-session / multi-process mode).
|
|
172
196
|
* This instance owns the browser and will close it on close().
|
|
@@ -341,6 +365,67 @@ export class BrowserManager {
|
|
|
341
365
|
}
|
|
342
366
|
}
|
|
343
367
|
|
|
368
|
+
// ─── iframe Targeting ──────────────────────────────────────
|
|
369
|
+
/**
|
|
370
|
+
* Set the active frame by CSS selector (e.g., '#my-iframe', 'iframe[name="content"]').
|
|
371
|
+
* Subsequent commands that use resolveRef, getLocatorRoot, or getFrameContext
|
|
372
|
+
* will target this frame's content instead of the main page.
|
|
373
|
+
*/
|
|
374
|
+
setFrame(selector: string) {
|
|
375
|
+
this.activeFrameSelector = selector;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Reset to main frame — clears the active frame selector.
|
|
380
|
+
*/
|
|
381
|
+
resetFrame() {
|
|
382
|
+
this.activeFrameSelector = null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Get the current active frame selector, or null if targeting main page.
|
|
387
|
+
*/
|
|
388
|
+
getActiveFrameSelector(): string | null {
|
|
389
|
+
return this.activeFrameSelector;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get a FrameLocator for the active frame.
|
|
394
|
+
* Returns null if no frame is active (targeting main page).
|
|
395
|
+
*/
|
|
396
|
+
getFrameLocator(): FrameLocator | null {
|
|
397
|
+
if (!this.activeFrameSelector) return null;
|
|
398
|
+
return this.getPage().frameLocator(this.activeFrameSelector);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get the Frame object for the active frame (needed for evaluate() calls).
|
|
403
|
+
* Returns null if no frame is active.
|
|
404
|
+
* Unlike FrameLocator, Frame supports evaluate(), querySelector, etc.
|
|
405
|
+
*/
|
|
406
|
+
async getFrameContext(): Promise<Frame | null> {
|
|
407
|
+
if (!this.activeFrameSelector) return null;
|
|
408
|
+
const page = this.getPage();
|
|
409
|
+
const frameEl = page.locator(this.activeFrameSelector);
|
|
410
|
+
const handle = await frameEl.elementHandle({ timeout: 5000 });
|
|
411
|
+
if (!handle) throw new Error(`Frame element not found: ${this.activeFrameSelector}`);
|
|
412
|
+
const frame = await handle.contentFrame();
|
|
413
|
+
if (!frame) throw new Error(`Cannot access content of frame: ${this.activeFrameSelector}`);
|
|
414
|
+
return frame;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Get a locator root scoped to the active frame (if any) or the page.
|
|
419
|
+
* Use this to create locators that respect the current frame context.
|
|
420
|
+
* Example: bm.getLocatorRoot().locator('button.submit')
|
|
421
|
+
*/
|
|
422
|
+
getLocatorRoot(): Page | FrameLocator {
|
|
423
|
+
if (this.activeFrameSelector) {
|
|
424
|
+
return this.getPage().frameLocator(this.activeFrameSelector);
|
|
425
|
+
}
|
|
426
|
+
return this.getPage();
|
|
427
|
+
}
|
|
428
|
+
|
|
344
429
|
// ─── Ref Map ──────────────────────────────────────────────
|
|
345
430
|
setRefMap(refs: Map<string, Locator>) {
|
|
346
431
|
this.refMap = refs;
|
|
@@ -354,6 +439,10 @@ export class BrowserManager {
|
|
|
354
439
|
/**
|
|
355
440
|
* Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
|
|
356
441
|
* Returns { locator } for refs or { selector } for CSS selectors.
|
|
442
|
+
*
|
|
443
|
+
* When a frame is active and a CSS selector is passed, returns { locator }
|
|
444
|
+
* scoped to the frame instead of { selector }, so callers automatically
|
|
445
|
+
* interact with elements inside the iframe.
|
|
357
446
|
*/
|
|
358
447
|
resolveRef(selector: string): { locator: Locator } | { selector: string } {
|
|
359
448
|
if (selector.startsWith('@e')) {
|
|
@@ -373,6 +462,11 @@ export class BrowserManager {
|
|
|
373
462
|
}
|
|
374
463
|
return { locator };
|
|
375
464
|
}
|
|
465
|
+
// When a frame is active, scope CSS selectors through the frame
|
|
466
|
+
if (this.activeFrameSelector) {
|
|
467
|
+
const frame = this.getPage().frameLocator(this.activeFrameSelector);
|
|
468
|
+
return { locator: frame.locator(selector) };
|
|
469
|
+
}
|
|
376
470
|
return { selector };
|
|
377
471
|
}
|
|
378
472
|
|
|
@@ -473,6 +567,28 @@ export class BrowserManager {
|
|
|
473
567
|
if (Object.keys(this.extraHeaders).length > 0) {
|
|
474
568
|
await newContext.setExtraHTTPHeaders(this.extraHeaders);
|
|
475
569
|
}
|
|
570
|
+
if (this.offline) {
|
|
571
|
+
await newContext.setOffline(true);
|
|
572
|
+
}
|
|
573
|
+
if (this.initScript) {
|
|
574
|
+
await newContext.addInitScript(this.initScript);
|
|
575
|
+
}
|
|
576
|
+
// Re-apply domain filter route
|
|
577
|
+
if (this.domainFilter) {
|
|
578
|
+
const df = this.domainFilter;
|
|
579
|
+
await newContext.route('**/*', (route) => {
|
|
580
|
+
const url = route.request().url();
|
|
581
|
+
if (df.isAllowed(url)) { route.continue(); } else { route.abort('blockedbyclient'); }
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// Re-apply user routes
|
|
585
|
+
for (const r of this.userRoutes) {
|
|
586
|
+
if (r.action === 'block') {
|
|
587
|
+
await newContext.route(r.pattern, (route) => route.abort('blockedbyclient'));
|
|
588
|
+
} else {
|
|
589
|
+
await newContext.route(r.pattern, (route) => route.fulfill({ status: r.status || 200, body: r.body || '', contentType: 'text/plain' }));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
476
592
|
} catch (err) {
|
|
477
593
|
await newContext.close().catch(() => {});
|
|
478
594
|
throw err;
|
|
@@ -484,6 +600,7 @@ export class BrowserManager {
|
|
|
484
600
|
const oldActiveTabId = this.activeTabId;
|
|
485
601
|
const oldNextTabId = this.nextTabId;
|
|
486
602
|
const oldTabSnapshots = new Map(this.tabSnapshots);
|
|
603
|
+
const oldRefMap = new Map(this.refMap);
|
|
487
604
|
|
|
488
605
|
// Swap to new context
|
|
489
606
|
this.context = newContext;
|
|
@@ -520,7 +637,7 @@ export class BrowserManager {
|
|
|
520
637
|
this.activeTabId = oldActiveTabId;
|
|
521
638
|
this.nextTabId = oldNextTabId;
|
|
522
639
|
this.tabSnapshots = oldTabSnapshots;
|
|
523
|
-
this.refMap
|
|
640
|
+
this.refMap = oldRefMap;
|
|
524
641
|
throw err;
|
|
525
642
|
}
|
|
526
643
|
|
|
@@ -600,6 +717,64 @@ export class BrowserManager {
|
|
|
600
717
|
return this.currentDevice;
|
|
601
718
|
}
|
|
602
719
|
|
|
720
|
+
// ─── Offline Mode ──────────────────────────────────────────
|
|
721
|
+
isOffline(): boolean {
|
|
722
|
+
return this.offline;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async setOffline(value: boolean): Promise<void> {
|
|
726
|
+
this.offline = value;
|
|
727
|
+
if (this.context) {
|
|
728
|
+
await this.context.setOffline(value);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// ─── HAR Recording ────────────────────────────────────────
|
|
733
|
+
startHarRecording(): void {
|
|
734
|
+
this.harRecording = { startTime: Date.now(), active: true };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
stopHarRecording(): HarRecording | null {
|
|
738
|
+
const recording = this.harRecording;
|
|
739
|
+
this.harRecording = null;
|
|
740
|
+
return recording;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
getHarRecording(): HarRecording | null {
|
|
744
|
+
return this.harRecording;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ─── Init Script ───────────────────────────────────────────
|
|
748
|
+
setInitScript(script: string): void {
|
|
749
|
+
this.initScript = script;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
getInitScript(): string | null {
|
|
753
|
+
return this.initScript;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ─── User Routes ──────────────────────────────────────────
|
|
757
|
+
addUserRoute(pattern: string, action: 'block' | 'fulfill', status?: number, body?: string) {
|
|
758
|
+
this.userRoutes.push({pattern, action, status, body});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
clearUserRoutes() {
|
|
762
|
+
this.userRoutes = [];
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
getUserRoutes() {
|
|
766
|
+
return this.userRoutes;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// ─── Domain Filter ────────────────────────────────────────
|
|
770
|
+
setDomainFilter(filter: DomainFilter) {
|
|
771
|
+
this.domainFilter = filter;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
getDomainFilter(): DomainFilter | null {
|
|
775
|
+
return this.domainFilter;
|
|
776
|
+
}
|
|
777
|
+
|
|
603
778
|
/**
|
|
604
779
|
* Reverse-lookup: find the tab ID that owns this page.
|
|
605
780
|
* Returns undefined if the page isn't committed to any tab yet (during newTab).
|
package/src/cli.ts
CHANGED
|
@@ -12,9 +12,22 @@
|
|
|
12
12
|
import * as fs from 'fs';
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import { DEFAULTS } from './constants';
|
|
15
|
+
import { loadConfig } from './config';
|
|
16
|
+
|
|
17
|
+
// Global CLI flags — set in main(), used by sendCommand()
|
|
18
|
+
const cliFlags = {
|
|
19
|
+
json: false,
|
|
20
|
+
contentBoundaries: false,
|
|
21
|
+
allowedDomains: '' as string,
|
|
22
|
+
};
|
|
15
23
|
|
|
16
24
|
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
|
17
|
-
|
|
25
|
+
// Instance isolation: each parent process (e.g., Claude Code) gets its own server.
|
|
26
|
+
// BROWSE_PORT takes precedence (explicit), then BROWSE_INSTANCE (env override), then PPID (auto).
|
|
27
|
+
// In compiled mode ($bunfs), PPID is unstable (shell forks per invocation) — skip it.
|
|
28
|
+
const IS_COMPILED = import.meta.dir.includes('$bunfs');
|
|
29
|
+
const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || (BROWSE_PORT || IS_COMPILED ? '' : String(process.ppid));
|
|
30
|
+
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
|
|
18
31
|
|
|
19
32
|
/**
|
|
20
33
|
* Resolve the project-local .browse/ directory for state files, logs, screenshots.
|
|
@@ -69,6 +82,11 @@ export function resolveServerScript(
|
|
|
69
82
|
}
|
|
70
83
|
}
|
|
71
84
|
|
|
85
|
+
// Compiled binary ($bunfs): server is bundled, no external file needed
|
|
86
|
+
if (metaDir.includes('$bunfs')) {
|
|
87
|
+
return '__compiled__';
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
throw new Error(
|
|
73
91
|
'[browse] Cannot find server.ts. Set BROWSE_SERVER_SCRIPT env var to the path of server.ts.'
|
|
74
92
|
);
|
|
@@ -170,10 +188,15 @@ async function startServer(): Promise<ServerState> {
|
|
|
170
188
|
}
|
|
171
189
|
} catch {}
|
|
172
190
|
|
|
173
|
-
// Start server as detached background process
|
|
174
|
-
|
|
191
|
+
// Start server as detached background process.
|
|
192
|
+
// Compiled binary: self-spawn with __BROWSE_SERVER_MODE=1
|
|
193
|
+
// Dev mode: spawn bun with server.ts
|
|
194
|
+
const spawnCmd = SERVER_SCRIPT === '__compiled__'
|
|
195
|
+
? [process.execPath]
|
|
196
|
+
: ['bun', 'run', SERVER_SCRIPT];
|
|
197
|
+
const proc = Bun.spawn(spawnCmd, {
|
|
175
198
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
176
|
-
env: { ...process.env, BROWSE_LOCAL_DIR: LOCAL_DIR },
|
|
199
|
+
env: { ...process.env, __BROWSE_SERVER_MODE: '1', BROWSE_LOCAL_DIR: LOCAL_DIR, BROWSE_INSTANCE },
|
|
177
200
|
});
|
|
178
201
|
|
|
179
202
|
// Don't hold the CLI open
|
|
@@ -224,13 +247,60 @@ async function ensureServer(): Promise<ServerState> {
|
|
|
224
247
|
} catch {
|
|
225
248
|
// Health check failed — server is dead or unhealthy
|
|
226
249
|
}
|
|
250
|
+
|
|
251
|
+
// Server is alive but unhealthy (shutting down, browser crashed).
|
|
252
|
+
// Kill it so we can start fresh.
|
|
253
|
+
try { process.kill(state.pid, 'SIGTERM'); } catch {}
|
|
254
|
+
// Brief wait for graceful exit
|
|
255
|
+
const deadline = Date.now() + 3000;
|
|
256
|
+
while (Date.now() < deadline && isProcessAlive(state.pid)) {
|
|
257
|
+
await Bun.sleep(100);
|
|
258
|
+
}
|
|
259
|
+
if (isProcessAlive(state.pid)) {
|
|
260
|
+
try { process.kill(state.pid, 'SIGKILL'); } catch {}
|
|
261
|
+
await Bun.sleep(200);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Clean up stale state file
|
|
266
|
+
if (state) {
|
|
267
|
+
try { fs.unlinkSync(STATE_FILE); } catch {}
|
|
227
268
|
}
|
|
228
269
|
|
|
270
|
+
// Clean up orphaned state files from other instances (e.g., old PPID-suffixed files)
|
|
271
|
+
cleanOrphanedServers();
|
|
272
|
+
|
|
229
273
|
// Need to (re)start
|
|
230
274
|
console.error('[browse] Starting server...');
|
|
231
275
|
return startServer();
|
|
232
276
|
}
|
|
233
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Clean up orphaned browse servers:
|
|
280
|
+
* 1. Remove state files with dead PIDs
|
|
281
|
+
* 2. Kill live servers from other instances (old PPID-suffixed state files)
|
|
282
|
+
*/
|
|
283
|
+
function cleanOrphanedServers(): void {
|
|
284
|
+
try {
|
|
285
|
+
const files = fs.readdirSync(LOCAL_DIR);
|
|
286
|
+
for (const file of files) {
|
|
287
|
+
if (!file.startsWith('browse-server') || !file.endsWith('.json') || file.endsWith('.lock')) continue;
|
|
288
|
+
const filePath = path.join(LOCAL_DIR, file);
|
|
289
|
+
if (filePath === STATE_FILE) continue; // Don't touch our own state file
|
|
290
|
+
try {
|
|
291
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
292
|
+
if (data.pid) {
|
|
293
|
+
if (isProcessAlive(data.pid)) {
|
|
294
|
+
// Live orphan from a different instance — kill it to free the port
|
|
295
|
+
try { process.kill(data.pid, 'SIGTERM'); } catch {}
|
|
296
|
+
}
|
|
297
|
+
fs.unlinkSync(filePath);
|
|
298
|
+
}
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
} catch {}
|
|
302
|
+
}
|
|
303
|
+
|
|
234
304
|
// ─── Command Dispatch ──────────────────────────────────────────
|
|
235
305
|
|
|
236
306
|
// Commands that are safe to retry after a transport failure.
|
|
@@ -241,10 +311,10 @@ async function ensureServer(): Promise<ServerState> {
|
|
|
241
311
|
export const SAFE_TO_RETRY = new Set([
|
|
242
312
|
// Read commands — no side effects
|
|
243
313
|
'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',
|
|
314
|
+
'css', 'attrs', 'element-state', 'dialog',
|
|
315
|
+
'console', 'network', 'cookies', 'perf', 'value', 'count',
|
|
316
|
+
// Meta commands that are read-only or idempotent
|
|
317
|
+
'tabs', 'status', 'url', 'snapshot', 'snapshot-diff', 'devices', 'sessions', 'frame',
|
|
248
318
|
]);
|
|
249
319
|
|
|
250
320
|
// Commands that return static data independent of page state.
|
|
@@ -261,6 +331,15 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
|
|
261
331
|
if (sessionId) {
|
|
262
332
|
headers['X-Browse-Session'] = sessionId;
|
|
263
333
|
}
|
|
334
|
+
if (cliFlags.json) {
|
|
335
|
+
headers['X-Browse-Json'] = '1';
|
|
336
|
+
}
|
|
337
|
+
if (cliFlags.contentBoundaries) {
|
|
338
|
+
headers['X-Browse-Boundaries'] = '1';
|
|
339
|
+
}
|
|
340
|
+
if (cliFlags.allowedDomains) {
|
|
341
|
+
headers['X-Browse-Allowed-Domains'] = cliFlags.allowedDomains;
|
|
342
|
+
}
|
|
264
343
|
|
|
265
344
|
try {
|
|
266
345
|
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
|
|
@@ -286,22 +365,24 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
|
|
286
365
|
process.stdout.write(text);
|
|
287
366
|
if (!text.endsWith('\n')) process.stdout.write('\n');
|
|
288
367
|
|
|
289
|
-
// After restart
|
|
290
|
-
if (command === 'restart') {
|
|
368
|
+
// After stop/restart, wait for old server to actually die
|
|
369
|
+
if (command === 'stop' || command === 'restart') {
|
|
291
370
|
const oldPid = state.pid;
|
|
292
|
-
// Wait up to 5s for graceful shutdown
|
|
293
371
|
const deadline = Date.now() + 5000;
|
|
294
372
|
while (Date.now() < deadline && isProcessAlive(oldPid)) {
|
|
295
373
|
await Bun.sleep(100);
|
|
296
374
|
}
|
|
297
|
-
// If still alive (e.g. browserManager.close() stalled), force-kill
|
|
298
375
|
if (isProcessAlive(oldPid)) {
|
|
299
376
|
try { process.kill(oldPid, 'SIGKILL'); } catch {}
|
|
300
|
-
// Brief wait for OS to reclaim the process and release the port
|
|
301
377
|
await Bun.sleep(300);
|
|
302
378
|
}
|
|
303
|
-
|
|
304
|
-
|
|
379
|
+
// Clean up state file
|
|
380
|
+
try { fs.unlinkSync(STATE_FILE); } catch {}
|
|
381
|
+
|
|
382
|
+
if (command === 'restart') {
|
|
383
|
+
const newState = await startServer();
|
|
384
|
+
console.error(`[browse] Server restarted (PID: ${newState.pid})`);
|
|
385
|
+
}
|
|
305
386
|
}
|
|
306
387
|
} else {
|
|
307
388
|
// Try to parse as JSON error
|
|
@@ -356,9 +437,12 @@ async function sendCommand(state: ServerState, command: string, args: string[],
|
|
|
356
437
|
}
|
|
357
438
|
|
|
358
439
|
// ─── Main ──────────────────────────────────────────────────────
|
|
359
|
-
async function main() {
|
|
440
|
+
export async function main() {
|
|
360
441
|
const args = process.argv.slice(2);
|
|
361
442
|
|
|
443
|
+
// Load project config (browse.json) — values serve as defaults
|
|
444
|
+
const config = loadConfig();
|
|
445
|
+
|
|
362
446
|
// Extract --session flag before command parsing
|
|
363
447
|
let sessionId: string | undefined;
|
|
364
448
|
const sessionIdx = args.indexOf('--session');
|
|
@@ -370,7 +454,43 @@ async function main() {
|
|
|
370
454
|
}
|
|
371
455
|
args.splice(sessionIdx, 2); // remove --session and its value
|
|
372
456
|
}
|
|
373
|
-
sessionId = sessionId || process.env.BROWSE_SESSION || undefined;
|
|
457
|
+
sessionId = sessionId || process.env.BROWSE_SESSION || config.session || undefined;
|
|
458
|
+
|
|
459
|
+
// Extract --json flag
|
|
460
|
+
let jsonMode = false;
|
|
461
|
+
const jsonIdx = args.indexOf('--json');
|
|
462
|
+
if (jsonIdx !== -1) {
|
|
463
|
+
jsonMode = true;
|
|
464
|
+
args.splice(jsonIdx, 1);
|
|
465
|
+
}
|
|
466
|
+
jsonMode = jsonMode || process.env.BROWSE_JSON === '1' || config.json === true;
|
|
467
|
+
|
|
468
|
+
// Extract --content-boundaries flag
|
|
469
|
+
let contentBoundaries = false;
|
|
470
|
+
const boundIdx = args.indexOf('--content-boundaries');
|
|
471
|
+
if (boundIdx !== -1) {
|
|
472
|
+
contentBoundaries = true;
|
|
473
|
+
args.splice(boundIdx, 1);
|
|
474
|
+
}
|
|
475
|
+
contentBoundaries = contentBoundaries || process.env.BROWSE_CONTENT_BOUNDARIES === '1' || config.contentBoundaries === true;
|
|
476
|
+
|
|
477
|
+
// Extract --allowed-domains flag
|
|
478
|
+
let allowedDomains: string | undefined;
|
|
479
|
+
const domIdx = args.indexOf('--allowed-domains');
|
|
480
|
+
if (domIdx !== -1) {
|
|
481
|
+
allowedDomains = args[domIdx + 1];
|
|
482
|
+
if (!allowedDomains || allowedDomains.startsWith('-')) {
|
|
483
|
+
console.error('Usage: browse --allowed-domains domain1,domain2 <command> [args...]');
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
args.splice(domIdx, 2);
|
|
487
|
+
}
|
|
488
|
+
allowedDomains = allowedDomains || process.env.BROWSE_ALLOWED_DOMAINS || (config.allowedDomains ? config.allowedDomains.join(',') : undefined);
|
|
489
|
+
|
|
490
|
+
// Set global flags for sendCommand()
|
|
491
|
+
cliFlags.json = jsonMode;
|
|
492
|
+
cliFlags.contentBoundaries = contentBoundaries;
|
|
493
|
+
cliFlags.allowedDomains = allowedDomains || '';
|
|
374
494
|
|
|
375
495
|
// ─── Local commands (no server needed) ─────────────────────
|
|
376
496
|
if (args[0] === 'install-skill') {
|
|
@@ -382,31 +502,42 @@ async function main() {
|
|
|
382
502
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
383
503
|
console.log(`browse — Fast headless browser for AI coding agents
|
|
384
504
|
|
|
385
|
-
Usage: browse [
|
|
505
|
+
Usage: browse [options] <command> [args...]
|
|
386
506
|
|
|
387
507
|
Navigation: goto <url> | back | forward | reload | url
|
|
388
508
|
Content: text | html [sel] | links | forms | accessibility
|
|
389
509
|
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
|
390
|
-
hover <sel> |
|
|
391
|
-
|
|
510
|
+
hover <sel> | dblclick <sel> | focus <sel>
|
|
511
|
+
check <sel> | uncheck <sel> | drag <src> <tgt>
|
|
512
|
+
type <text> | press <key> | keydown <key> | keyup <key>
|
|
513
|
+
scroll [sel|up|down] | wait <sel|--url|--network-idle>
|
|
514
|
+
viewport <WxH> | highlight <sel> | download <sel> [path]
|
|
392
515
|
Device: emulate <device> | emulate reset | devices [filter]
|
|
393
516
|
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
394
|
-
console [--clear] | network [--clear]
|
|
517
|
+
element-state <sel> | console [--clear] | network [--clear]
|
|
395
518
|
cookies | storage [set <k> <v>] | perf
|
|
519
|
+
value <sel> | count <sel>
|
|
396
520
|
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
397
521
|
Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
|
|
398
522
|
Compare: diff <url1> <url2>
|
|
399
523
|
Multi-step: chain (reads JSON from stdin)
|
|
524
|
+
Network: offline [on|off] | route <pattern> block|fulfill
|
|
525
|
+
Recording: har start | har stop [path]
|
|
400
526
|
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
|
527
|
+
Frames: frame <sel> | frame main
|
|
401
528
|
Sessions: sessions | session-close <id>
|
|
529
|
+
Auth: auth save <name> <url> <user> <pass|--password-stdin>
|
|
530
|
+
auth login <name> | auth list | auth delete <name>
|
|
531
|
+
State: state save [name] | state load [name]
|
|
402
532
|
Server: status | cookie <n>=<v> | header <n>:<v>
|
|
403
533
|
useragent <str> | stop | restart
|
|
404
534
|
Setup: install-skill [path]
|
|
405
535
|
|
|
406
536
|
Options:
|
|
407
|
-
--session <id>
|
|
408
|
-
|
|
409
|
-
|
|
537
|
+
--session <id> Named session (isolates tabs, refs, cookies)
|
|
538
|
+
--json Wrap output as {success, data, command}
|
|
539
|
+
--content-boundaries Wrap page content in nonce-delimited markers
|
|
540
|
+
--allowed-domains <d,d> Block navigation/resources outside allowlist
|
|
410
541
|
|
|
411
542
|
Snapshot flags:
|
|
412
543
|
-i Interactive elements only (buttons, links, inputs)
|
|
@@ -434,7 +565,10 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|
|
434
565
|
await sendCommand(state, command, commandArgs, 0, sessionId);
|
|
435
566
|
}
|
|
436
567
|
|
|
437
|
-
if (
|
|
568
|
+
if (process.env.__BROWSE_SERVER_MODE === '1') {
|
|
569
|
+
import('./server');
|
|
570
|
+
} else if (import.meta.main) {
|
|
571
|
+
// Direct execution: bun run src/cli.ts <command>
|
|
438
572
|
main().catch((err) => {
|
|
439
573
|
console.error(`[browse] ${err.message}`);
|
|
440
574
|
process.exit(1);
|