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 +111 -0
- package/bin/wb-browser-runtime.js +434 -0
- package/package.json +19 -0
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
|
+
}
|