runline 0.23.0 → 0.25.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.
Files changed (27) hide show
  1. package/dist/core/engine.js +5 -2
  2. package/dist/plugin/schema.d.ts +13 -1
  3. package/dist/plugin/schema.js +115 -19
  4. package/dist/plugin/types.d.ts +11 -0
  5. package/dist/plugins/shiftTranscription/src/index.js +1 -1
  6. package/dist/plugins/shiftTranscription/src/transcription.js +12 -0
  7. package/dist/plugins/steel/src/browser.js +11 -79
  8. package/dist/plugins/steel/src/index.js +2 -0
  9. package/dist/plugins/steel/src/page/bundle.generated.js +11 -0
  10. package/dist/plugins/steel/src/page/cdp.js +232 -0
  11. package/dist/plugins/steel/src/page/driver.js +286 -0
  12. package/dist/plugins/steel/src/page/entry.js +91 -0
  13. package/dist/plugins/steel/src/page/install.js +3 -0
  14. package/dist/plugins/steel/src/page/page-snapshot.js +330 -0
  15. package/dist/plugins/steel/src/page/tools.js +649 -0
  16. package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshot.js +411 -0
  17. package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshotDistiller.js +207 -0
  18. package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaTypes.js +62 -0
  19. package/dist/plugins/steel/src/page/vendor/aria-snapshot/cssTokenizer.js +949 -0
  20. package/dist/plugins/steel/src/page/vendor/aria-snapshot/domUtils.js +157 -0
  21. package/dist/plugins/steel/src/page/vendor/aria-snapshot/roleUtils.js +1193 -0
  22. package/dist/plugins/steel/src/page/vendor/aria-snapshot/stringUtils.js +194 -0
  23. package/dist/plugins/steel/src/page/vendor/aria-snapshot/yaml.js +80 -0
  24. package/dist/plugins/steel/src/page/wait-for.js +30 -0
  25. package/dist/plugins/steel/src/sessions.js +2 -7
  26. package/dist/plugins/steel/src/shared.js +12 -0
  27. package/package.json +1 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * A minimal Chrome DevTools Protocol client over the Steel session's
3
+ * websocket, and the page-bridge channel built on top of it.
4
+ *
5
+ * This exists because the Steel plugin cannot assume Playwright: the host
6
+ * app may not have it installed, and quietly substituting a lookalike is
7
+ * what made `browser.run` report wrong results instead of failing. Here
8
+ * the dependency is the protocol itself, which Steel always speaks.
9
+ *
10
+ * Scope is deliberate. This is not a browser automation library — it is
11
+ * the transport for two things: evaluating the page bridge, and
12
+ * dispatching trusted input. Anything richer belongs in the page bridge,
13
+ * where it is ordinary DOM code.
14
+ */
15
+ import { PAGE_BRIDGE_GLOBAL, PAGE_BRIDGE_SOURCE, PAGE_BRIDGE_VERSION, } from "./bundle.generated.js";
16
+ const CONNECT_TIMEOUT_MS = 30_000;
17
+ const COMMAND_TIMEOUT_MS = 30_000;
18
+ export class CdpError extends Error {
19
+ code;
20
+ retryable;
21
+ constructor(code, message, retryable = true) {
22
+ super(message);
23
+ this.code = code;
24
+ this.retryable = retryable;
25
+ this.name = "CdpError";
26
+ }
27
+ }
28
+ /** Connect to a CDP endpoint and resolve once the socket is open. */
29
+ export async function connectCdp(url) {
30
+ const ws = new WebSocket(url);
31
+ const pending = new Map();
32
+ const handlers = new Set();
33
+ let nextId = 0;
34
+ let closed = null;
35
+ const failAll = (error) => {
36
+ closed = error;
37
+ for (const [id, wait] of pending) {
38
+ clearTimeout(wait.timer);
39
+ pending.delete(id);
40
+ wait.reject(error);
41
+ }
42
+ };
43
+ await new Promise((resolve, reject) => {
44
+ const timer = setTimeout(() => reject(new CdpError("cdp_unavailable", `Timed out connecting to the browser after ${CONNECT_TIMEOUT_MS}ms`)), CONNECT_TIMEOUT_MS);
45
+ ws.addEventListener("open", () => {
46
+ clearTimeout(timer);
47
+ resolve();
48
+ }, { once: true });
49
+ ws.addEventListener("error", () => {
50
+ clearTimeout(timer);
51
+ reject(new CdpError("cdp_unavailable", "Could not connect to the browser"));
52
+ }, { once: true });
53
+ });
54
+ ws.addEventListener("close", () => failAll(new CdpError("cdp_closed", "The browser connection closed")));
55
+ ws.addEventListener("message", (event) => {
56
+ let message;
57
+ try {
58
+ message = JSON.parse(String(event.data));
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ if (typeof message.id === "number") {
64
+ const wait = pending.get(message.id);
65
+ if (!wait)
66
+ return;
67
+ clearTimeout(wait.timer);
68
+ pending.delete(message.id);
69
+ if (message.error) {
70
+ const error = message.error;
71
+ wait.reject(new CdpError("cdp_command_failed", error.message ?? "CDP error"));
72
+ }
73
+ else {
74
+ wait.resolve(message.result ?? {});
75
+ }
76
+ return;
77
+ }
78
+ if (typeof message.method === "string") {
79
+ // The originating target is on the envelope, not in params. Dropping
80
+ // it here would make every per-page subscription see every page's
81
+ // events.
82
+ const from = typeof message.sessionId === "string" ? message.sessionId : undefined;
83
+ for (const handler of handlers) {
84
+ handler(message.method, (message.params ?? {}), from);
85
+ }
86
+ }
87
+ });
88
+ return {
89
+ get alive() {
90
+ return closed === null;
91
+ },
92
+ send(method, params = {}, sessionId) {
93
+ if (closed)
94
+ return Promise.reject(closed);
95
+ return new Promise((resolve, reject) => {
96
+ const id = ++nextId;
97
+ const timer = setTimeout(() => {
98
+ pending.delete(id);
99
+ reject(new CdpError("cdp_timeout", `${method} timed out`));
100
+ }, COMMAND_TIMEOUT_MS);
101
+ pending.set(id, {
102
+ resolve: resolve,
103
+ reject,
104
+ timer,
105
+ });
106
+ ws.send(JSON.stringify({
107
+ id,
108
+ method,
109
+ params,
110
+ ...(sessionId ? { sessionId } : {}),
111
+ }));
112
+ });
113
+ },
114
+ on(handler) {
115
+ handlers.add(handler);
116
+ return () => handlers.delete(handler);
117
+ },
118
+ close() {
119
+ failAll(new CdpError("cdp_closed", "The browser connection was closed"));
120
+ try {
121
+ ws.close();
122
+ }
123
+ catch {
124
+ // Already closing; nothing to recover.
125
+ }
126
+ },
127
+ };
128
+ }
129
+ /**
130
+ * One attached page target, with the page bridge kept installed on it.
131
+ */
132
+ export class CdpPage {
133
+ cdp;
134
+ targetId;
135
+ sessionId;
136
+ constructor(cdp, targetId, sessionId) {
137
+ this.cdp = cdp;
138
+ this.targetId = targetId;
139
+ this.sessionId = sessionId;
140
+ }
141
+ send(method, params = {}) {
142
+ return this.cdp.send(method, params, this.sessionId);
143
+ }
144
+ /** Subscribe to this page's protocol events; returns an unsubscribe. */
145
+ onEvent(handler) {
146
+ return this.cdp.on((method, params, from) => {
147
+ if (from !== undefined && from !== this.sessionId)
148
+ return;
149
+ handler(method, params);
150
+ });
151
+ }
152
+ /** Evaluate an expression in the page and return its value. */
153
+ async evaluate(expression) {
154
+ const result = (await this.send("Runtime.evaluate", {
155
+ expression,
156
+ returnByValue: true,
157
+ awaitPromise: true,
158
+ }));
159
+ if (result.exceptionDetails) {
160
+ const detail = result.exceptionDetails;
161
+ throw new CdpError("page_error", detail.exception?.description ?? detail.text ?? "Page evaluation failed");
162
+ }
163
+ return result.result?.value;
164
+ }
165
+ /**
166
+ * Call the page bridge, installing it first when the world does not
167
+ * have it. Navigation destroys the world, so this self-heals rather
168
+ * than requiring callers to track page lifetime.
169
+ */
170
+ async bridge(request) {
171
+ let response = await this.callBridge(request);
172
+ if (response === undefined) {
173
+ await this.installBridge();
174
+ response = await this.callBridge(request);
175
+ }
176
+ if (response === undefined) {
177
+ throw new CdpError("page_unavailable", "The page bridge did not install; the page may have navigated mid-action");
178
+ }
179
+ const outcome = response;
180
+ if (!outcome.ok) {
181
+ throw new CdpError(outcome.error.code, outcome.error.message, outcome.error.retryable);
182
+ }
183
+ return outcome.value;
184
+ }
185
+ async callBridge(request) {
186
+ return await this.evaluate(`(() => {
187
+ const bridge = window[${JSON.stringify(PAGE_BRIDGE_GLOBAL)}];
188
+ if (!bridge || bridge.version !== ${PAGE_BRIDGE_VERSION}) return undefined;
189
+ return bridge.handle(${JSON.stringify(request)});
190
+ })()`);
191
+ }
192
+ async installBridge() {
193
+ await this.evaluate(PAGE_BRIDGE_SOURCE);
194
+ }
195
+ /**
196
+ * Install the bridge on every future document too. Without this the
197
+ * first call after any navigation pays a reinstall round trip, and a
198
+ * page that navigates itself mid-action could be observed bridgeless.
199
+ */
200
+ async installOnNewDocuments() {
201
+ await this.send("Page.addScriptToEvaluateOnNewDocument", {
202
+ source: PAGE_BRIDGE_SOURCE,
203
+ });
204
+ }
205
+ }
206
+ /** Attach to the session's first page target. */
207
+ export async function attachToPage(cdp) {
208
+ const { targetInfos } = (await cdp.send("Target.getTargets"));
209
+ const pages = (targetInfos ?? []).filter((info) => info.type === "page");
210
+ const target = pages[0];
211
+ if (!target) {
212
+ throw new CdpError("page_unavailable", "The browser session has no page to control", false);
213
+ }
214
+ return await attachToTarget(cdp, target.targetId);
215
+ }
216
+ export async function attachToTarget(cdp, targetId) {
217
+ const { sessionId } = (await cdp.send("Target.attachToTarget", {
218
+ targetId,
219
+ flatten: true,
220
+ }));
221
+ const page = new CdpPage(cdp, targetId, sessionId);
222
+ await page.send("Page.enable");
223
+ await page.send("Runtime.enable");
224
+ await page.send("DOM.enable");
225
+ // Needed before cookies can be written on the target.
226
+ await page.send("Network.enable");
227
+ await page.installOnNewDocuments();
228
+ // The bridge is installed lazily by the first `bridge()` call, which
229
+ // already probes for it and self-heals. Installing here as well would
230
+ // re-evaluate the whole bundle on every attach for no benefit.
231
+ return page;
232
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Trusted browser input over raw CDP.
3
+ *
4
+ * CDP domains are addressed directly rather than through a browser
5
+ * automation library, because the plugin must work in hosts that have
6
+ * none installed. The events are the real ones — `Input.dispatchMouseEvent`
7
+ * and friends produce `isTrusted` events, so pages that reject synthetic
8
+ * input behave as they do for a person.
9
+ *
10
+ * Targets arrive as a `locatorToken` the page bridge stamped onto the
11
+ * element. Resolving that token to coordinates happens here, immediately
12
+ * before dispatch, so a layout shift between preparation and action is
13
+ * caught rather than clicked through.
14
+ */
15
+ import { CdpError } from "./cdp.js";
16
+ import { TARGET_ATTRIBUTE } from "./page-snapshot.js";
17
+ const MODIFIER_BITS = {
18
+ Alt: 1,
19
+ Control: 2,
20
+ Meta: 4,
21
+ Shift: 8,
22
+ };
23
+ /**
24
+ * CDP wants a bitmask; the tools speak in names. Unknown names are
25
+ * refused rather than folded in as `undefined`, which would turn the
26
+ * whole mask into NaN and silently strip every modifier.
27
+ */
28
+ function modifierMask(modifiers = []) {
29
+ let mask = 0;
30
+ for (const name of modifiers) {
31
+ const bit = MODIFIER_BITS[name];
32
+ if (bit === undefined) {
33
+ throw new CdpError("invalid_arguments", `Unknown modifier ${name}; expected Alt, Control, Meta, or Shift`, false);
34
+ }
35
+ mask |= bit;
36
+ }
37
+ return mask;
38
+ }
39
+ /** A page expression selecting the element the bridge stamped for us. */
40
+ function elementExpression(locatorToken) {
41
+ return `document.querySelector(${JSON.stringify(`[${TARGET_ATTRIBUTE}=${JSON.stringify(locatorToken)}]`)})`;
42
+ }
43
+ export class CdpDriver {
44
+ page;
45
+ openDialog = null;
46
+ detachDialogs;
47
+ constructor(page) {
48
+ this.page = page;
49
+ // Dialogs block the renderer: every subsequent evaluate would hang
50
+ // until one is handled, so the tools need to see it as a result
51
+ // rather than as a timeout.
52
+ this.detachDialogs = page.onEvent((method, params) => {
53
+ if (method === "Page.javascriptDialogOpening") {
54
+ this.openDialog = {
55
+ type: String(params.type ?? "dialog"),
56
+ message: String(params.message ?? ""),
57
+ defaultValue: String(params.defaultPrompt ?? ""),
58
+ };
59
+ }
60
+ if (method === "Page.javascriptDialogClosed")
61
+ this.openDialog = null;
62
+ });
63
+ }
64
+ dialog() {
65
+ return this.openDialog;
66
+ }
67
+ dispose() {
68
+ this.detachDialogs();
69
+ }
70
+ async handleDialog(options) {
71
+ if (!this.openDialog) {
72
+ throw new CdpError("no_dialog", "No dialog is currently open", false);
73
+ }
74
+ await this.page.send("Page.handleJavaScriptDialog", {
75
+ accept: options.accept,
76
+ ...(options.promptText === undefined
77
+ ? {}
78
+ : { promptText: options.promptText }),
79
+ });
80
+ this.openDialog = null;
81
+ }
82
+ /**
83
+ * Resolve a prepared token to viewport coordinates. Runs in the page so
84
+ * the element's own scroll container is honoured, and fails loudly when
85
+ * the element has gone or moved out of view.
86
+ */
87
+ async point(target) {
88
+ const box = await this.page.evaluate(`(() => {
89
+ const el = ${elementExpression(target.locatorToken)};
90
+ if (!el) return { error: 'detached' };
91
+ el.scrollIntoView({ block: 'center', inline: 'center' });
92
+ const r = el.getBoundingClientRect();
93
+ if (r.width <= 0 || r.height <= 0) return { error: 'not_visible' };
94
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
95
+ })()`);
96
+ if ("error" in box) {
97
+ throw box.error === "detached"
98
+ ? new CdpError("stale_ref", "The target element left the page between preparing and acting on it. Capture a fresh snapshot and retry.")
99
+ : new CdpError("element_not_visible", "The target element is not visible at action time");
100
+ }
101
+ return box;
102
+ }
103
+ async mouse(type, point, options = {}) {
104
+ await this.page.send("Input.dispatchMouseEvent", {
105
+ type,
106
+ x: point.x,
107
+ y: point.y,
108
+ button: options.button ?? "left",
109
+ clickCount: options.clickCount ?? 0,
110
+ modifiers: options.modifiers ?? 0,
111
+ });
112
+ }
113
+ async click(target, options) {
114
+ const point = await this.point(target);
115
+ const modifiers = modifierMask(options.modifiers);
116
+ const button = options.button ?? "left";
117
+ await this.mouse("mouseMoved", point, { modifiers });
118
+ const clicks = options.doubleClick ? 2 : 1;
119
+ for (let index = 1; index <= clicks; index += 1) {
120
+ await this.mouse("mousePressed", point, {
121
+ button,
122
+ clickCount: index,
123
+ modifiers,
124
+ });
125
+ await this.mouse("mouseReleased", point, {
126
+ button,
127
+ clickCount: index,
128
+ modifiers,
129
+ });
130
+ }
131
+ }
132
+ async hover(target) {
133
+ await this.mouse("mouseMoved", await this.point(target));
134
+ }
135
+ async drag(start, end) {
136
+ const from = await this.point(start);
137
+ const to = await this.point(end);
138
+ await this.mouse("mouseMoved", from);
139
+ await this.mouse("mousePressed", from, { clickCount: 1 });
140
+ // Intermediate moves: drag handlers commonly ignore a single jump.
141
+ for (let step = 1; step <= 4; step += 1) {
142
+ await this.mouse("mouseMoved", {
143
+ x: from.x + ((to.x - from.x) * step) / 4,
144
+ y: from.y + ((to.y - from.y) * step) / 4,
145
+ });
146
+ }
147
+ await this.mouse("mouseReleased", to, { clickCount: 1 });
148
+ }
149
+ async scroll(deltaY, target) {
150
+ const point = target
151
+ ? await this.point(target)
152
+ : await this.viewportCentre();
153
+ await this.page.send("Input.dispatchMouseEvent", {
154
+ type: "mouseWheel",
155
+ x: point.x,
156
+ y: point.y,
157
+ deltaX: 0,
158
+ deltaY,
159
+ });
160
+ }
161
+ async viewportCentre() {
162
+ return await this.page.evaluate("({ x: window.innerWidth / 2, y: window.innerHeight / 2 })");
163
+ }
164
+ async type(target, text, options) {
165
+ // The bridge already focused the element and set the selection for
166
+ // replace/append, so typing here just replays keys into it.
167
+ if (options.slowly) {
168
+ for (const char of text)
169
+ await this.insertKey(char);
170
+ return;
171
+ }
172
+ if (text.length === 0) {
173
+ // Replace-mode with empty text means "clear": the selection is
174
+ // set, so a delete is what removes it.
175
+ await this.press("Delete");
176
+ return;
177
+ }
178
+ await this.page.send("Input.insertText", { text });
179
+ }
180
+ async insertKey(char) {
181
+ await this.page.send("Input.dispatchKeyEvent", {
182
+ type: "keyDown",
183
+ text: char,
184
+ unmodifiedText: char,
185
+ });
186
+ await this.page.send("Input.dispatchKeyEvent", {
187
+ type: "keyUp",
188
+ text: char,
189
+ });
190
+ }
191
+ async press(key) {
192
+ const parts = key.split("+");
193
+ const main = parts[parts.length - 1] ?? key;
194
+ const modifiers = modifierMask(parts.slice(0, -1));
195
+ const descriptor = keyDescriptor(main);
196
+ await this.page.send("Input.dispatchKeyEvent", {
197
+ type: descriptor.text ? "keyDown" : "rawKeyDown",
198
+ modifiers,
199
+ ...descriptor,
200
+ });
201
+ await this.page.send("Input.dispatchKeyEvent", {
202
+ type: "keyUp",
203
+ modifiers,
204
+ ...descriptor,
205
+ });
206
+ }
207
+ async select(target, values) {
208
+ const ok = await this.page.evaluate(`(() => {
209
+ const el = ${elementExpression(target.locatorToken)};
210
+ if (!el) return false;
211
+ const wanted = new Set(${JSON.stringify(values)});
212
+ for (const option of el.options) option.selected = wanted.has(option.value);
213
+ el.dispatchEvent(new Event('input', { bubbles: true }));
214
+ el.dispatchEvent(new Event('change', { bubbles: true }));
215
+ return true;
216
+ })()`);
217
+ if (!ok) {
218
+ throw new CdpError("stale_ref", "The select element left the page between preparing and acting on it. Capture a fresh snapshot and retry.");
219
+ }
220
+ }
221
+ async screenshot(options) {
222
+ const params = {
223
+ format: "jpeg",
224
+ quality: 80,
225
+ };
226
+ if (options.fullPage)
227
+ params.captureBeyondViewport = true;
228
+ const result = (await this.page.send("Page.captureScreenshot", params));
229
+ return { data: result.data, mimeType: "image/jpeg" };
230
+ }
231
+ /** Let the page react to the action before it is snapshotted. */
232
+ async settle() {
233
+ await new Promise((resolve) => setTimeout(resolve, 120));
234
+ if (this.openDialog)
235
+ return;
236
+ await this.page
237
+ .evaluate(`new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))`)
238
+ .catch(() => {
239
+ // A navigation or dialog can tear down the world mid-settle;
240
+ // the caller's snapshot reports the real state either way.
241
+ });
242
+ }
243
+ }
244
+ const NAMED_KEYS = {
245
+ Enter: { code: "Enter", keyCode: 13 },
246
+ Tab: { code: "Tab", keyCode: 9 },
247
+ Escape: { code: "Escape", keyCode: 27 },
248
+ Backspace: { code: "Backspace", keyCode: 8 },
249
+ Delete: { code: "Delete", keyCode: 46 },
250
+ ArrowUp: { code: "ArrowUp", keyCode: 38 },
251
+ ArrowDown: { code: "ArrowDown", keyCode: 40 },
252
+ ArrowLeft: { code: "ArrowLeft", keyCode: 37 },
253
+ ArrowRight: { code: "ArrowRight", keyCode: 39 },
254
+ Home: { code: "Home", keyCode: 36 },
255
+ End: { code: "End", keyCode: 35 },
256
+ PageUp: { code: "PageUp", keyCode: 33 },
257
+ PageDown: { code: "PageDown", keyCode: 34 },
258
+ Space: { code: "Space", keyCode: 32 },
259
+ };
260
+ /**
261
+ * CDP needs `key`/`code`/`windowsVirtualKeyCode` for non-printable keys;
262
+ * printable ones also need `text` or they arrive as bare keydowns.
263
+ */
264
+ function keyDescriptor(key) {
265
+ const match = NAMED_KEYS[key];
266
+ if (match) {
267
+ return {
268
+ key: key === "Space" ? " " : key,
269
+ code: match.code,
270
+ windowsVirtualKeyCode: match.keyCode,
271
+ ...(key === "Enter" ? { text: "\r" } : {}),
272
+ ...(key === "Space" ? { text: " " } : {}),
273
+ };
274
+ }
275
+ if (key.length === 1) {
276
+ const upper = key.toUpperCase();
277
+ return {
278
+ key,
279
+ code: /[a-z]/i.test(key) ? `Key${upper}` : `Digit${key}`,
280
+ windowsVirtualKeyCode: upper.charCodeAt(0),
281
+ text: key,
282
+ unmodifiedText: key,
283
+ };
284
+ }
285
+ return { key };
286
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The in-page half of the Steel page tools.
3
+ *
4
+ * This module is bundled (see `scripts/build-steel-page-bundle.ts`) into
5
+ * one self-contained IIFE and evaluated inside the Steel session's page.
6
+ * Everything here is pure DOM code — it never sees the Steel API key, the
7
+ * CDP socket, or any plugin state.
8
+ *
9
+ * Why a resident global rather than a fresh evaluate per call: element
10
+ * refs (`e12`) are only stable while the aria-snapshot module's counter
11
+ * and each element's cached `_ariaRef` survive between calls. A new
12
+ * bundle per call would renumber every element and silently invalidate
13
+ * refs the agent is holding. So the bundle installs itself once per
14
+ * execution context and the driver re-injects only when the world is
15
+ * gone — which is exactly navigation, where fresh refs are correct.
16
+ */
17
+ import { PageSnapshotService, BrowserToolError, } from "./page-snapshot.js";
18
+ import { waitForPage } from "./wait-for.js";
19
+ /** Bumped when the in-page contract changes, so a stale world is replaced. */
20
+ export const PAGE_BRIDGE_VERSION = 1;
21
+ export const PAGE_BRIDGE_GLOBAL = "__steelPageBridge";
22
+ export function createPageBridge(document) {
23
+ let service = new PageSnapshotService(document);
24
+ let maxChars;
25
+ return {
26
+ version: PAGE_BRIDGE_VERSION,
27
+ async handle(request) {
28
+ try {
29
+ return { ok: true, value: await dispatch(request) };
30
+ }
31
+ catch (error) {
32
+ if (error instanceof BrowserToolError) {
33
+ return {
34
+ ok: false,
35
+ error: {
36
+ code: error.code,
37
+ message: error.message,
38
+ retryable: error.retryable,
39
+ },
40
+ };
41
+ }
42
+ return {
43
+ ok: false,
44
+ error: {
45
+ code: "page_error",
46
+ message: error instanceof Error ? error.message : String(error),
47
+ retryable: true,
48
+ },
49
+ };
50
+ }
51
+ },
52
+ };
53
+ async function dispatch(request) {
54
+ switch (request.action) {
55
+ case "snapshot": {
56
+ // maxChars is a construction option upstream; honour a per-call
57
+ // override by rebuilding rather than reaching into the service.
58
+ if (request.maxChars !== undefined && request.maxChars !== maxChars) {
59
+ maxChars = request.maxChars;
60
+ service = new PageSnapshotService(document, { maxChars });
61
+ }
62
+ return service.capture({
63
+ ...(request.depth !== undefined ? { depth: request.depth } : {}),
64
+ ...(request.boxes !== undefined ? { boxes: request.boxes } : {}),
65
+ });
66
+ }
67
+ case "prepare_target":
68
+ return await service.prepareTarget(request.target);
69
+ case "prepare_editable":
70
+ return await service.prepareEditableTarget(request.target, request.mode);
71
+ case "editable_value":
72
+ return service.readEditableValue(request.target);
73
+ case "resolve_options":
74
+ return { selected: service.resolveOptions(request.target, request.values) };
75
+ case "wait_for":
76
+ return await waitForPage(document, request);
77
+ }
78
+ }
79
+ }
80
+ /**
81
+ * Install idempotently. Re-evaluating the bundle in a world that already
82
+ * has a current bridge must not reset the ref counter.
83
+ */
84
+ export function installPageBridge() {
85
+ const existing = globalThis[PAGE_BRIDGE_GLOBAL];
86
+ if (existing?.version === PAGE_BRIDGE_VERSION)
87
+ return existing;
88
+ const bridge = createPageBridge(document);
89
+ globalThis[PAGE_BRIDGE_GLOBAL] = bridge;
90
+ return bridge;
91
+ }
@@ -0,0 +1,3 @@
1
+ /** Bundle entrypoint: install the bridge into the page's global world. */
2
+ import { installPageBridge } from "./entry.js";
3
+ installPageBridge();