wb-browser-runtime 0.6.0 → 0.7.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/README.md +58 -5
- package/bin/wb-browser-runtime.js +147 -993
- package/lib/http.js +63 -0
- package/lib/io.js +56 -0
- package/lib/providers/browser-use.js +133 -0
- package/lib/providers/browserbase.js +120 -0
- package/lib/providers/index.js +43 -0
- package/lib/recording-manager.js +620 -0
- package/lib/session-manager.js +101 -0
- package/lib/stub-page.js +112 -0
- package/lib/util.js +33 -0
- package/package.json +8 -3
- package/verbs/assert.js +23 -0
- package/verbs/click.js +8 -0
- package/verbs/eval.js +20 -0
- package/verbs/extract.js +38 -0
- package/verbs/fill.js +13 -0
- package/verbs/goto.js +10 -0
- package/verbs/index.js +70 -0
- package/verbs/press.js +9 -0
- package/verbs/save.js +55 -0
- package/verbs/screenshot.js +48 -0
- package/verbs/wait_for.js +13 -0
package/lib/http.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { log } from "./io.js";
|
|
2
|
+
|
|
3
|
+
export async function safeText(res) {
|
|
4
|
+
try {
|
|
5
|
+
return (await res.text()).slice(0, 200);
|
|
6
|
+
} catch {
|
|
7
|
+
return "<unreadable>";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Retry transient network + 5xx/429 failures with short exponential backoff.
|
|
12
|
+
// Each attempt gets its own AbortController + timeout; caller-passed signals
|
|
13
|
+
// are not plumbed through since we don't have a cancellation story above this
|
|
14
|
+
// layer. Non-retryable statuses (4xx except 429) are returned immediately for
|
|
15
|
+
// the caller to handle.
|
|
16
|
+
//
|
|
17
|
+
// `bodyFactory`, when set, is invoked per attempt to produce a fresh body —
|
|
18
|
+
// required for streaming uploads where the previous attempt consumed the
|
|
19
|
+
// stream. Takes precedence over opts.body.
|
|
20
|
+
export async function retryableFetch(
|
|
21
|
+
url,
|
|
22
|
+
opts = {},
|
|
23
|
+
label,
|
|
24
|
+
{ timeoutMs = 30_000, bodyFactory = null } = {},
|
|
25
|
+
) {
|
|
26
|
+
const delays = [100, 500];
|
|
27
|
+
let lastErr = null;
|
|
28
|
+
let lastRes = null;
|
|
29
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
30
|
+
if (attempt > 0) {
|
|
31
|
+
await new Promise((r) => setTimeout(r, delays[attempt - 1]));
|
|
32
|
+
const prev = lastRes
|
|
33
|
+
? `status=${lastRes.status}`
|
|
34
|
+
: `err=${lastErr?.message || lastErr}`;
|
|
35
|
+
log(`[retry] ${label} attempt ${attempt + 1}/3 (${prev})`);
|
|
36
|
+
}
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
|
+
try {
|
|
40
|
+
const fetchOpts = { ...opts, signal: controller.signal };
|
|
41
|
+
if (bodyFactory) {
|
|
42
|
+
fetchOpts.body = bodyFactory();
|
|
43
|
+
// undici requires duplex: "half" for streaming (non-Buffer, non-string)
|
|
44
|
+
// request bodies. Omitting it throws at request time.
|
|
45
|
+
fetchOpts.duplex = "half";
|
|
46
|
+
}
|
|
47
|
+
const res = await fetch(url, fetchOpts);
|
|
48
|
+
if (res.ok) return res;
|
|
49
|
+
if (res.status === 429 || (res.status >= 500 && res.status < 600)) {
|
|
50
|
+
lastRes = res;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
return res;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
lastErr = e;
|
|
56
|
+
continue;
|
|
57
|
+
} finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (lastRes) return lastRes;
|
|
62
|
+
throw lastErr;
|
|
63
|
+
}
|
package/lib/io.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Line-framed JSON protocol I/O. `send` writes a single frame to stdout;
|
|
2
|
+
// `log*` helpers write diagnostic output to stderr, filtered by
|
|
3
|
+
// `WB_LOG_LEVEL` (trace|debug|info|warn|error, default info).
|
|
4
|
+
//
|
|
5
|
+
// `log()` (unqualified) is info-level for back-compat — existing call
|
|
6
|
+
// sites don't need to be reclassified. New verbose output should use
|
|
7
|
+
// `logDebug` / `logTrace` so it can be silenced by default. Warn/error
|
|
8
|
+
// helpers exist so a single grep finds all the paths that will always
|
|
9
|
+
// surface.
|
|
10
|
+
|
|
11
|
+
const LOG_LEVELS = { trace: 0, debug: 1, info: 2, warn: 3, error: 4 };
|
|
12
|
+
|
|
13
|
+
function resolveLevel() {
|
|
14
|
+
const raw = (process.env.WB_LOG_LEVEL || "info").trim().toLowerCase();
|
|
15
|
+
const level = LOG_LEVELS[raw];
|
|
16
|
+
if (level === undefined) {
|
|
17
|
+
process.stderr.write(
|
|
18
|
+
`[warn] WB_LOG_LEVEL=${raw} is not valid (trace|debug|info|warn|error); defaulting to info\n`,
|
|
19
|
+
);
|
|
20
|
+
return LOG_LEVELS.info;
|
|
21
|
+
}
|
|
22
|
+
return level;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolved once at module load — sidecar boots, runs, exits. If we ever
|
|
26
|
+
// need live reconfiguration, swap this for a getter that re-reads env.
|
|
27
|
+
const CURRENT_LEVEL = resolveLevel();
|
|
28
|
+
|
|
29
|
+
export function send(obj) {
|
|
30
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function emit(level, args) {
|
|
34
|
+
if (LOG_LEVELS[level] < CURRENT_LEVEL) return;
|
|
35
|
+
process.stderr.write(args.join(" ") + "\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function logTrace(...args) {
|
|
39
|
+
emit("trace", args);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function logDebug(...args) {
|
|
43
|
+
emit("debug", args);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function log(...args) {
|
|
47
|
+
emit("info", args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function logWarn(...args) {
|
|
51
|
+
emit("warn", args);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function logError(...args) {
|
|
55
|
+
emit("error", args);
|
|
56
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// browser-use cloud provider.
|
|
2
|
+
//
|
|
3
|
+
// Two differences vs. Browserbase worth knowing:
|
|
4
|
+
// 1. `POST /api/v3/browsers` returns `cdpUrl` AND `liveUrl` in one call —
|
|
5
|
+
// no separate debug fetch. We stash liveUrl on the allocated handle so
|
|
6
|
+
// getLiveUrl() is a sync property read, and the timing buckets in
|
|
7
|
+
// slice.session_started stay shaped the same way (just with ~0ms
|
|
8
|
+
// connect-phase fetch on this vendor).
|
|
9
|
+
// 2. Stealth + proxies are on by default (proxyCountryCode defaults to
|
|
10
|
+
// "us"); no Scale-plan gate. Set BROWSER_USE_PROXY_COUNTRY=null to
|
|
11
|
+
// disable the proxy, or to e.g. "gb" to route elsewhere.
|
|
12
|
+
//
|
|
13
|
+
// Release uses PATCH with action:"stop" (the documented update-session
|
|
14
|
+
// endpoint). "Unused time automatically refunded if the session ran less
|
|
15
|
+
// than 1 hour" per their docs, so early teardown on sidecar shutdown is
|
|
16
|
+
// real savings, not just quota cleanup.
|
|
17
|
+
//
|
|
18
|
+
// Not yet wired: customProxy, browserScreenWidth/Height, allowResizing,
|
|
19
|
+
// enableRecording (we have our own rrweb + screencast pipeline; enabling
|
|
20
|
+
// theirs would double-record).
|
|
21
|
+
|
|
22
|
+
import { retryableFetch, safeText } from "../http.js";
|
|
23
|
+
import { log } from "../io.js";
|
|
24
|
+
|
|
25
|
+
const BU_BASE = "https://api.browser-use.com/api/v3";
|
|
26
|
+
|
|
27
|
+
export function createBrowserUseProvider() {
|
|
28
|
+
return {
|
|
29
|
+
name: "browser-use",
|
|
30
|
+
|
|
31
|
+
async allocate({ profile, sessionName: _sessionName } = {}) {
|
|
32
|
+
const apiKey = process.env.BROWSER_USE_API_KEY;
|
|
33
|
+
if (!apiKey) {
|
|
34
|
+
throw new Error("BROWSER_USE_API_KEY must be set");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Profile id is an opaque UUID from the human-driven `profile.sh`
|
|
38
|
+
// bootstrap, baked into the runbook frontmatter by whatever generates
|
|
39
|
+
// it (UI editor, codegen, hand-authored). The slice envelope carries
|
|
40
|
+
// it through; this provider just forwards.
|
|
41
|
+
const profileId = profile ?? null;
|
|
42
|
+
|
|
43
|
+
const body = {};
|
|
44
|
+
if (profileId) body.profileId = profileId;
|
|
45
|
+
|
|
46
|
+
// Pass-through knobs. Only include when the operator set them — server
|
|
47
|
+
// defaults (proxy=us, timeout=60min) are better behaved than anything
|
|
48
|
+
// we'd invent here.
|
|
49
|
+
const proxyCountry = process.env.BROWSER_USE_PROXY_COUNTRY;
|
|
50
|
+
if (proxyCountry !== undefined) {
|
|
51
|
+
// Explicit "null" string disables the proxy entirely.
|
|
52
|
+
body.proxyCountryCode =
|
|
53
|
+
proxyCountry.toLowerCase() === "null" ? null : proxyCountry;
|
|
54
|
+
}
|
|
55
|
+
const timeoutMin = Number.parseInt(
|
|
56
|
+
process.env.BROWSER_USE_TIMEOUT_MIN || "",
|
|
57
|
+
10,
|
|
58
|
+
);
|
|
59
|
+
if (Number.isFinite(timeoutMin) && timeoutMin > 0) {
|
|
60
|
+
body.timeout = timeoutMin;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
log(
|
|
64
|
+
`[bu] session create profile=${profileId ?? "<none>"} proxy=${body.proxyCountryCode ?? "<default>"} timeout=${body.timeout ?? "<default>"}m`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const res = await retryableFetch(
|
|
68
|
+
`${BU_BASE}/browsers`,
|
|
69
|
+
{
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
"X-Browser-Use-API-Key": apiKey,
|
|
73
|
+
"Content-Type": "application/json",
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
},
|
|
77
|
+
"bu.create",
|
|
78
|
+
);
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`browser-use create failed (${res.status}): ${await safeText(res)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const created = await res.json();
|
|
85
|
+
if (!created.cdpUrl) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`browser-use create returned no cdpUrl (status=${created.status ?? "?"}); session unusable`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
sid: created.id,
|
|
92
|
+
cdpUrl: created.cdpUrl,
|
|
93
|
+
// Stashed so getLiveUrl() below is a property read, not a round-trip.
|
|
94
|
+
_liveUrl: created.liveUrl ?? null,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async getLiveUrl(allocated) {
|
|
99
|
+
return allocated._liveUrl;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async release(sid) {
|
|
103
|
+
const apiKey = process.env.BROWSER_USE_API_KEY;
|
|
104
|
+
try {
|
|
105
|
+
const res = await retryableFetch(
|
|
106
|
+
`${BU_BASE}/browsers/${sid}`,
|
|
107
|
+
{
|
|
108
|
+
method: "PATCH",
|
|
109
|
+
headers: {
|
|
110
|
+
"X-Browser-Use-API-Key": apiKey,
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ action: "stop" }),
|
|
114
|
+
},
|
|
115
|
+
"bu.release",
|
|
116
|
+
);
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
// retryableFetch returns 4xx silently (only 5xx/429 are retried).
|
|
119
|
+
// Surface non-2xx loudly — a 400/404 here means the session is
|
|
120
|
+
// still alive and quota is still burning, which is the bug we
|
|
121
|
+
// were silently masking before.
|
|
122
|
+
log(
|
|
123
|
+
`[bu] release session ${sid} returned HTTP ${res.status}: ${await safeText(res)}`,
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
log(`[bu] released session ${sid}`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
log(`[bu] release session ${sid} failed: ${e.message}`);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Browserbase provider. Three REST calls wrapped behind the provider
|
|
2
|
+
// interface (allocate / getLiveUrl / release). Extracted from the sidecar
|
|
3
|
+
// entry point so browser-use (and future vendors) can slot in via the same
|
|
4
|
+
// shape without the verb/recording pipeline caring which chromium it's
|
|
5
|
+
// driving.
|
|
6
|
+
|
|
7
|
+
import { retryableFetch, safeText } from "../http.js";
|
|
8
|
+
import { log } from "../io.js";
|
|
9
|
+
|
|
10
|
+
const BB_BASE = "https://api.browserbase.com";
|
|
11
|
+
|
|
12
|
+
function envBool(v) {
|
|
13
|
+
return v === "1" || (typeof v === "string" && v.toLowerCase() === "true");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createBrowserbaseProvider() {
|
|
17
|
+
return {
|
|
18
|
+
name: "browserbase",
|
|
19
|
+
|
|
20
|
+
async allocate({ profile, sessionName: _sessionName } = {}) {
|
|
21
|
+
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|
22
|
+
const projectId = process.env.BROWSERBASE_PROJECT_ID;
|
|
23
|
+
if (!apiKey || !projectId) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID must be set",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (profile) {
|
|
30
|
+
// Browserbase has "contexts" as its cross-session persistence concept
|
|
31
|
+
// but wb doesn't thread them yet. Log so operators see the `profile:`
|
|
32
|
+
// field arrived but had no effect — easier to debug than silent drop.
|
|
33
|
+
log(
|
|
34
|
+
`[bb] profile="${profile}" ignored — browserbase vendor has no profile binding yet`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// advancedStealth is Scale-plan-gated on Browserbase's side; proxies
|
|
39
|
+
// adds residential-IP cost. Default off so a misconfigured plan doesn't
|
|
40
|
+
// break unrelated runs; flip per vendor when the target sits behind
|
|
41
|
+
// Cloudflare / similar bot detection.
|
|
42
|
+
const advancedStealth = envBool(process.env.BROWSERBASE_ADVANCED_STEALTH);
|
|
43
|
+
const proxies = envBool(process.env.BROWSERBASE_PROXIES);
|
|
44
|
+
|
|
45
|
+
// keepAlive:false — slice lifetime is tied to wb process; on shutdown
|
|
46
|
+
// we explicitly REQUEST_RELEASE so quota isn't burned by orphans.
|
|
47
|
+
const body = { projectId, keepAlive: false };
|
|
48
|
+
if (advancedStealth) body.browserSettings = { advancedStealth: true };
|
|
49
|
+
if (proxies) body.proxies = true;
|
|
50
|
+
|
|
51
|
+
log(
|
|
52
|
+
`[bb] session create advancedStealth=${advancedStealth} proxies=${proxies}`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const res = await retryableFetch(
|
|
56
|
+
`${BB_BASE}/v1/sessions`,
|
|
57
|
+
{
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
"X-BB-API-Key": apiKey,
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
},
|
|
65
|
+
"bb.create",
|
|
66
|
+
);
|
|
67
|
+
if (!res.ok) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Browserbase create failed (${res.status}): ${await safeText(res)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
const created = await res.json();
|
|
73
|
+
return { sid: created.id, cdpUrl: created.connectUrl };
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async getLiveUrl(allocated) {
|
|
77
|
+
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|
78
|
+
const res = await retryableFetch(
|
|
79
|
+
`${BB_BASE}/v1/sessions/${allocated.sid}/debug`,
|
|
80
|
+
{ headers: { "X-BB-API-Key": apiKey } },
|
|
81
|
+
"bb.debug",
|
|
82
|
+
);
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Browserbase debug fetch failed (${res.status}): ${await safeText(res)}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const body = await res.json();
|
|
89
|
+
return body.debuggerFullscreenUrl;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async release(sid) {
|
|
93
|
+
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|
94
|
+
const projectId = process.env.BROWSERBASE_PROJECT_ID;
|
|
95
|
+
try {
|
|
96
|
+
const res = await retryableFetch(
|
|
97
|
+
`${BB_BASE}/v1/sessions/${sid}`,
|
|
98
|
+
{
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: {
|
|
101
|
+
"X-BB-API-Key": apiKey,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ projectId, status: "REQUEST_RELEASE" }),
|
|
105
|
+
},
|
|
106
|
+
"bb.release",
|
|
107
|
+
);
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
log(
|
|
110
|
+
`[bb] release session ${sid} returned HTTP ${res.status}: ${await safeText(res)}`,
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
log(`[bb] released session ${sid}`);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
log(`[bb] release session ${sid} failed: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Browser-session provider boundary.
|
|
2
|
+
//
|
|
3
|
+
// All vendor-specific work (session allocation, live-preview URL, release)
|
|
4
|
+
// lives behind a provider. Everything else in the sidecar — verbs, recording,
|
|
5
|
+
// session cache, substitutions — runs against a Playwright Page and doesn't
|
|
6
|
+
// care which vendor handed us the CDP endpoint.
|
|
7
|
+
//
|
|
8
|
+
// Provider interface:
|
|
9
|
+
// {
|
|
10
|
+
// name: string,
|
|
11
|
+
// async allocate({ profile, sessionName }) -> { sid, cdpUrl, ...opaque },
|
|
12
|
+
// async getLiveUrl(allocated) -> string,
|
|
13
|
+
// async release(sid) -> void,
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// Two-phase allocate/getLiveUrl split exists so vendors that return the live
|
|
17
|
+
// URL in the same call as session create (browser-use) can just stash it on
|
|
18
|
+
// the `allocated` object and return it synchronously from getLiveUrl —
|
|
19
|
+
// while vendors that require a second round-trip (browserbase) preserve
|
|
20
|
+
// today's timing buckets (allocate_ms vs. connect_ms in slice.session_started).
|
|
21
|
+
//
|
|
22
|
+
// Vendor selection is a single env var, resolved once at sidecar boot:
|
|
23
|
+
// WB_BROWSER_VENDOR=browserbase (default)
|
|
24
|
+
// WB_BROWSER_VENDOR=browser-use (future)
|
|
25
|
+
|
|
26
|
+
import { createBrowserbaseProvider } from "./browserbase.js";
|
|
27
|
+
import { createBrowserUseProvider } from "./browser-use.js";
|
|
28
|
+
|
|
29
|
+
export function getProvider() {
|
|
30
|
+
const raw = (process.env.WB_BROWSER_VENDOR || "browserbase")
|
|
31
|
+
.trim()
|
|
32
|
+
.toLowerCase();
|
|
33
|
+
switch (raw) {
|
|
34
|
+
case "browserbase":
|
|
35
|
+
return createBrowserbaseProvider();
|
|
36
|
+
case "browser-use":
|
|
37
|
+
return createBrowserUseProvider();
|
|
38
|
+
default:
|
|
39
|
+
throw new Error(
|
|
40
|
+
`WB_BROWSER_VENDOR="${raw}" is not a known vendor (expected: browserbase | browser-use)`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|