@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/LICENSE +1 -1
- package/README.md +444 -300
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +114 -7
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -244
- package/src/browser-manager.ts +0 -961
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -70
- package/src/cli.ts +0 -683
- package/src/commands/meta.ts +0 -748
- package/src/commands/read.ts +0 -347
- package/src/commands/write.ts +0 -484
- package/src/config.ts +0 -45
- package/src/constants.ts +0 -14
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -485
- package/src/session-manager.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
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
|
-
});
|
package/src/session-manager.ts
DELETED
|
@@ -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
|
-
}
|