@ulpi/browse 0.7.5 → 1.0.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/server.ts DELETED
@@ -1,485 +0,0 @@
1
- /**
2
- * browse server — persistent Chromium daemon
3
- *
4
- * Architecture:
5
- * Bun.serve HTTP on localhost → routes commands to Playwright
6
- * Session multiplexing: multiple agents share one Chromium via X-Browse-Session header
7
- * Console/network buffers: per-session in-memory + disk flush every 1s
8
- * Chromium crash → server EXITS with clear error (CLI auto-restarts)
9
- * Auto-shutdown when all sessions idle past BROWSE_IDLE_TIMEOUT (default 30 min)
10
- */
11
-
12
- import type { Browser } from 'playwright';
13
- import { getRuntime, type BrowserRuntime } from './runtime';
14
- import { SessionManager, type Session } from './session-manager';
15
- import { handleReadCommand } from './commands/read';
16
- import { handleWriteCommand } from './commands/write';
17
- import { handleMetaCommand } from './commands/meta';
18
- import { PolicyChecker } from './policy';
19
- import { DEFAULTS } from './constants';
20
- import { type LogEntry, type NetworkEntry } from './buffers';
21
- import * as fs from 'fs';
22
- import * as path from 'path';
23
- import * as crypto from 'crypto';
24
-
25
- // Re-export types for backward compatibility
26
- export { type LogEntry, type NetworkEntry };
27
-
28
- // ─── Auth (inline) ─────────────────────────────────────────────
29
- const AUTH_TOKEN = crypto.randomUUID();
30
- const DEBUG_PORT = parseInt(process.env.BROWSE_DEBUG_PORT || '0', 10);
31
- const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
32
- const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || '';
33
- const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
34
- const LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || '/tmp';
35
- const STATE_FILE = process.env.BROWSE_STATE_FILE || `${LOCAL_DIR}/browse-server${INSTANCE_SUFFIX}.json`;
36
- const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || String(DEFAULTS.IDLE_TIMEOUT_MS), 10);
37
-
38
- function validateAuth(req: Request): boolean {
39
- const header = req.headers.get('authorization');
40
- return header === `Bearer ${AUTH_TOKEN}`;
41
- }
42
-
43
- // ─── Per-Session Buffer Flush ──────────────────────────────────
44
- // Flushes each session's buffers to separate log files on disk.
45
-
46
- function flushAllBuffers(sessionManager: SessionManager, final = false) {
47
- for (const session of sessionManager.getAllSessions()) {
48
- flushSessionBuffers(session, final);
49
- }
50
- }
51
-
52
- function flushSessionBuffers(session: Session, final: boolean) {
53
- const consolePath = `${session.outputDir}/console.log`;
54
- const networkPath = `${session.outputDir}/network.log`;
55
- const buffers = session.buffers;
56
-
57
- // Console flush
58
- const newConsoleCount = buffers.consoleTotalAdded - buffers.lastConsoleFlushed;
59
- if (newConsoleCount > 0) {
60
- const count = Math.min(newConsoleCount, buffers.consoleBuffer.length);
61
- const newEntries = buffers.consoleBuffer.slice(-count);
62
- const lines = newEntries.map(e =>
63
- `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
64
- ).join('\n') + '\n';
65
- fs.appendFileSync(consolePath, lines);
66
- buffers.lastConsoleFlushed = buffers.consoleTotalAdded;
67
- }
68
-
69
- // Network flush
70
- let newNetworkCount = buffers.networkTotalAdded - buffers.lastNetworkFlushed;
71
- if (newNetworkCount > 0) {
72
- if (newNetworkCount > buffers.networkBuffer.length) {
73
- buffers.lastNetworkFlushed = buffers.networkTotalAdded - buffers.networkBuffer.length;
74
- newNetworkCount = buffers.networkBuffer.length;
75
- }
76
- const newEntries = buffers.networkBuffer.slice(-newNetworkCount);
77
- const now = Date.now();
78
-
79
- let prefixLen = 0;
80
- for (let i = 0; i < newEntries.length; i++) {
81
- const e = newEntries[i];
82
- if (final || e.status !== undefined || (now - e.timestamp > DEFAULTS.NETWORK_SETTLE_MS)) {
83
- prefixLen = i + 1;
84
- } else {
85
- break;
86
- }
87
- }
88
-
89
- if (prefixLen > 0) {
90
- const prefix = newEntries.slice(0, prefixLen);
91
- const lines = prefix.map(e =>
92
- `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
93
- ).join('\n') + '\n';
94
- fs.appendFileSync(networkPath, lines);
95
- buffers.lastNetworkFlushed += prefixLen;
96
- }
97
- }
98
- }
99
-
100
- // ─── Server ────────────────────────────────────────────────────
101
- let sessionManager: SessionManager;
102
- let browser: Browser;
103
- let activeRuntime: BrowserRuntime | undefined;
104
- let isShuttingDown = false;
105
- let isRemoteBrowser = false;
106
- const policyChecker = new PolicyChecker();
107
-
108
- // Read/write/meta command sets for routing
109
- const READ_COMMANDS = new Set([
110
- 'text', 'html', 'links', 'forms', 'accessibility',
111
- 'js', 'eval', 'css', 'attrs', 'element-state', 'dialog',
112
- 'console', 'network', 'cookies', 'storage', 'perf', 'devices',
113
- 'value', 'count', 'clipboard',
114
- ]);
115
-
116
- const WRITE_COMMANDS = new Set([
117
- 'goto', 'back', 'forward', 'reload',
118
- 'click', 'dblclick', 'fill', 'select', 'hover', 'focus', 'check', 'uncheck',
119
- 'type', 'press', 'scroll', 'wait',
120
- 'viewport', 'cookie', 'header', 'useragent',
121
- 'upload', 'dialog-accept', 'dialog-dismiss', 'emulate',
122
- 'drag', 'keydown', 'keyup',
123
- 'highlight', 'download', 'route', 'offline',
124
- ]);
125
-
126
- const META_COMMANDS = new Set([
127
- 'tabs', 'tab', 'newtab', 'closetab',
128
- 'status', 'stop', 'restart',
129
- 'screenshot', 'pdf', 'responsive',
130
- 'chain', 'diff',
131
- 'url', 'snapshot', 'snapshot-diff', 'screenshot-diff',
132
- 'sessions', 'session-close',
133
- 'frame', 'state', 'find',
134
- 'auth', 'har', 'video', 'inspect',
135
- ]);
136
-
137
- // Probe if a port is free using net.createServer (not Bun.serve which fatally crashes on EADDRINUSE)
138
- import * as net from 'net';
139
-
140
- function isPortFree(port: number): Promise<boolean> {
141
- return new Promise((resolve) => {
142
- const srv = net.createServer();
143
- srv.once('error', () => resolve(false));
144
- srv.once('listening', () => { srv.close(() => resolve(true)); });
145
- srv.listen(port, '127.0.0.1');
146
- });
147
- }
148
-
149
- // Find port: use BROWSE_PORT or scan range
150
- async function findPort(): Promise<number> {
151
- if (BROWSE_PORT) {
152
- if (await isPortFree(BROWSE_PORT)) return BROWSE_PORT;
153
- throw new Error(`[browse] Port ${BROWSE_PORT} is in use`);
154
- }
155
-
156
- // Scan range
157
- const start = parseInt(process.env.BROWSE_PORT_START || String(DEFAULTS.PORT_RANGE_START), 10);
158
- const end = start + (DEFAULTS.PORT_RANGE_END - DEFAULTS.PORT_RANGE_START);
159
- for (let port = start; port <= end; port++) {
160
- if (await isPortFree(port)) return port;
161
- }
162
- throw new Error(`[browse] No available port in range ${start}-${end}`);
163
- }
164
-
165
- // Commands that return page-derived content (for --content-boundaries wrapping).
166
- // Action commands (click, goto) and meta commands (status, tabs) are NOT wrapped.
167
- const PAGE_CONTENT_COMMANDS = new Set([
168
- 'text', 'html', 'links', 'forms', 'accessibility',
169
- 'js', 'eval', 'console', 'network', 'snapshot',
170
- ]);
171
-
172
- // Nonce for content boundaries — generated once per server process
173
- const BOUNDARY_NONCE = crypto.randomUUID();
174
-
175
- interface RequestOptions {
176
- jsonMode: boolean;
177
- contentBoundaries: boolean;
178
- }
179
-
180
- /**
181
- * Rewrite Playwright error messages into actionable hints for AI agents.
182
- * Raw errors like "locator.click: Timeout 5000ms exceeded" are unhelpful.
183
- */
184
- function rewriteError(msg: string): string {
185
- if (msg.includes('strict mode violation')) {
186
- const countMatch = msg.match(/resolved to (\d+) elements/);
187
- return `Multiple elements matched (${countMatch?.[1] || 'several'}). Use a more specific selector or run 'snapshot -i' to find exact refs.`;
188
- }
189
- if (msg.includes('Timeout') && msg.includes('exceeded')) {
190
- const timeMatch = msg.match(/Timeout (\d+)ms/);
191
- return `Element not found within ${timeMatch?.[1] || '?'}ms. The element may not exist, be hidden, or the page is still loading. Try 'wait <selector>' first, or check with 'snapshot -i'.`;
192
- }
193
- if (msg.includes('waiting for locator') || msg.includes('waiting for selector')) {
194
- return `Element not found on the page. Run 'snapshot -i' to see available elements, or check the current URL with 'url'.`;
195
- }
196
- if (msg.includes('not an HTMLInputElement') || msg.includes('not an input')) {
197
- return `Cannot fill this element — it's not an input field. Use 'click' instead, or run 'snapshot -i' to find the correct input.`;
198
- }
199
- if (msg.includes('Element is not visible')) {
200
- return `Element exists but is hidden (display:none or visibility:hidden). Try scrolling to it with 'scroll <selector>' or wait for it with 'wait <selector>'.`;
201
- }
202
- if (msg.includes('Element is outside of the viewport')) {
203
- return `Element is off-screen. Scroll to it first with 'scroll <selector>'.`;
204
- }
205
- if (msg.includes('intercepts pointer events')) {
206
- return `Another element is covering the target (e.g., a modal, overlay, or cookie banner). Close the overlay first or use 'js' to click directly.`;
207
- }
208
- if (msg.includes('Frame was detached') || msg.includes('frame was detached')) {
209
- return `The iframe was removed or navigated away. Run 'frame main' to return to the main page, then re-navigate.`;
210
- }
211
- if (msg.includes('Target closed') || msg.includes('target closed')) {
212
- return `The page or tab was closed. Use 'tabs' to list open tabs, or 'goto' to navigate to a new page.`;
213
- }
214
- if (msg.includes('net::ERR_')) {
215
- const errMatch = msg.match(/(net::\w+)/);
216
- return `Network error: ${errMatch?.[1] || 'connection failed'}. Check the URL and ensure the site is reachable.`;
217
- }
218
- return msg;
219
- }
220
-
221
- async function handleCommand(body: any, session: Session, opts: RequestOptions): Promise<Response> {
222
- const { command, args = [] } = body;
223
-
224
- if (!command) {
225
- const error = 'Missing "command" field';
226
- if (opts.jsonMode) {
227
- return new Response(JSON.stringify({ success: false, error }), {
228
- status: 400, headers: { 'Content-Type': 'application/json' },
229
- });
230
- }
231
- return new Response(JSON.stringify({ error }), {
232
- status: 400, headers: { 'Content-Type': 'application/json' },
233
- });
234
- }
235
-
236
- // Policy check
237
- const policyResult = policyChecker.check(command);
238
- if (policyResult === 'deny') {
239
- const error = `Command '${command}' denied by policy`;
240
- const hint = 'Update browse-policy.json to allow this command.';
241
- if (opts.jsonMode) {
242
- return new Response(JSON.stringify({ success: false, error, hint }), {
243
- status: 403, headers: { 'Content-Type': 'application/json' },
244
- });
245
- }
246
- return new Response(JSON.stringify({ error, hint }), {
247
- status: 403, headers: { 'Content-Type': 'application/json' },
248
- });
249
- }
250
- if (policyResult === 'confirm') {
251
- const error = `Command '${command}' requires confirmation (policy). Non-interactive CLI cannot confirm.`;
252
- const hint = 'Move this command to the allow list in browse-policy.json.';
253
- if (opts.jsonMode) {
254
- return new Response(JSON.stringify({ success: false, error, hint }), {
255
- status: 403, headers: { 'Content-Type': 'application/json' },
256
- });
257
- }
258
- return new Response(JSON.stringify({ error, hint }), {
259
- status: 403, headers: { 'Content-Type': 'application/json' },
260
- });
261
- }
262
-
263
- try {
264
- let result: string;
265
-
266
- if (READ_COMMANDS.has(command)) {
267
- result = await handleReadCommand(command, args, session.manager, session.buffers);
268
- } else if (WRITE_COMMANDS.has(command)) {
269
- result = await handleWriteCommand(command, args, session.manager, session.domainFilter);
270
- } else if (META_COMMANDS.has(command)) {
271
- result = await handleMetaCommand(command, args, session.manager, shutdown, sessionManager, session);
272
- } else {
273
- const error = `Unknown command: ${command}`;
274
- const hint = `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`;
275
- if (opts.jsonMode) {
276
- return new Response(JSON.stringify({ success: false, error, hint }), {
277
- status: 400, headers: { 'Content-Type': 'application/json' },
278
- });
279
- }
280
- return new Response(JSON.stringify({ error, hint }), {
281
- status: 400, headers: { 'Content-Type': 'application/json' },
282
- });
283
- }
284
-
285
- // Apply content boundaries for page-content commands
286
- if (opts.contentBoundaries && PAGE_CONTENT_COMMANDS.has(command)) {
287
- const origin = session.manager.getCurrentUrl();
288
- result = `--- BROWSE_CONTENT nonce=${BOUNDARY_NONCE} origin=${origin} ---\n${result}\n--- END_BROWSE_CONTENT nonce=${BOUNDARY_NONCE} ---`;
289
- }
290
-
291
- // Apply JSON wrapping
292
- if (opts.jsonMode) {
293
- return new Response(JSON.stringify({ success: true, data: result, command }), {
294
- status: 200, headers: { 'Content-Type': 'application/json' },
295
- });
296
- }
297
-
298
- return new Response(result, {
299
- status: 200, headers: { 'Content-Type': 'text/plain' },
300
- });
301
- } catch (err: any) {
302
- const friendlyError = rewriteError(err.message);
303
- if (opts.jsonMode) {
304
- return new Response(JSON.stringify({ success: false, error: friendlyError, command }), {
305
- status: 500, headers: { 'Content-Type': 'application/json' },
306
- });
307
- }
308
- return new Response(JSON.stringify({ error: friendlyError }), {
309
- status: 500, headers: { 'Content-Type': 'application/json' },
310
- });
311
- }
312
- }
313
-
314
- async function shutdown() {
315
- if (isShuttingDown) return;
316
- isShuttingDown = true;
317
-
318
- console.log('[browse] Shutting down...');
319
- clearInterval(flushInterval);
320
- clearInterval(sessionCleanupInterval);
321
- flushAllBuffers(sessionManager, true);
322
-
323
- await sessionManager.closeAll();
324
-
325
- // Close the shared browser (skip if remote — we don't own it)
326
- if (browser && !isRemoteBrowser) {
327
- browser.removeAllListeners('disconnected');
328
- await browser.close().catch(() => {});
329
- }
330
-
331
- // Clean up runtime resources (e.g. lightpanda child process)
332
- await activeRuntime?.close?.().catch(() => {});
333
-
334
- // Only remove state file if it still belongs to this server instance.
335
- try {
336
- const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
337
- if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
338
- fs.unlinkSync(STATE_FILE);
339
- }
340
- } catch {}
341
-
342
- process.exit(0);
343
- }
344
-
345
- // Handle signals
346
- process.on('SIGTERM', shutdown);
347
- process.on('SIGINT', shutdown);
348
-
349
- // ─── Flush Timer ────────────────────────────────────────────────
350
- const flushInterval = setInterval(() => {
351
- if (sessionManager) flushAllBuffers(sessionManager);
352
- }, DEFAULTS.BUFFER_FLUSH_INTERVAL_MS);
353
-
354
- // ─── Session Idle Cleanup ───────────────────────────────────────
355
- const sessionCleanupInterval = setInterval(async () => {
356
- if (!sessionManager || isShuttingDown) return;
357
-
358
- const closed = await sessionManager.closeIdleSessions(IDLE_TIMEOUT_MS, (session) => flushSessionBuffers(session, true));
359
- for (const id of closed) {
360
- console.log(`[browse] Session "${id}" idle for ${IDLE_TIMEOUT_MS / 1000}s — closed`);
361
- }
362
-
363
- if (sessionManager.getSessionCount() === 0) {
364
- console.log('[browse] All sessions idle — shutting down');
365
- shutdown();
366
- }
367
- }, 60_000);
368
-
369
- // ─── Start ─────────────────────────────────────────────────────
370
- async function start() {
371
- const port = await findPort();
372
-
373
- // Resolve browser runtime (playwright, rebrowser, etc.)
374
- const runtimeName = process.env.BROWSE_RUNTIME;
375
- const runtime = await getRuntime(runtimeName);
376
- activeRuntime = runtime;
377
- console.log(`[browse] Runtime: ${runtime.name}`);
378
-
379
- // Launch or connect to browser
380
- const cdpUrl = process.env.BROWSE_CDP_URL;
381
- if (cdpUrl) {
382
- // Connect to remote Chrome via CDP
383
- browser = await runtime.chromium.connectOverCDP(cdpUrl);
384
- isRemoteBrowser = true;
385
- console.log(`[browse] Connected to remote Chrome via CDP: ${cdpUrl}`);
386
- } else if (runtime.browser) {
387
- // Process runtime (e.g. lightpanda) -- browser already connected
388
- browser = runtime.browser;
389
- browser.on('disconnected', () => {
390
- if (isShuttingDown) return;
391
- console.error('[browse] Browser disconnected. Shutting down.');
392
- shutdown();
393
- });
394
- } else {
395
- // Launch local Chromium
396
- const launchOptions: Record<string, any> = { headless: process.env.BROWSE_HEADED !== '1' };
397
- if (DEBUG_PORT > 0) {
398
- launchOptions.args = [`--remote-debugging-port=${DEBUG_PORT}`];
399
- }
400
- const proxyServer = process.env.BROWSE_PROXY;
401
- if (proxyServer) {
402
- launchOptions.proxy = { server: proxyServer };
403
- if (process.env.BROWSE_PROXY_BYPASS) {
404
- launchOptions.proxy.bypass = process.env.BROWSE_PROXY_BYPASS;
405
- }
406
- }
407
- browser = await runtime.chromium.launch(launchOptions);
408
-
409
- // Chromium crash → clean shutdown (only for owned browser)
410
- browser.on('disconnected', () => {
411
- if (isShuttingDown) return;
412
- console.error('[browse] Chromium disconnected. Shutting down.');
413
- shutdown();
414
- });
415
- }
416
-
417
- // Create session manager
418
- sessionManager = new SessionManager(browser, LOCAL_DIR);
419
-
420
- const startTime = Date.now();
421
- const server = Bun.serve({
422
- port,
423
- hostname: '127.0.0.1',
424
- fetch: async (req) => {
425
- const url = new URL(req.url);
426
-
427
- // Health check — no auth required
428
- if (url.pathname === '/health') {
429
- const healthy = !isShuttingDown && browser.isConnected();
430
- return new Response(JSON.stringify({
431
- status: healthy ? 'healthy' : 'unhealthy',
432
- uptime: Math.floor((Date.now() - startTime) / 1000),
433
- sessions: sessionManager.getSessionCount(),
434
- }), {
435
- status: 200,
436
- headers: { 'Content-Type': 'application/json' },
437
- });
438
- }
439
-
440
- // All other endpoints require auth
441
- if (!validateAuth(req)) {
442
- return new Response(JSON.stringify({ error: 'Unauthorized' }), {
443
- status: 401,
444
- headers: { 'Content-Type': 'application/json' },
445
- });
446
- }
447
-
448
- if (url.pathname === '/command' && req.method === 'POST') {
449
- const body = await req.json();
450
- const sessionId = req.headers.get('x-browse-session') || 'default';
451
- const allowedDomains = req.headers.get('x-browse-allowed-domains') || undefined;
452
- const session = await sessionManager.getOrCreate(sessionId, allowedDomains);
453
- const opts: RequestOptions = {
454
- jsonMode: req.headers.get('x-browse-json') === '1',
455
- contentBoundaries: req.headers.get('x-browse-boundaries') === '1',
456
- };
457
- return handleCommand(body, session, opts);
458
- }
459
-
460
- return new Response('Not found', { status: 404 });
461
- },
462
- });
463
-
464
- // Write state file
465
- const state: Record<string, any> = {
466
- pid: process.pid,
467
- port,
468
- token: AUTH_TOKEN,
469
- startedAt: new Date().toISOString(),
470
- serverPath: path.resolve(import.meta.dir, 'server.ts'),
471
- };
472
- if (DEBUG_PORT > 0) {
473
- state.debugPort = DEBUG_PORT;
474
- }
475
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
476
-
477
- console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`);
478
- console.log(`[browse] State file: ${STATE_FILE}`);
479
- console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`);
480
- }
481
-
482
- start().catch((err) => {
483
- console.error(`[browse] Failed to start: ${err.message}`);
484
- process.exit(1);
485
- });
@@ -1,192 +0,0 @@
1
- /**
2
- * Session manager — multiplexes multiple agents on a single Chromium instance
3
- *
4
- * Each session gets its own BrowserManager (tabs, refs, cookies, storage)
5
- * backed by an isolated BrowserContext on the shared Browser.
6
- * Sessions are identified by string IDs (from X-Browse-Session header).
7
- */
8
-
9
- import type { Browser } from 'playwright';
10
- import { BrowserManager } from './browser-manager';
11
- import { SessionBuffers } from './buffers';
12
- import { DomainFilter } from './domain-filter';
13
- import { sanitizeName } from './sanitize';
14
- import * as fs from 'fs';
15
- import * as path from 'path';
16
-
17
- export interface Session {
18
- id: string;
19
- manager: BrowserManager;
20
- buffers: SessionBuffers;
21
- domainFilter: DomainFilter | null;
22
- outputDir: string;
23
- lastActivity: number;
24
- createdAt: number;
25
- }
26
-
27
- export class SessionManager {
28
- private sessions = new Map<string, Session>();
29
- private browser: Browser;
30
- private localDir: string;
31
-
32
- constructor(browser: Browser, localDir: string = '/tmp') {
33
- this.browser = browser;
34
- this.localDir = localDir;
35
- }
36
-
37
- /**
38
- * Get an existing session or create a new one.
39
- * Creating a session launches a new BrowserContext on the shared Chromium.
40
- */
41
- async getOrCreate(sessionId: string, allowedDomains?: string): Promise<Session> {
42
- let session = this.sessions.get(sessionId);
43
- if (session) {
44
- session.lastActivity = Date.now();
45
- // Update domain filter if provided and session doesn't already have one
46
- if (allowedDomains && !session.domainFilter) {
47
- const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
48
- if (domains.length > 0) {
49
- const domainFilter = new DomainFilter(domains);
50
- session.manager.setDomainFilter(domainFilter);
51
- const context = session.manager.getContext();
52
- if (context) {
53
- await context.route('**/*', (route) => {
54
- const url = route.request().url();
55
- if (domainFilter.isAllowed(url)) {
56
- route.fallback();
57
- } else {
58
- route.abort('blockedbyclient');
59
- }
60
- });
61
- const initScript = domainFilter.generateInitScript();
62
- await context.addInitScript(initScript);
63
- session.manager.setInitScript(initScript);
64
- // Inject filter script into ALL open tabs immediately
65
- for (const tab of session.manager.getTabList()) {
66
- try {
67
- const page = session.manager.getPageById(tab.id);
68
- if (page) await page.evaluate(initScript);
69
- } catch {}
70
- }
71
- }
72
- session.domainFilter = domainFilter;
73
- }
74
- }
75
- return session;
76
- }
77
-
78
- // Create per-session output directory
79
- const outputDir = path.join(this.localDir, 'sessions', sanitizeName(sessionId));
80
- fs.mkdirSync(outputDir, { recursive: true });
81
-
82
- const buffers = new SessionBuffers();
83
- const manager = new BrowserManager(buffers);
84
- await manager.launchWithBrowser(this.browser);
85
-
86
- // Apply domain filter if allowed domains are specified
87
- let domainFilter: DomainFilter | null = null;
88
- if (allowedDomains) {
89
- const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
90
- if (domains.length > 0) {
91
- domainFilter = new DomainFilter(domains);
92
- manager.setDomainFilter(domainFilter);
93
- const context = manager.getContext();
94
- if (context) {
95
- // Block disallowed domains at the network level via Playwright route()
96
- await context.route('**/*', (route) => {
97
- const url = route.request().url();
98
- if (domainFilter!.isAllowed(url)) {
99
- route.fallback();
100
- } else {
101
- route.abort('blockedbyclient');
102
- }
103
- });
104
- // Block WebSocket, EventSource, sendBeacon via JS injection
105
- const initScript = domainFilter.generateInitScript();
106
- await context.addInitScript(initScript);
107
- manager.setInitScript(initScript);
108
- }
109
- }
110
- }
111
-
112
- session = {
113
- id: sessionId,
114
- manager,
115
- buffers,
116
- domainFilter,
117
- outputDir,
118
- lastActivity: Date.now(),
119
- createdAt: Date.now(),
120
- };
121
- this.sessions.set(sessionId, session);
122
- console.log(`[browse] Session "${sessionId}" created`);
123
- return session;
124
- }
125
-
126
- /**
127
- * Close and remove a specific session.
128
- */
129
- async closeSession(sessionId: string): Promise<void> {
130
- const session = this.sessions.get(sessionId);
131
- if (!session) throw new Error(`Session "${sessionId}" not found`);
132
-
133
- await session.manager.close();
134
- this.sessions.delete(sessionId);
135
- console.log(`[browse] Session "${sessionId}" closed`);
136
- }
137
-
138
- /**
139
- * Close sessions idle longer than maxIdleMs.
140
- * Returns list of closed session IDs.
141
- */
142
- async closeIdleSessions(maxIdleMs: number, flushFn?: (session: Session) => void): Promise<string[]> {
143
- const now = Date.now();
144
- const closed: string[] = [];
145
-
146
- for (const [id, session] of this.sessions) {
147
- if (now - session.lastActivity > maxIdleMs) {
148
- if (flushFn) flushFn(session);
149
- await session.manager.close().catch(() => {});
150
- this.sessions.delete(id);
151
- closed.push(id);
152
- }
153
- }
154
-
155
- return closed;
156
- }
157
-
158
- /**
159
- * List all active sessions (for status/sessions commands).
160
- */
161
- listSessions(): Array<{ id: string; tabs: number; url: string; idleSeconds: number; active: boolean }> {
162
- const now = Date.now();
163
- return [...this.sessions.entries()].map(([id, session]) => ({
164
- id,
165
- tabs: session.manager.getTabCount(),
166
- url: session.manager.getCurrentUrl(),
167
- idleSeconds: Math.floor((now - session.lastActivity) / 1000),
168
- active: true,
169
- }));
170
- }
171
-
172
- /**
173
- * Get all sessions (for buffer flush iteration).
174
- */
175
- getAllSessions(): Session[] {
176
- return [...this.sessions.values()];
177
- }
178
-
179
- getSessionCount(): number {
180
- return this.sessions.size;
181
- }
182
-
183
- /**
184
- * Close all sessions (for server shutdown).
185
- */
186
- async closeAll(): Promise<void> {
187
- for (const [id, session] of this.sessions) {
188
- await session.manager.close().catch(() => {});
189
- }
190
- this.sessions.clear();
191
- }
192
- }