wb-browser-runtime 0.3.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 ADDED
@@ -0,0 +1,111 @@
1
+ # wb-browser-runtime
2
+
3
+ Browser sidecar for `wb` — deterministic Playwright slices over Browserbase.
4
+
5
+ Each `browser` fenced block in a workbook arrives as one `slice` message;
6
+ this sidecar dispatches its `verbs` against a `playwright-core` `Page`
7
+ connected to a Browserbase session via CDP. Sessions are cached by `session:`
8
+ name across slices for the lifetime of the sidecar process so a runbook with
9
+ multiple browser blocks against the same vendor reuses one logged-in browser
10
+ context.
11
+
12
+ ## Install (local dev)
13
+
14
+ ```bash
15
+ cd runtimes/browser
16
+ npm install # installs playwright-core
17
+ npm link # exposes `wb-browser-runtime` on $PATH
18
+ ```
19
+
20
+ Or set `WB_BROWSER_RUNTIME=/absolute/path/to/bin/wb-browser-runtime.js` for a
21
+ specific run.
22
+
23
+ ## Required env
24
+
25
+ - `BROWSERBASE_API_KEY`
26
+ - `BROWSERBASE_PROJECT_ID`
27
+
28
+ Verb arguments support `{{ env.NAME }}` substitution at dispatch time, so any
29
+ secrets your runbook needs (e.g. `HACKERNEWS_PASSWORD`) get pulled from the
30
+ sidecar process env without ever appearing on stdout.
31
+
32
+ ## Usage
33
+
34
+ ```bash
35
+ WB_EXPERIMENTAL_BROWSER=1 wb run examples/browser-demo.md
36
+ ```
37
+
38
+ See `examples/browser-demo.md` for a minimal workbook that exercises the
39
+ protocol against the Playwright-pause demo. For a real Browserbase end-to-end
40
+ example, see the `browserbase-hn-upvoted-probe` runbook in the xatabase repo.
41
+
42
+ ## Verbs
43
+
44
+ | Verb | Bare arg form | Object form fields |
45
+ |--------------|-----------------------------|-------------------------------------------------|
46
+ | `goto` | `goto: <url>` | `url`, `wait_until`, `timeout` |
47
+ | `fill` | — | `selector`, `value`, `timeout` |
48
+ | `click` | `click: <selector>` | `selector`, `timeout` |
49
+ | `press` | `press: <key>` | `key`, `selector`, `timeout` |
50
+ | `wait_for` | `wait_for: <selector>` | `selector`, `state`, `timeout` |
51
+ | `screenshot` | `screenshot: <path>` | `path`, `full_page` |
52
+ | `extract` | — | `selector` (rows), `fields: { name → spec }` |
53
+ | `assert` | `assert: <selector>` | `selector`, `text_contains`, `url_contains` |
54
+ | `eval` | `eval: <js>` | `script` |
55
+
56
+ `extract`'s `fields` entries are either a CSS selector string (returns
57
+ `textContent`), or `{ selector, attr }` to read an attribute.
58
+
59
+ ## Protocol
60
+
61
+ Line-framed JSON, one message per line, on stdin/stdout. `stderr` is treated as
62
+ opaque diagnostics by `wb` and printed dimmed to the user's terminal.
63
+
64
+ ### Handshake (on spawn)
65
+
66
+ ```
67
+ wb → {"type": "hello", "wb_version": "...", "protocol": "wb-sidecar/1"}
68
+ wb ← {"type": "ready", "runtime": "wb-browser-runtime", "version": "...",
69
+ "protocol": "wb-sidecar/1", "supports": ["goto", "click", "fill", ...]}
70
+ ```
71
+
72
+ ### Slice
73
+
74
+ ```
75
+ wb → {"type": "slice", "session": "airbase", "verbs": [...],
76
+ "line_number": 42, "section_index": 3}
77
+ wb ← {"type": "slice.session_started", "session": "airbase", (0..1, first slice per session)
78
+ "session_id": "abc123", "live_url": "https://..."}
79
+ wb ← {"type": "verb.complete", "verb": "click", "summary": "..."} (0..n)
80
+ wb ← {"type": "verb.failed", "verb": "click", "error": "..."} (0..n)
81
+ wb ← {"type": "slice.complete"} OR {"type": "slice.failed", "error": "..."}
82
+ ```
83
+
84
+ ### Lifecycle event passthrough
85
+
86
+ Any `slice.<suffix>` event the sidecar emits (other than the terminal
87
+ `slice.complete` / `slice.failed` / `slice.paused`) is forwarded by `wb` to
88
+ the callback stream as a lifecycle event:
89
+
90
+ - `slice.session_*` → `session.*` (run-scoped, e.g. live URL ready)
91
+ - `slice.<other>` → `step.<other>` (block-scoped, e.g. `slice.network_idle`)
92
+
93
+ The full event payload (minus `type`) is merged into the callback envelope, so
94
+ new fields ship without a `wb` release. See `src/sidecar.rs` for the dispatcher.
95
+
96
+ ### Shutdown
97
+
98
+ ```
99
+ wb → {"type": "shutdown"}
100
+ ```
101
+
102
+ Sidecar exits 0.
103
+
104
+ ## Roadmap
105
+
106
+ - v0.1 — protocol skeleton (echo only)
107
+ - v0.2 — `slice.session_started` event with stub URL
108
+ - v0.3 — Browserbase + playwright-core, real `goto/fill/click/wait_for/extract/assert` (this)
109
+ - v0.4 — `act:` recovery via Stagehand, `slice.recovered` events
110
+ - v0.5 — `wait_for_mfa` / `wait_for_email_otp` emitting `slice.paused` with
111
+ `resume_url`
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ // wb-browser-runtime — Browserbase + Playwright sidecar for `wb`.
3
+ //
4
+ // Speaks wb's line-framed JSON protocol on stdio (see ../README.md). Each
5
+ // `browser` fenced block in a workbook arrives as one `slice` message; this
6
+ // sidecar dispatches its verbs against a Playwright `Page` connected to a
7
+ // Browserbase session via CDP.
8
+ //
9
+ // Sessions are cached by `session:` name across slices for the lifetime of
10
+ // this process, so a runbook with multiple browser blocks against the same
11
+ // vendor reuses one Browserbase session (and one logged-in browser context).
12
+ //
13
+ // Env required for real runs:
14
+ // BROWSERBASE_API_KEY
15
+ // BROWSERBASE_PROJECT_ID
16
+ //
17
+ // Verb args support `{{ env.NAME }}` substitution, expanded recursively
18
+ // against process.env at dispatch time. Credentials passed this way never
19
+ // hit stdout — only the verb name + selector make it into the summary.
20
+
21
+ import readline from "node:readline";
22
+ import { chromium } from "playwright-core";
23
+
24
+ const SUPPORTS = [
25
+ "goto",
26
+ "fill",
27
+ "click",
28
+ "press",
29
+ "wait_for",
30
+ "screenshot",
31
+ "extract",
32
+ "assert",
33
+ "eval",
34
+ ];
35
+
36
+ const BB_BASE = "https://api.browserbase.com";
37
+ const VERSION = "0.3.0";
38
+
39
+ function send(obj) {
40
+ process.stdout.write(JSON.stringify(obj) + "\n");
41
+ }
42
+
43
+ function log(...args) {
44
+ process.stderr.write(args.join(" ") + "\n");
45
+ }
46
+
47
+ // --- Browserbase REST -------------------------------------------------------
48
+
49
+ async function bbCreateSession() {
50
+ const apiKey = process.env.BROWSERBASE_API_KEY;
51
+ const projectId = process.env.BROWSERBASE_PROJECT_ID;
52
+ if (!apiKey || !projectId) {
53
+ throw new Error(
54
+ "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID must be set",
55
+ );
56
+ }
57
+ const res = await fetch(`${BB_BASE}/v1/sessions`, {
58
+ method: "POST",
59
+ headers: {
60
+ "X-BB-API-Key": apiKey,
61
+ "Content-Type": "application/json",
62
+ },
63
+ // keepAlive:false — slice lifetime is tied to wb process; on shutdown
64
+ // we explicitly REQUEST_RELEASE so quota isn't burned by orphans.
65
+ body: JSON.stringify({ projectId, keepAlive: false }),
66
+ });
67
+ if (!res.ok) {
68
+ throw new Error(
69
+ `Browserbase create failed (${res.status}): ${await safeText(res)}`,
70
+ );
71
+ }
72
+ return await res.json();
73
+ }
74
+
75
+ async function bbGetLiveUrl(sessionId) {
76
+ const apiKey = process.env.BROWSERBASE_API_KEY;
77
+ const res = await fetch(`${BB_BASE}/v1/sessions/${sessionId}/debug`, {
78
+ headers: { "X-BB-API-Key": apiKey },
79
+ });
80
+ if (!res.ok) {
81
+ throw new Error(
82
+ `Browserbase debug fetch failed (${res.status}): ${await safeText(res)}`,
83
+ );
84
+ }
85
+ const body = await res.json();
86
+ return body.debuggerFullscreenUrl;
87
+ }
88
+
89
+ async function bbReleaseSession(sessionId) {
90
+ const apiKey = process.env.BROWSERBASE_API_KEY;
91
+ const projectId = process.env.BROWSERBASE_PROJECT_ID;
92
+ try {
93
+ await fetch(`${BB_BASE}/v1/sessions/${sessionId}`, {
94
+ method: "POST",
95
+ headers: { "X-BB-API-Key": apiKey, "Content-Type": "application/json" },
96
+ body: JSON.stringify({ projectId, status: "REQUEST_RELEASE" }),
97
+ });
98
+ } catch (e) {
99
+ log(`[shutdown] release session ${sessionId} failed: ${e.message}`);
100
+ }
101
+ }
102
+
103
+ async function safeText(res) {
104
+ try {
105
+ return (await res.text()).slice(0, 200);
106
+ } catch {
107
+ return "<unreadable>";
108
+ }
109
+ }
110
+
111
+ // --- Session cache ----------------------------------------------------------
112
+
113
+ const sessions = new Map(); // name -> { sid, browser, context, page, liveUrl }
114
+
115
+ async function ensureSession(name) {
116
+ if (sessions.has(name)) return sessions.get(name);
117
+
118
+ const created = await bbCreateSession();
119
+ const liveUrl = await bbGetLiveUrl(created.id);
120
+ const browser = await chromium.connectOverCDP(created.connectUrl);
121
+ const context = browser.contexts()[0] ?? (await browser.newContext());
122
+ const page = context.pages()[0] ?? (await context.newPage());
123
+
124
+ const info = {
125
+ sid: created.id,
126
+ browser,
127
+ context,
128
+ page,
129
+ liveUrl,
130
+ };
131
+ sessions.set(name, info);
132
+
133
+ send({
134
+ type: "slice.session_started",
135
+ session: name,
136
+ session_id: created.id,
137
+ live_url: liveUrl,
138
+ started_at: new Date().toISOString(),
139
+ });
140
+ return info;
141
+ }
142
+
143
+ // --- {{ env.X }} substitution ----------------------------------------------
144
+
145
+ const ENV_RE = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
146
+
147
+ function expand(value) {
148
+ if (typeof value === "string") {
149
+ return value.replace(ENV_RE, (_, name) => {
150
+ const v = process.env[name];
151
+ if (v === undefined) {
152
+ // Leave the placeholder visible so failures surface in stderr summaries
153
+ // instead of silently turning into empty strings.
154
+ log(`[warn] env var ${name} is not set; leaving placeholder`);
155
+ return "";
156
+ }
157
+ return v;
158
+ });
159
+ }
160
+ if (Array.isArray(value)) return value.map(expand);
161
+ if (value && typeof value === "object") {
162
+ const out = {};
163
+ for (const [k, v] of Object.entries(value)) out[k] = expand(v);
164
+ return out;
165
+ }
166
+ return value;
167
+ }
168
+
169
+ // --- Verb dispatch ----------------------------------------------------------
170
+
171
+ function verbName(verb) {
172
+ if (!verb || typeof verb !== "object") return String(verb);
173
+ return Object.keys(verb)[0] || "verb";
174
+ }
175
+
176
+ // Most verbs accept either a bare string ("goto: https://...") or a structured
177
+ // object ("goto: { url: ..., wait_until: ... }"). This pulls the canonical
178
+ // field out of either shape.
179
+ function arg(value, primaryKey) {
180
+ if (typeof value === "string") return { [primaryKey]: value };
181
+ if (value && typeof value === "object") return value;
182
+ return {};
183
+ }
184
+
185
+ async function runVerb(page, verb, index) {
186
+ const name = verbName(verb);
187
+ const raw = verb[name];
188
+ const a = expand(arg(raw, defaultKey(name)));
189
+
190
+ switch (name) {
191
+ case "goto": {
192
+ const url = a.url ?? "";
193
+ const waitUntil = a.wait_until ?? "domcontentloaded";
194
+ await page.goto(url, { waitUntil, timeout: a.timeout ?? 30_000 });
195
+ return `→ ${page.url()}`;
196
+ }
197
+ case "fill": {
198
+ // Don't echo the value into the summary — could be a credential.
199
+ await page.fill(a.selector, String(a.value ?? ""), {
200
+ timeout: a.timeout ?? 10_000,
201
+ });
202
+ return `${a.selector} = «${redact(a.value)}»`;
203
+ }
204
+ case "click": {
205
+ await page.click(a.selector, { timeout: a.timeout ?? 10_000 });
206
+ return `${a.selector}`;
207
+ }
208
+ case "press": {
209
+ const target = a.selector ?? "body";
210
+ await page.press(target, a.key, { timeout: a.timeout ?? 5_000 });
211
+ return `${target} ⌨ ${a.key}`;
212
+ }
213
+ case "wait_for": {
214
+ const selector = a.selector;
215
+ const state = a.state ?? "visible";
216
+ await page.waitForSelector(selector, {
217
+ state,
218
+ timeout: a.timeout ?? 15_000,
219
+ });
220
+ return `${selector} (${state})`;
221
+ }
222
+ case "screenshot": {
223
+ const path = a.path ?? `screenshot-${Date.now()}.png`;
224
+ await page.screenshot({ path, fullPage: !!a.full_page });
225
+ return `→ ${path}`;
226
+ }
227
+ case "extract": {
228
+ // Pull structured rows out of the page. Each `field` entry is either:
229
+ // string — CSS selector relative to row, take textContent
230
+ // { selector, attr } — CSS selector relative to row, take attribute
231
+ // { selector, text: true } — explicit textContent (default)
232
+ const rowSelector = a.selector;
233
+ const fields = a.fields ?? {};
234
+ const items = await page.$$eval(
235
+ rowSelector,
236
+ (rows, fieldSpec) =>
237
+ rows.map((row) => {
238
+ const out = {};
239
+ for (const [name, spec] of Object.entries(fieldSpec)) {
240
+ const sel = typeof spec === "string" ? spec : spec.selector;
241
+ const attr = typeof spec === "string" ? null : spec.attr ?? null;
242
+ const el = sel ? row.querySelector(sel) : row;
243
+ if (!el) {
244
+ out[name] = null;
245
+ continue;
246
+ }
247
+ out[name] = attr
248
+ ? el.getAttribute(attr)
249
+ : (el.textContent || "").trim();
250
+ }
251
+ return out;
252
+ }),
253
+ fields,
254
+ );
255
+ // Emit as JSON to stdout so wb captures it in step.complete.stdout.
256
+ // Pretty-printed for readability when a runbook surfaces the output.
257
+ console.log(JSON.stringify(items, null, 2));
258
+ return `${rowSelector} → ${items.length} rows`;
259
+ }
260
+ case "assert": {
261
+ const sel = a.selector;
262
+ const handle = await page.$(sel);
263
+ if (!handle) throw new Error(`assert: selector not found: ${sel}`);
264
+ if (a.text_contains) {
265
+ const txt = (await handle.textContent()) ?? "";
266
+ if (!txt.includes(a.text_contains)) {
267
+ throw new Error(
268
+ `assert: text "${a.text_contains}" not in ${sel} (got "${txt.slice(0, 80)}")`,
269
+ );
270
+ }
271
+ }
272
+ if (a.url_contains && !page.url().includes(a.url_contains)) {
273
+ throw new Error(
274
+ `assert: url does not contain "${a.url_contains}" (got ${page.url()})`,
275
+ );
276
+ }
277
+ return `${sel}`;
278
+ }
279
+ case "eval": {
280
+ // Run arbitrary JS in the page; result is JSON-serialized to stdout.
281
+ const result = await page.evaluate(a.script);
282
+ console.log(JSON.stringify(result, null, 2));
283
+ return `script ran`;
284
+ }
285
+ default:
286
+ throw new Error(`unsupported verb: ${name}`);
287
+ }
288
+ }
289
+
290
+ function defaultKey(name) {
291
+ switch (name) {
292
+ case "goto":
293
+ return "url";
294
+ case "click":
295
+ case "wait_for":
296
+ case "assert":
297
+ return "selector";
298
+ case "screenshot":
299
+ return "path";
300
+ case "press":
301
+ return "key";
302
+ case "eval":
303
+ return "script";
304
+ default:
305
+ return "value";
306
+ }
307
+ }
308
+
309
+ function redact(value) {
310
+ if (typeof value !== "string") return "";
311
+ if (value.length <= 4) return "***";
312
+ return `${value.slice(0, 2)}***`;
313
+ }
314
+
315
+ // --- Slice handler ----------------------------------------------------------
316
+
317
+ async function handleSlice(msg) {
318
+ const verbs = Array.isArray(msg.verbs) ? msg.verbs : [];
319
+ const sessionName = msg.session || "default";
320
+ const restore = msg.restore || null;
321
+
322
+ let session;
323
+ try {
324
+ session = await ensureSession(sessionName);
325
+ } catch (e) {
326
+ send({
327
+ type: "slice.failed",
328
+ error: `session start failed: ${e.message}`,
329
+ });
330
+ return;
331
+ }
332
+
333
+ // Restore-from-pause is not implemented yet (no pause verb wired here).
334
+ // The sidecar protocol leaves room for it; when wait_for_mfa lands, this
335
+ // is where we'd jump to verbs[restore.state.verb_index].
336
+ const startAt = restore?.state?.verb_index ?? 0;
337
+
338
+ for (let i = startAt; i < verbs.length; i++) {
339
+ const v = verbs[i];
340
+ const name = verbName(v);
341
+ try {
342
+ const summary = await runVerb(session.page, v, i);
343
+ send({
344
+ type: "verb.complete",
345
+ verb: name,
346
+ verb_index: i,
347
+ summary,
348
+ });
349
+ } catch (e) {
350
+ send({
351
+ type: "verb.failed",
352
+ verb: name,
353
+ verb_index: i,
354
+ error: e.message,
355
+ });
356
+ send({
357
+ type: "slice.failed",
358
+ error: `verb ${name} (index ${i}): ${e.message}`,
359
+ });
360
+ return;
361
+ }
362
+ }
363
+ send({ type: "slice.complete" });
364
+ }
365
+
366
+ // --- Shutdown ---------------------------------------------------------------
367
+
368
+ let shuttingDown = false;
369
+ async function shutdown() {
370
+ if (shuttingDown) return;
371
+ shuttingDown = true;
372
+ for (const [name, info] of sessions) {
373
+ try {
374
+ await info.browser.close();
375
+ } catch (e) {
376
+ log(`[shutdown] close ${name}: ${e.message}`);
377
+ }
378
+ }
379
+ // Ask Browserbase to release sessions explicitly so quota isn't held by
380
+ // orphans waiting for their idle timeout.
381
+ await Promise.all(
382
+ Array.from(sessions.values()).map((s) => bbReleaseSession(s.sid)),
383
+ );
384
+ process.exit(0);
385
+ }
386
+
387
+ // --- Main loop --------------------------------------------------------------
388
+
389
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
390
+
391
+ // Serialize incoming messages — Playwright operations are async and we don't
392
+ // want concurrent slice handlers stomping on the shared page.
393
+ let chain = Promise.resolve();
394
+ function enqueue(fn) {
395
+ chain = chain.then(fn).catch((e) => log(`[loop] ${e.message}`));
396
+ return chain;
397
+ }
398
+
399
+ rl.on("line", (line) => {
400
+ const trimmed = line.trim();
401
+ if (!trimmed) return;
402
+ let msg;
403
+ try {
404
+ msg = JSON.parse(trimmed);
405
+ } catch {
406
+ log(`[warn] ignoring non-JSON input: ${trimmed.slice(0, 80)}`);
407
+ return;
408
+ }
409
+
410
+ switch (msg.type) {
411
+ case "hello":
412
+ send({
413
+ type: "ready",
414
+ runtime: "wb-browser-runtime",
415
+ version: VERSION,
416
+ protocol: "wb-sidecar/1",
417
+ supports: SUPPORTS,
418
+ });
419
+ break;
420
+ case "slice":
421
+ enqueue(() => handleSlice(msg));
422
+ break;
423
+ case "shutdown":
424
+ enqueue(shutdown);
425
+ break;
426
+ default:
427
+ log(`[warn] unknown message type: ${msg.type}`);
428
+ }
429
+ });
430
+
431
+ rl.on("close", () => {
432
+ // stdin closed — drain pending work then exit.
433
+ enqueue(shutdown);
434
+ });
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "wb-browser-runtime",
3
+ "version": "0.3.0",
4
+ "description": "Browser sidecar runtime for wb — Browserbase + Playwright over the wb-sidecar/1 line-framed JSON protocol.",
5
+ "bin": {
6
+ "wb-browser-runtime": "bin/wb-browser-runtime.js"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "dependencies": {
13
+ "playwright-core": "^1.49.0"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "README.md"
18
+ ]
19
+ }