bwb-browser 2.0.3 → 3.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/AGENTS.md +31 -12
- package/README.md +130 -218
- package/lib/act.mjs +326 -0
- package/lib/browser.mjs +290 -0
- package/lib/diagnose.mjs +149 -0
- package/lib/fingerprint.mjs +100 -0
- package/lib/helpers.mjs +126 -0
- package/lib/session.mjs +88 -0
- package/lib/tabs.mjs +165 -0
- package/package.json +22 -4
- package/server.mjs +315 -655
package/lib/diagnose.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bwb-browser — Page Diagnostics
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive page health check: performance metrics, console errors,
|
|
5
|
+
* broken resources, meta tags, and network failures.
|
|
6
|
+
* Combines multiple CDP + JS checks into a single report.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a full diagnostic on the current page.
|
|
11
|
+
* Returns performance metrics, console errors, broken images, meta tags.
|
|
12
|
+
*/
|
|
13
|
+
export async function diagnosePage(protocol) {
|
|
14
|
+
const { Runtime, Network, Page } = protocol;
|
|
15
|
+
|
|
16
|
+
// Collect console errors during diagnostic
|
|
17
|
+
const consoleErrors = [];
|
|
18
|
+
let unsubConsole, unsubException;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await Runtime.enable();
|
|
22
|
+
unsubConsole = Runtime.consoleAPICalled((params) => {
|
|
23
|
+
if (params.type === "error" || params.type === "warning") {
|
|
24
|
+
consoleErrors.push({
|
|
25
|
+
level: params.type,
|
|
26
|
+
text: (params.args || []).map(a => a.value !== undefined ? String(a.value) : a.description || "").join(" "),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
unsubException = Runtime.exceptionThrown((params) => {
|
|
31
|
+
const d = params.exceptionDetails;
|
|
32
|
+
consoleErrors.push({
|
|
33
|
+
level: "exception",
|
|
34
|
+
text: d?.exception?.description || d?.text || "Unknown exception",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
// Allow page to settle
|
|
40
|
+
await new Promise(r => setTimeout(r, 300));
|
|
41
|
+
|
|
42
|
+
// Page load performance
|
|
43
|
+
let performance = {};
|
|
44
|
+
try {
|
|
45
|
+
const { result } = await Runtime.evaluate({
|
|
46
|
+
expression: `JSON.stringify({
|
|
47
|
+
loadTime: performance.timing ? (performance.timing.loadEventEnd - performance.timing.navigationStart) : null,
|
|
48
|
+
domContentLoaded: performance.timing ? (performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart) : null,
|
|
49
|
+
domInteractive: performance.timing ? (performance.timing.domInteractive - performance.timing.navigationStart) : null,
|
|
50
|
+
})`,
|
|
51
|
+
returnByValue: true,
|
|
52
|
+
});
|
|
53
|
+
performance = JSON.parse(result?.value || "{}");
|
|
54
|
+
} catch {}
|
|
55
|
+
|
|
56
|
+
// Meta tags
|
|
57
|
+
let meta = {};
|
|
58
|
+
try {
|
|
59
|
+
const { result } = await Runtime.evaluate({
|
|
60
|
+
expression: `JSON.stringify({
|
|
61
|
+
title: document.title,
|
|
62
|
+
description: (document.querySelector('meta[name=description]') || {}).content || '',
|
|
63
|
+
viewport: (document.querySelector('meta[name=viewport]') || {}).content || '',
|
|
64
|
+
charset: document.characterSet || '',
|
|
65
|
+
lang: document.documentElement?.lang || '',
|
|
66
|
+
})`,
|
|
67
|
+
returnByValue: true,
|
|
68
|
+
});
|
|
69
|
+
meta = JSON.parse(result?.value || "{}");
|
|
70
|
+
} catch {}
|
|
71
|
+
|
|
72
|
+
// Broken images
|
|
73
|
+
let brokenImages = [];
|
|
74
|
+
try {
|
|
75
|
+
const { result } = await Runtime.evaluate({
|
|
76
|
+
expression: `JSON.stringify(
|
|
77
|
+
Array.from(document.querySelectorAll('img')).filter(i => i.naturalWidth === 0 && i.src).map(i => ({
|
|
78
|
+
src: i.src.slice(0, 200),
|
|
79
|
+
alt: i.alt || '(missing)',
|
|
80
|
+
width: i.width,
|
|
81
|
+
height: i.height,
|
|
82
|
+
}))
|
|
83
|
+
)`,
|
|
84
|
+
returnByValue: true,
|
|
85
|
+
});
|
|
86
|
+
brokenImages = JSON.parse(result?.value || "[]");
|
|
87
|
+
} catch {}
|
|
88
|
+
|
|
89
|
+
// Links check
|
|
90
|
+
let linkCount = 0;
|
|
91
|
+
try {
|
|
92
|
+
const { result } = await Runtime.evaluate({
|
|
93
|
+
expression: "document.querySelectorAll('a[href]').length",
|
|
94
|
+
});
|
|
95
|
+
linkCount = result?.value || 0;
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
// Input count
|
|
99
|
+
let inputCount = 0;
|
|
100
|
+
try {
|
|
101
|
+
const { result } = await Runtime.evaluate({
|
|
102
|
+
expression: "document.querySelectorAll('input:not([type=hidden]), textarea, select, button, [role=button]').length",
|
|
103
|
+
});
|
|
104
|
+
inputCount = result?.value || 0;
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
// Current URL
|
|
108
|
+
let currentUrl = "";
|
|
109
|
+
try {
|
|
110
|
+
const { result } = await Runtime.evaluate({ expression: "location.href" });
|
|
111
|
+
currentUrl = result?.value || "";
|
|
112
|
+
} catch {}
|
|
113
|
+
|
|
114
|
+
// Cleanup
|
|
115
|
+
try { if (unsubConsole) unsubConsole(); } catch {}
|
|
116
|
+
try { if (unsubException) unsubException(); } catch {}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
url: currentUrl,
|
|
120
|
+
timestamp: new Date().toISOString(),
|
|
121
|
+
performance,
|
|
122
|
+
meta,
|
|
123
|
+
interactions: {
|
|
124
|
+
links: linkCount,
|
|
125
|
+
inputs: inputCount,
|
|
126
|
+
},
|
|
127
|
+
issues: {
|
|
128
|
+
consoleErrors: consoleErrors.slice(0, 20),
|
|
129
|
+
brokenImages: brokenImages.slice(0, 20),
|
|
130
|
+
hasBrokenImages: brokenImages.length > 0,
|
|
131
|
+
hasConsoleErrors: consoleErrors.length > 0,
|
|
132
|
+
},
|
|
133
|
+
score: calculateHealthScore(performance, consoleErrors, brokenImages),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function calculateHealthScore(perf, errors, brokenImgs) {
|
|
138
|
+
let score = 100;
|
|
139
|
+
|
|
140
|
+
// Penalize slow loads
|
|
141
|
+
if (perf.loadTime > 5000) score -= 20;
|
|
142
|
+
else if (perf.loadTime > 2000) score -= 10;
|
|
143
|
+
|
|
144
|
+
// Penalize errors
|
|
145
|
+
score -= errors.length * 5;
|
|
146
|
+
score -= brokenImgs.length * 10;
|
|
147
|
+
|
|
148
|
+
return Math.max(0, Math.min(100, score));
|
|
149
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bwb-browser — Realistic Browser Fingerprint
|
|
3
|
+
*
|
|
4
|
+
* Applies standard browser fingerprint countermeasures to reduce
|
|
5
|
+
* false-positive automation detection in CI/testing environments.
|
|
6
|
+
*
|
|
7
|
+
* These techniques are the same ones used by Playwright's Stealth plugin
|
|
8
|
+
* and Puppeteer Extra — common tools for testing your own site's
|
|
9
|
+
* bot-detection systems in a realistic browser environment.
|
|
10
|
+
*
|
|
11
|
+
* How it works:
|
|
12
|
+
* - Override navigator.webdriver → false
|
|
13
|
+
* - Set realistic navigator.plugins
|
|
14
|
+
* - Configure sensible language preferences
|
|
15
|
+
* - Override chrome.runtime references
|
|
16
|
+
* - Set a standard desktop User-Agent
|
|
17
|
+
*
|
|
18
|
+
* NOTE: Scripts are injected via Page.addScriptToEvaluateOnNewDocument,
|
|
19
|
+
* which means they only affect pages loaded AFTER applyRealisticProfile
|
|
20
|
+
* is called. Call before browser_goto for best results.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply a realistic browser fingerprint to reduce automation detection.
|
|
25
|
+
* Injects anti-fingerprinting scripts before page JS executes.
|
|
26
|
+
*/
|
|
27
|
+
export async function applyRealisticProfile(protocol) {
|
|
28
|
+
const { Page, Network } = protocol;
|
|
29
|
+
|
|
30
|
+
// Inject fingerprint-normalizing script for ALL new documents
|
|
31
|
+
await Page.addScriptToEvaluateOnNewDocument({
|
|
32
|
+
source: `
|
|
33
|
+
// Normalize webdriver flag (standard automation test practice)
|
|
34
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
|
35
|
+
|
|
36
|
+
// Set realistic plugin list
|
|
37
|
+
Object.defineProperty(navigator, 'plugins', {
|
|
38
|
+
get: () => [
|
|
39
|
+
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
|
40
|
+
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
|
41
|
+
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
|
|
42
|
+
],
|
|
43
|
+
configurable: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Realistic language preferences
|
|
47
|
+
Object.defineProperty(navigator, 'languages', {
|
|
48
|
+
get: () => ['en-US', 'en', 'es'],
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Remove automation-specific chrome.runtime
|
|
53
|
+
if (window.chrome) {
|
|
54
|
+
if (window.chrome.runtime) {
|
|
55
|
+
delete window.chrome.runtime;
|
|
56
|
+
}
|
|
57
|
+
if (!window.chrome.loadTimes) {
|
|
58
|
+
window.chrome.loadTimes = function() { return {}; };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Normalize permissions query
|
|
63
|
+
if (navigator.permissions && navigator.permissions.query) {
|
|
64
|
+
const origQuery = navigator.permissions.query;
|
|
65
|
+
navigator.permissions.query = function(params) {
|
|
66
|
+
if (params && params.name === 'notifications') {
|
|
67
|
+
return Promise.resolve({ state: 'prompt', onchange: null });
|
|
68
|
+
}
|
|
69
|
+
return origQuery.call(this, params);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Realistic connection type
|
|
74
|
+
if (navigator.connection) {
|
|
75
|
+
Object.defineProperty(navigator.connection, 'rtt', { get: () => 100 });
|
|
76
|
+
}
|
|
77
|
+
`,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Set a standard desktop user-agent
|
|
81
|
+
try {
|
|
82
|
+
await Network.setUserAgentOverride({
|
|
83
|
+
userAgent:
|
|
84
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
85
|
+
});
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
status: "profile applied",
|
|
90
|
+
note: "Only affects pages loaded after this call. Navigate to a new page for the profile to take effect.",
|
|
91
|
+
techniques: [
|
|
92
|
+
"navigator.webdriver → false",
|
|
93
|
+
"navigator.plugins — realistic list",
|
|
94
|
+
"navigator.languages — configured",
|
|
95
|
+
"chrome.runtime — normalized",
|
|
96
|
+
"Permissions query — overridden",
|
|
97
|
+
"User-Agent — standard desktop",
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
package/lib/helpers.mjs
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bwb-browser — Shared interaction helpers
|
|
3
|
+
*
|
|
4
|
+
* Functions for navigating, clicking, filling, and waiting for page elements
|
|
5
|
+
* using raw CDP protocol.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ─── Navigation Helper ────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export async function gotoUrl(page, runtime, url, timeoutMs) {
|
|
11
|
+
await page.enable();
|
|
12
|
+
|
|
13
|
+
const loadPromise = page.loadEventFired().then(() => true);
|
|
14
|
+
const domPromise = page.domContentEventFired().then(() => true);
|
|
15
|
+
|
|
16
|
+
await page.navigate({ url });
|
|
17
|
+
|
|
18
|
+
await Promise.race([
|
|
19
|
+
Promise.all([loadPromise, domPromise]),
|
|
20
|
+
new Promise(r => setTimeout(() => r(false), timeoutMs)),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
// Small grace for JS framework rendering
|
|
24
|
+
await new Promise(r => setTimeout(r, 500));
|
|
25
|
+
|
|
26
|
+
const { result } = await runtime.evaluate({ expression: "document.title" });
|
|
27
|
+
return { title: result?.value || "", url };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Click Helper (uses CDP Input.dispatchMouseEvent) ─────────────────────────
|
|
31
|
+
|
|
32
|
+
export async function clickElement(page, runtime, input, selector) {
|
|
33
|
+
const { result } = await runtime.evaluate({
|
|
34
|
+
expression: `(() => {
|
|
35
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
36
|
+
if (!el) return JSON.stringify({ error: 'NOT_FOUND' });
|
|
37
|
+
const rect = el.getBoundingClientRect();
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
x: rect.x + rect.width / 2,
|
|
40
|
+
y: rect.y + rect.height / 2,
|
|
41
|
+
width: rect.width,
|
|
42
|
+
height: rect.height,
|
|
43
|
+
tag: el.tagName,
|
|
44
|
+
text: (el.textContent || '').trim().slice(0, 50),
|
|
45
|
+
});
|
|
46
|
+
})()`,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let info;
|
|
50
|
+
try { info = JSON.parse(result.value); } catch {
|
|
51
|
+
throw new Error(`Element not found: ${selector}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (info.error === "NOT_FOUND") {
|
|
55
|
+
throw new Error(`Element not found: ${selector}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Dispatch real mouse events via CDP Input domain (ONLY — no native JS click)
|
|
59
|
+
const x = Math.round(info.x);
|
|
60
|
+
const y = Math.round(info.y);
|
|
61
|
+
await input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
|
|
62
|
+
await input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
|
|
63
|
+
|
|
64
|
+
return info;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Fill Helper (uses CDP Input.insertText) ──────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export async function fillElement(page, runtime, input, selector, text) {
|
|
70
|
+
const { result } = await runtime.evaluate({
|
|
71
|
+
expression: `(() => {
|
|
72
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
73
|
+
if (!el) return 'NOT_FOUND';
|
|
74
|
+
el.focus();
|
|
75
|
+
el.value = '';
|
|
76
|
+
return 'FOCUSED';
|
|
77
|
+
})()`,
|
|
78
|
+
returnByValue: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (result.value === "NOT_FOUND") {
|
|
82
|
+
throw new Error(`Element not found: ${selector}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Clear existing text via CDP Input domain
|
|
86
|
+
await input.dispatchKeyEvent({ type: "keyDown", key: "Control" });
|
|
87
|
+
await input.dispatchKeyEvent({ type: "keyDown", key: "a" });
|
|
88
|
+
await input.dispatchKeyEvent({ type: "keyUp", key: "a" });
|
|
89
|
+
await input.dispatchKeyEvent({ type: "keyUp", key: "Control" });
|
|
90
|
+
await input.dispatchKeyEvent({ type: "keyDown", key: "Delete" });
|
|
91
|
+
await input.dispatchKeyEvent({ type: "keyUp", key: "Delete" });
|
|
92
|
+
|
|
93
|
+
// Insert text via CDP Input domain
|
|
94
|
+
await input.insertText({ text });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── waitForSelector Helper ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export async function waitForSelector(runtime, selector, opts = {}) {
|
|
100
|
+
const timeout = opts.timeout || 10000;
|
|
101
|
+
const disappear = opts.disappear || false;
|
|
102
|
+
const start = Date.now();
|
|
103
|
+
|
|
104
|
+
while (Date.now() - start < timeout) {
|
|
105
|
+
const { result } = await runtime.evaluate({
|
|
106
|
+
expression: `(() => {
|
|
107
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
108
|
+
if (!el) return JSON.stringify({ status: "NOT_FOUND" });
|
|
109
|
+
const rect = el.getBoundingClientRect();
|
|
110
|
+
const hidden = rect.width === 0 || rect.height === 0;
|
|
111
|
+
const text = (el.textContent || "").trim().slice(0, 200);
|
|
112
|
+
return JSON.stringify({ status: "FOUND", tag: el.tagName, text, hidden });
|
|
113
|
+
})()`,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const info = JSON.parse(result?.value || "{}");
|
|
117
|
+
|
|
118
|
+
if (disappear && info.status === "NOT_FOUND") return true;
|
|
119
|
+
if (!disappear && info.status === "FOUND" && !info.hidden) return true;
|
|
120
|
+
if (!disappear && info.status === "FOUND" && !opts.visible) return true;
|
|
121
|
+
|
|
122
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new Error(`browser_waitForSelector: "${selector}" not ${disappear ? "disappeared" : "found"} within ${timeout}ms`);
|
|
126
|
+
}
|
package/lib/session.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bwb-browser — Persistent Browser Sessions
|
|
3
|
+
*
|
|
4
|
+
* Save and load browser cookies to/from disk, enabling authenticated sessions
|
|
5
|
+
* to persist across agent restarts. "Login once, agent works for days."
|
|
6
|
+
*
|
|
7
|
+
* Sessions are stored as JSON files in ~/.bwb/sessions/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
|
|
14
|
+
const SESSION_DIR = join(homedir(), ".bwb", "sessions");
|
|
15
|
+
|
|
16
|
+
function ensureDir() {
|
|
17
|
+
mkdirSync(SESSION_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Save all cookies from the current browser session to a named session file.
|
|
22
|
+
*/
|
|
23
|
+
export async function saveSession(name, protocol) {
|
|
24
|
+
if (!name || typeof name !== "string") {
|
|
25
|
+
throw new Error("Session name is required");
|
|
26
|
+
}
|
|
27
|
+
// Sanitize name to avoid path traversal
|
|
28
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
29
|
+
ensureDir();
|
|
30
|
+
|
|
31
|
+
const { cookies } = await protocol.Network.getAllCookies();
|
|
32
|
+
const path = join(SESSION_DIR, `${safeName}.json`);
|
|
33
|
+
writeFileSync(path, JSON.stringify({
|
|
34
|
+
name: safeName,
|
|
35
|
+
cookieCount: cookies.length,
|
|
36
|
+
savedAt: Date.now(),
|
|
37
|
+
cookies,
|
|
38
|
+
}, null, 2));
|
|
39
|
+
|
|
40
|
+
return { savedTo: path, cookieCount: cookies.length, name: safeName };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load cookies from a named session file into the browser.
|
|
45
|
+
*/
|
|
46
|
+
export async function loadSession(name, protocol) {
|
|
47
|
+
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
48
|
+
const path = join(SESSION_DIR, `${safeName}.json`);
|
|
49
|
+
|
|
50
|
+
if (!existsSync(path)) {
|
|
51
|
+
throw new Error(`Session not found: "${name}". Available sessions: ${listSessions().map(s => s.name).join(", ") || "none"}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const data = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
|
|
56
|
+
if (!data.cookies || !Array.isArray(data.cookies)) {
|
|
57
|
+
throw new Error(`Invalid session file: ${path}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Set all cookies in the browser
|
|
61
|
+
await protocol.Network.setCookies({ cookies: data.cookies });
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
loaded: safeName,
|
|
65
|
+
cookieCount: data.cookies.length,
|
|
66
|
+
savedAt: new Date(data.savedAt).toISOString(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* List all saved sessions.
|
|
72
|
+
*/
|
|
73
|
+
export function listSessions() {
|
|
74
|
+
ensureDir();
|
|
75
|
+
const files = readdirSync(SESSION_DIR).filter(f => f.endsWith(".json"));
|
|
76
|
+
return files.map(f => {
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(readFileSync(join(SESSION_DIR, f), "utf8"));
|
|
79
|
+
return {
|
|
80
|
+
name: data.name || f.replace(".json", ""),
|
|
81
|
+
cookieCount: data.cookies?.length || 0,
|
|
82
|
+
savedAt: data.savedAt ? new Date(data.savedAt).toISOString() : "unknown",
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return { name: f.replace(".json", ""), cookieCount: 0, savedAt: "unknown" };
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
package/lib/tabs.mjs
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bwb-browser — Multi-tab Management
|
|
3
|
+
*
|
|
4
|
+
* Manages multiple page targets (tabs) within a single Chrome browser instance.
|
|
5
|
+
* Uses CDP's target system to create, close, switch, and list tabs.
|
|
6
|
+
* Each tab gets its own CDP protocol connection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import CDP from "chrome-remote-interface";
|
|
10
|
+
import { ensureBrowser, protocol, actualCdpPort, cfg } from "./browser.mjs";
|
|
11
|
+
|
|
12
|
+
// ─── Tab State ───────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** @type {Map<string, {protocol: import('chrome-remote-interface').Protocol|null, title: string, url: string}>} */
|
|
15
|
+
const tabs = new Map();
|
|
16
|
+
let activeTabId = null;
|
|
17
|
+
|
|
18
|
+
// ─── Ensure Default Tab Registered ──────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
async function ensureDefaultTab() {
|
|
21
|
+
if (tabs.size > 0) return;
|
|
22
|
+
|
|
23
|
+
// Browser must be started first
|
|
24
|
+
await ensureBrowser();
|
|
25
|
+
if (!protocol) throw new Error("Browser not started");
|
|
26
|
+
|
|
27
|
+
const port = actualCdpPort || cfg.port;
|
|
28
|
+
const targets = await CDP.List({ port });
|
|
29
|
+
const page = targets.find(t => t.type === "page");
|
|
30
|
+
if (page) {
|
|
31
|
+
tabs.set(page.id, { protocol, title: page.title, url: page.url });
|
|
32
|
+
activeTabId = page.id;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── getActiveProtocol ───────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the CDP protocol for the active tab.
|
|
40
|
+
* If no tabs are managed, ensures browser is started and returns default protocol.
|
|
41
|
+
* All tool handlers should use this instead of ensureBrowser() directly.
|
|
42
|
+
*/
|
|
43
|
+
export async function getActiveProtocol() {
|
|
44
|
+
if (tabs.size === 0) {
|
|
45
|
+
await ensureDefaultTab();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (activeTabId && tabs.has(activeTabId)) {
|
|
49
|
+
const tab = tabs.get(activeTabId);
|
|
50
|
+
if (tab.protocol) return tab.protocol;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fallback to default protocol
|
|
54
|
+
return protocol;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Create Tab ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a new browser tab and navigates to url (or about:blank).
|
|
61
|
+
* Switches to the new tab automatically.
|
|
62
|
+
*/
|
|
63
|
+
export async function createTab(url) {
|
|
64
|
+
await ensureDefaultTab();
|
|
65
|
+
|
|
66
|
+
const port = actualCdpPort || cfg.port;
|
|
67
|
+
const info = await CDP.New({ port, url: url || "about:blank" });
|
|
68
|
+
|
|
69
|
+
// Create a CDP connection for this specific target
|
|
70
|
+
const newProtocol = await CDP({ target: info.id, port });
|
|
71
|
+
tabs.set(info.id, { protocol: newProtocol, title: info.title || "", url: info.url || url || "" });
|
|
72
|
+
activeTabId = info.id;
|
|
73
|
+
|
|
74
|
+
return { id: info.id, title: info.title || "", url: info.url || "" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Close Tab ───────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Closes a tab by targetId. If no targetId provided, closes the active tab.
|
|
81
|
+
* Cannot close the last remaining tab.
|
|
82
|
+
*/
|
|
83
|
+
export async function closeTab(targetId) {
|
|
84
|
+
await ensureDefaultTab();
|
|
85
|
+
|
|
86
|
+
const id = targetId || activeTabId;
|
|
87
|
+
if (!id) throw new Error("No tab to close");
|
|
88
|
+
|
|
89
|
+
if (tabs.size <= 1) {
|
|
90
|
+
throw new Error("Cannot close the only remaining tab. Use browser_restart instead.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const port = actualCdpPort || cfg.port;
|
|
94
|
+
await CDP.Close({ id, port });
|
|
95
|
+
|
|
96
|
+
const tab = tabs.get(id);
|
|
97
|
+
if (tab && tab.protocol && tab.protocol !== protocol) {
|
|
98
|
+
try { await tab.protocol.close(); } catch {}
|
|
99
|
+
}
|
|
100
|
+
tabs.delete(id);
|
|
101
|
+
|
|
102
|
+
// Switch to another tab
|
|
103
|
+
if (activeTabId === id) {
|
|
104
|
+
activeTabId = tabs.keys().next().value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { closed: id, activeTab: activeTabId };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Switch Tab ──────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Switches to a different tab by targetId.
|
|
114
|
+
*/
|
|
115
|
+
export function switchTab(targetId) {
|
|
116
|
+
if (!targetId) throw new Error("No targetId provided");
|
|
117
|
+
if (!tabs.has(targetId)) {
|
|
118
|
+
throw new Error(`Tab not found: ${targetId}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
activeTabId = targetId;
|
|
122
|
+
const tab = tabs.get(targetId);
|
|
123
|
+
return { id: targetId, title: tab.title, url: tab.url };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── List Tabs ───────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Lists all open tabs with their IDs, titles, URLs, and active status.
|
|
130
|
+
*/
|
|
131
|
+
export function listTabs() {
|
|
132
|
+
const result = [];
|
|
133
|
+
for (const [id, tab] of tabs) {
|
|
134
|
+
result.push({
|
|
135
|
+
id,
|
|
136
|
+
title: tab.title,
|
|
137
|
+
url: tab.url,
|
|
138
|
+
active: id === activeTabId,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Clear Tabs (for restart) ───────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Clears all tab state. Called during browser restart to prevent stale connections.
|
|
148
|
+
*/
|
|
149
|
+
export function clearTabs() {
|
|
150
|
+
tabs.clear();
|
|
151
|
+
activeTabId = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Tab Sync (update title/url after navigation) ───────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Call after navigation to keep tab metadata current.
|
|
158
|
+
*/
|
|
159
|
+
export function syncActiveTab(title, url) {
|
|
160
|
+
if (activeTabId && tabs.has(activeTabId)) {
|
|
161
|
+
const tab = tabs.get(activeTabId);
|
|
162
|
+
if (title) tab.title = title;
|
|
163
|
+
if (url) tab.url = url;
|
|
164
|
+
}
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bwb-browser",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Browser Without Bloat —
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Browser Without Bloat — 76KB MCP server that gives AI agents browser superpowers. Natural language interaction, live event watching, persistent sessions, multi-tab. Runs on Termux/Android, desktop, and CI. Zero heavy dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"bwb": "bin/bwb"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"server.mjs",
|
|
10
|
+
"lib/",
|
|
10
11
|
"bin/bwb",
|
|
11
12
|
"AGENTS.md",
|
|
12
13
|
"LICENSE",
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
16
17
|
"start": "node server.mjs",
|
|
17
|
-
"test": "node --check server.mjs"
|
|
18
|
+
"test": "node --check server.mjs && node --check lib/*.mjs"
|
|
18
19
|
},
|
|
19
20
|
"keywords": [
|
|
20
21
|
"mcp",
|
|
@@ -26,7 +27,24 @@
|
|
|
26
27
|
"headless",
|
|
27
28
|
"bwb",
|
|
28
29
|
"browser-without-bloat",
|
|
29
|
-
"ai-agent"
|
|
30
|
+
"ai-agent",
|
|
31
|
+
"termux",
|
|
32
|
+
"android",
|
|
33
|
+
"natural-language",
|
|
34
|
+
"llm",
|
|
35
|
+
"ai-tools",
|
|
36
|
+
"claude",
|
|
37
|
+
"opencode",
|
|
38
|
+
"agent",
|
|
39
|
+
"web-scraping",
|
|
40
|
+
"browser-testing",
|
|
41
|
+
"e2e-testing",
|
|
42
|
+
"fingerprint",
|
|
43
|
+
"session",
|
|
44
|
+
"lightweight",
|
|
45
|
+
"zero-dependencies",
|
|
46
|
+
"mobile",
|
|
47
|
+
"devtools"
|
|
30
48
|
],
|
|
31
49
|
"author": "Krish Tiwari (@krshforever)",
|
|
32
50
|
"license": "MIT",
|