@alfe.ai/browser 0.2.0 → 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 +29 -0
- package/dist/index.cjs +729 -153
- package/dist/index.d.cts +58 -14
- package/dist/index.d.ts +58 -14
- package/dist/index.js +730 -154
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,134 @@
|
|
|
1
|
-
import { RemoteFrameType, TurnController,
|
|
1
|
+
import { RemoteFrameType, TurnController, decodeKeyInputPayload, decodeMouseInputPayload, decodeResizePayload, decodeScreencastAckPayload, decodeWheelInputPayload, encodeFrame, encodeJsonFrame, encodeScreencastFrame } from "@alfe.ai/remote";
|
|
2
2
|
import puppeteer, { TargetType } from "puppeteer-core";
|
|
3
|
+
//#region src/boundary.ts
|
|
4
|
+
const MAX_VIEWPORT_WIDTH = 4096;
|
|
5
|
+
const MAX_VIEWPORT_HEIGHT = 4096;
|
|
6
|
+
const MAX_SELECTOR_CHARS = 4096;
|
|
7
|
+
const MAX_EXPRESSION_CHARS = 128 * 1024;
|
|
8
|
+
const MAX_NAVIGATION_URL_CHARS = 8192;
|
|
9
|
+
const MAX_WAIT_MS = 12e4;
|
|
10
|
+
const MAX_RESULT_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_RESULT_DEPTH = 10;
|
|
12
|
+
const MAX_RESULT_NODES = 2e4;
|
|
13
|
+
const MAX_RESULT_ARRAY_ITEMS = 1e4;
|
|
14
|
+
const MAX_RESULT_STRING_CHARS = 25e4;
|
|
15
|
+
const UNSAFE_KEYS = new Set([
|
|
16
|
+
"__proto__",
|
|
17
|
+
"constructor",
|
|
18
|
+
"prototype"
|
|
19
|
+
]);
|
|
20
|
+
const FORBIDDEN_CHROME_ARG_PREFIXES = [
|
|
21
|
+
"--allow-file-access-from-files",
|
|
22
|
+
"--disable-web-security",
|
|
23
|
+
"--host-resolver-rules",
|
|
24
|
+
"--load-extension",
|
|
25
|
+
"--remote-debugging-address",
|
|
26
|
+
"--remote-debugging-pipe",
|
|
27
|
+
"--remote-debugging-port",
|
|
28
|
+
"--user-data-dir"
|
|
29
|
+
];
|
|
30
|
+
function normalizeViewport(width, height, dpr) {
|
|
31
|
+
if (!Number.isInteger(width) || width < 1 || width > 4096) throw new Error(`viewport width must be an integer from 1 to ${String(MAX_VIEWPORT_WIDTH)}`);
|
|
32
|
+
if (!Number.isInteger(height) || height < 1 || height > 4096) throw new Error(`viewport height must be an integer from 1 to ${String(MAX_VIEWPORT_HEIGHT)}`);
|
|
33
|
+
if (!Number.isFinite(dpr) || dpr < .1 || dpr > 4) throw new Error(`viewport dpr must be from 0.1 to ${String(4)}`);
|
|
34
|
+
if (width * height * dpr * dpr > 33554432) throw new Error("viewport exceeds the device-pixel budget");
|
|
35
|
+
return {
|
|
36
|
+
width,
|
|
37
|
+
height,
|
|
38
|
+
dpr
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function validateIdleShutdownMs(value) {
|
|
42
|
+
if (!Number.isInteger(value) || value < 1e3 || value > 1440 * 60 * 1e3) throw new Error("idle shutdown must be an integer from 1000 to 86400000ms");
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function validateChromeArgs(values) {
|
|
46
|
+
if (values.length > 64) throw new Error("too many extra Chrome arguments");
|
|
47
|
+
return values.map((value) => {
|
|
48
|
+
const arg = validateString("Chrome argument", value, 4096);
|
|
49
|
+
const key = arg.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
50
|
+
if (FORBIDDEN_CHROME_ARG_PREFIXES.includes(key)) throw new Error(`Chrome argument ${key} is owned by the browser runtime`);
|
|
51
|
+
return arg;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function validateNavigationUrl(value) {
|
|
55
|
+
const raw = validateString("navigation URL", value, MAX_NAVIGATION_URL_CHARS);
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error("navigation URL must be an absolute HTTP(S) URL");
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") throw new Error("navigation URL must be an absolute HTTP(S) URL without credentials");
|
|
63
|
+
return parsed.href;
|
|
64
|
+
}
|
|
65
|
+
function validateSelector(value) {
|
|
66
|
+
return validateString("selector", value, MAX_SELECTOR_CHARS);
|
|
67
|
+
}
|
|
68
|
+
function validateTypeText(value) {
|
|
69
|
+
if (value.length > 32768) throw new Error("type text exceeds the character limit");
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function validateExpression(value) {
|
|
73
|
+
return validateString("expression", value, MAX_EXPRESSION_CHARS);
|
|
74
|
+
}
|
|
75
|
+
function validateWaitOptions(opts) {
|
|
76
|
+
if ([
|
|
77
|
+
opts.selector !== void 0,
|
|
78
|
+
opts.ms !== void 0,
|
|
79
|
+
opts.urlPattern !== void 0
|
|
80
|
+
].filter(Boolean).length !== 1) throw new Error("waitFor requires exactly one selector, ms, or urlPattern");
|
|
81
|
+
if (opts.selector !== void 0) return { selector: validateSelector(opts.selector) };
|
|
82
|
+
if (opts.urlPattern !== void 0) return { urlPattern: validateString("URL pattern", opts.urlPattern, MAX_NAVIGATION_URL_CHARS) };
|
|
83
|
+
if (!Number.isInteger(opts.ms) || (opts.ms ?? 0) < 0 || (opts.ms ?? 0) > 12e4) throw new Error(`wait duration must be an integer from 0 to ${String(MAX_WAIT_MS)}ms`);
|
|
84
|
+
return { ms: opts.ms };
|
|
85
|
+
}
|
|
86
|
+
function assertBoundedAutomationResult(value) {
|
|
87
|
+
cloneJson(value, 0, { nodes: 0 });
|
|
88
|
+
let encoded;
|
|
89
|
+
try {
|
|
90
|
+
encoded = JSON.stringify(value);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("browser evaluation result must be JSON serializable");
|
|
93
|
+
}
|
|
94
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_RESULT_BYTES) throw new Error("browser evaluation result exceeds the byte limit");
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
function validateString(label, value, maxChars) {
|
|
98
|
+
if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function cloneJson(value, depth, state) {
|
|
102
|
+
state.nodes += 1;
|
|
103
|
+
if (state.nodes > MAX_RESULT_NODES) throw new Error("browser evaluation result contains too many values");
|
|
104
|
+
if (depth > MAX_RESULT_DEPTH) throw new Error("browser evaluation result exceeds the depth limit");
|
|
105
|
+
if (value === null || typeof value === "boolean") return value;
|
|
106
|
+
if (typeof value === "number") {
|
|
107
|
+
if (!Number.isFinite(value)) throw new Error("browser evaluation result contains a non-finite number");
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
if (value.length > MAX_RESULT_STRING_CHARS) throw new Error("browser evaluation result contains an oversized string");
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
if (value.length > MAX_RESULT_ARRAY_ITEMS) throw new Error("browser evaluation result contains an oversized array");
|
|
116
|
+
return value.map((entry) => cloneJson(entry, depth + 1, state));
|
|
117
|
+
}
|
|
118
|
+
if (typeof value !== "object") throw new Error("browser evaluation result contains a non-JSON value");
|
|
119
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
120
|
+
if (UNSAFE_KEYS.has(key) || key.length < 1 || key.length > 256) throw new Error("browser evaluation result contains an unsafe property name");
|
|
121
|
+
cloneJson(nested, depth + 1, state);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function containsControlCharacter(value) {
|
|
126
|
+
return Array.from(value).some((character) => {
|
|
127
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
128
|
+
return codePoint < 32 || codePoint === 127;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
3
132
|
//#region src/browser-session.ts
|
|
4
133
|
/**
|
|
5
134
|
* BrowserSession — owns the single headless Chrome instance the agent
|
|
@@ -29,50 +158,79 @@ var BrowserSession = class {
|
|
|
29
158
|
launching = null;
|
|
30
159
|
holds = 0;
|
|
31
160
|
idleTimer = null;
|
|
161
|
+
generation = 0;
|
|
162
|
+
preparedPages = /* @__PURE__ */ new WeakSet();
|
|
163
|
+
activePageListeners = /* @__PURE__ */ new Set();
|
|
32
164
|
log;
|
|
33
165
|
idleMs;
|
|
34
166
|
constructor(options) {
|
|
35
167
|
this.options = options;
|
|
36
168
|
this.log = options.logger ?? noopLogger$2;
|
|
37
|
-
this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
|
|
169
|
+
this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
|
|
38
170
|
}
|
|
39
171
|
/** Launch Chrome if not already running (idempotent, concurrent-safe). */
|
|
40
172
|
async ensureLaunched() {
|
|
41
173
|
if (this.browser) return;
|
|
42
174
|
if (this.launching) return this.launching;
|
|
43
|
-
|
|
44
|
-
|
|
175
|
+
const generation = ++this.generation;
|
|
176
|
+
const launching = this.doLaunch(generation).finally(() => {
|
|
177
|
+
if (this.launching === launching) this.launching = null;
|
|
45
178
|
});
|
|
46
|
-
|
|
179
|
+
this.launching = launching;
|
|
180
|
+
return launching;
|
|
47
181
|
}
|
|
48
|
-
async doLaunch() {
|
|
182
|
+
async doLaunch(generation) {
|
|
49
183
|
const args = [
|
|
50
184
|
"--disable-blink-features=AutomationControlled",
|
|
51
185
|
...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
|
|
52
|
-
...this.options.extraArgs ?? []
|
|
186
|
+
...validateChromeArgs(this.options.extraArgs ?? [])
|
|
53
187
|
];
|
|
54
188
|
this.log.info(`Launching Chrome (${this.options.executablePath})`);
|
|
55
|
-
|
|
189
|
+
const browser = await puppeteer.launch({
|
|
56
190
|
executablePath: this.options.executablePath,
|
|
57
191
|
headless: this.options.headless ?? true,
|
|
58
192
|
userDataDir: this.options.userDataDir,
|
|
59
193
|
args
|
|
60
194
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
195
|
+
try {
|
|
196
|
+
if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
|
|
197
|
+
browser.on("disconnected", () => {
|
|
198
|
+
if (this.browser !== browser) return;
|
|
199
|
+
this.generation += 1;
|
|
200
|
+
this.browser = null;
|
|
201
|
+
this.activePage = null;
|
|
202
|
+
});
|
|
203
|
+
const pages = await browser.pages();
|
|
204
|
+
const initialPage = pages[0] ?? await browser.newPage();
|
|
205
|
+
await Promise.all(pages.map((page) => this.preparePage(page)));
|
|
206
|
+
await this.preparePage(initialPage);
|
|
207
|
+
if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
|
|
208
|
+
this.browser = browser;
|
|
209
|
+
this.setActivePage(initialPage);
|
|
210
|
+
browser.on("targetcreated", (target) => {
|
|
211
|
+
if (this.browser !== browser || target.type() !== TargetType.PAGE) return;
|
|
212
|
+
target.page().then(async (page) => {
|
|
213
|
+
if (!page || this.browser !== browser) return;
|
|
214
|
+
await this.preparePage(page);
|
|
215
|
+
if (this.adoptIfNavigable(page)) return;
|
|
216
|
+
page.on("framenavigated", (frame) => {
|
|
217
|
+
if (this.browser === browser && frame === page.mainFrame()) this.adoptIfNavigable(page);
|
|
218
|
+
});
|
|
219
|
+
}).catch(() => {});
|
|
220
|
+
});
|
|
221
|
+
browser.on("targetdestroyed", (target) => {
|
|
222
|
+
const closedPage = this.activePage;
|
|
223
|
+
if (this.browser !== browser || !closedPage || target.type() !== TargetType.PAGE) return;
|
|
224
|
+
target.page().then(async (page) => {
|
|
225
|
+
if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
|
|
226
|
+
}).catch(() => {
|
|
227
|
+
this.log.warn("Could not restore the browser page after a popup closed");
|
|
69
228
|
});
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
await browser.close().catch(() => void 0);
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
76
234
|
}
|
|
77
235
|
/**
|
|
78
236
|
* Adopt `page` as the streamed active page iff it has a real navigable
|
|
@@ -82,18 +240,98 @@ var BrowserSession = class {
|
|
|
82
240
|
adoptIfNavigable(page) {
|
|
83
241
|
if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
|
|
84
242
|
if (this.activePage === page) return true;
|
|
85
|
-
this.
|
|
243
|
+
this.setActivePage(page);
|
|
86
244
|
this.log.debug("Active page switched to new target");
|
|
87
245
|
return true;
|
|
88
246
|
}
|
|
247
|
+
/** A login popup can close while the agent is parked for human control.
|
|
248
|
+
* Recover here, without waiting for another automation call to discover
|
|
249
|
+
* that the active page is closed, and rebind the viewer to its opener. */
|
|
250
|
+
async restoreAfterClose(browser, closedPage, openerTarget) {
|
|
251
|
+
let opener = await openerTarget?.page();
|
|
252
|
+
while (this.browser === browser && this.activePage === closedPage) {
|
|
253
|
+
const pages = await browser.pages();
|
|
254
|
+
if (this.browser !== browser || this.activePage !== closedPage) return;
|
|
255
|
+
const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
|
|
256
|
+
opener = null;
|
|
257
|
+
try {
|
|
258
|
+
await this.preparePage(page);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (page.isClosed()) continue;
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
if (page.isClosed()) continue;
|
|
264
|
+
if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
89
268
|
/** The current active page, launching Chrome first if needed. */
|
|
90
269
|
async getActivePage() {
|
|
91
270
|
await this.ensureLaunched();
|
|
92
271
|
if (!this.activePage || this.activePage.isClosed()) {
|
|
93
272
|
if (!this.browser) throw new Error("Browser not available");
|
|
94
|
-
|
|
273
|
+
const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
|
|
274
|
+
await this.preparePage(page);
|
|
275
|
+
this.setActivePage(page);
|
|
276
|
+
}
|
|
277
|
+
const activePage = this.activePage;
|
|
278
|
+
if (activePage === null) throw new Error("Browser page not available");
|
|
279
|
+
return activePage;
|
|
280
|
+
}
|
|
281
|
+
onActivePageChange(listener) {
|
|
282
|
+
this.activePageListeners.add(listener);
|
|
283
|
+
return () => {
|
|
284
|
+
this.activePageListeners.delete(listener);
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
/** Internal to the serialized automation turn. The caller holds Chrome until
|
|
288
|
+
* the adapter has disconnected its client/awaited its child process exit. */
|
|
289
|
+
async withCdpTarget(operation, signal) {
|
|
290
|
+
signal.throwIfAborted();
|
|
291
|
+
const page = await this.getActivePage();
|
|
292
|
+
const browser = this.browser;
|
|
293
|
+
const generation = this.generation;
|
|
294
|
+
if (!browser) throw new Error("Browser is unavailable");
|
|
295
|
+
const disconnected = new AbortController();
|
|
296
|
+
const abort = () => {
|
|
297
|
+
disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
|
|
298
|
+
};
|
|
299
|
+
const operationSignal = AbortSignal.any([signal, disconnected.signal]);
|
|
300
|
+
const assertCurrent = () => {
|
|
301
|
+
operationSignal.throwIfAborted();
|
|
302
|
+
if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
|
|
303
|
+
};
|
|
304
|
+
browser.on("disconnected", abort);
|
|
305
|
+
page.on("close", abort);
|
|
306
|
+
try {
|
|
307
|
+
assertCurrent();
|
|
308
|
+
const cdp = await page.createCDPSession();
|
|
309
|
+
let targetId;
|
|
310
|
+
try {
|
|
311
|
+
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
|
312
|
+
targetId = targetInfo.targetId;
|
|
313
|
+
} finally {
|
|
314
|
+
await cdp.detach().catch(() => void 0);
|
|
315
|
+
}
|
|
316
|
+
assertCurrent();
|
|
317
|
+
const browserWSEndpoint = browser.wsEndpoint();
|
|
318
|
+
const endpoint = new URL(browserWSEndpoint);
|
|
319
|
+
if (endpoint.protocol !== "ws:" || ![
|
|
320
|
+
"127.0.0.1",
|
|
321
|
+
"[::1]",
|
|
322
|
+
"localhost"
|
|
323
|
+
].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
|
|
324
|
+
const result = await operation({
|
|
325
|
+
browserWSEndpoint,
|
|
326
|
+
targetId,
|
|
327
|
+
signal: operationSignal
|
|
328
|
+
});
|
|
329
|
+
assertCurrent();
|
|
330
|
+
return result;
|
|
331
|
+
} finally {
|
|
332
|
+
browser.off("disconnected", abort);
|
|
333
|
+
page.off("close", abort);
|
|
95
334
|
}
|
|
96
|
-
return this.activePage;
|
|
97
335
|
}
|
|
98
336
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
99
337
|
addHold() {
|
|
@@ -128,6 +366,8 @@ var BrowserSession = class {
|
|
|
128
366
|
}
|
|
129
367
|
async shutdown() {
|
|
130
368
|
this.clearIdleTimer();
|
|
369
|
+
this.generation += 1;
|
|
370
|
+
this.launching = null;
|
|
131
371
|
const browser = this.browser;
|
|
132
372
|
this.browser = null;
|
|
133
373
|
this.activePage = null;
|
|
@@ -135,6 +375,40 @@ var BrowserSession = class {
|
|
|
135
375
|
await browser.close();
|
|
136
376
|
} catch {}
|
|
137
377
|
}
|
|
378
|
+
setActivePage(page) {
|
|
379
|
+
if (this.activePage === page) return;
|
|
380
|
+
this.activePage = page;
|
|
381
|
+
for (const listener of this.activePageListeners) try {
|
|
382
|
+
Promise.resolve(listener(page)).catch((error) => {
|
|
383
|
+
this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
384
|
+
});
|
|
385
|
+
} catch (error) {
|
|
386
|
+
this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
async preparePage(page) {
|
|
390
|
+
if (this.preparedPages.has(page)) return;
|
|
391
|
+
this.preparedPages.add(page);
|
|
392
|
+
const policy = this.options.isNavigationAllowed;
|
|
393
|
+
if (policy === void 0) return;
|
|
394
|
+
try {
|
|
395
|
+
await page.setRequestInterception(true);
|
|
396
|
+
page.on("request", (request) => {
|
|
397
|
+
if (request.isInterceptResolutionHandled()) return;
|
|
398
|
+
let allowed = false;
|
|
399
|
+
try {
|
|
400
|
+
allowed = policy(request.url());
|
|
401
|
+
} catch {}
|
|
402
|
+
(allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
|
|
403
|
+
});
|
|
404
|
+
} catch (error) {
|
|
405
|
+
this.preparedPages.delete(page);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
isLaunchCurrent(generation, browser) {
|
|
410
|
+
return generation === this.generation && browser.connected;
|
|
411
|
+
}
|
|
138
412
|
};
|
|
139
413
|
//#endregion
|
|
140
414
|
//#region src/screencast-pump.ts
|
|
@@ -149,6 +423,12 @@ var ScreencastPump = class {
|
|
|
149
423
|
running = false;
|
|
150
424
|
frameSeq = 0;
|
|
151
425
|
outstanding = null;
|
|
426
|
+
generation = 0;
|
|
427
|
+
viewport = {
|
|
428
|
+
width: 1280,
|
|
429
|
+
height: 720,
|
|
430
|
+
dpr: 1
|
|
431
|
+
};
|
|
152
432
|
log;
|
|
153
433
|
quality;
|
|
154
434
|
ackTimeoutMs;
|
|
@@ -157,6 +437,8 @@ var ScreencastPump = class {
|
|
|
157
437
|
this.log = options.logger ?? noopLogger$1;
|
|
158
438
|
this.quality = options.quality ?? 70;
|
|
159
439
|
this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
|
|
440
|
+
if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
|
|
441
|
+
if (!Number.isInteger(this.ackTimeoutMs) || this.ackTimeoutMs < 100 || this.ackTimeoutMs > 3e4) throw new Error("Screencast ack timeout must be an integer from 100 to 30000ms");
|
|
160
442
|
}
|
|
161
443
|
get isRunning() {
|
|
162
444
|
return this.running;
|
|
@@ -164,33 +446,60 @@ var ScreencastPump = class {
|
|
|
164
446
|
/** Start (or restart) the screencast on the given page. */
|
|
165
447
|
async start(page, viewport) {
|
|
166
448
|
await this.stop();
|
|
449
|
+
const generation = ++this.generation;
|
|
450
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
167
451
|
const cdp = await page.createCDPSession();
|
|
452
|
+
if (generation !== this.generation) {
|
|
453
|
+
await cdp.detach().catch(() => void 0);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
168
456
|
this.cdp = cdp;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
457
|
+
try {
|
|
458
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
459
|
+
width: this.viewport.width,
|
|
460
|
+
height: this.viewport.height,
|
|
461
|
+
deviceScaleFactor: this.viewport.dpr,
|
|
462
|
+
mobile: false
|
|
463
|
+
});
|
|
464
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
465
|
+
cdp.on("Page.screencastFrame", (event) => {
|
|
466
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
467
|
+
this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
|
|
468
|
+
});
|
|
469
|
+
await cdp.send("Page.startScreencast", {
|
|
470
|
+
format: "jpeg",
|
|
471
|
+
quality: this.quality,
|
|
472
|
+
maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
|
|
473
|
+
maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
|
|
474
|
+
everyNthFrame: 1
|
|
475
|
+
});
|
|
476
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
477
|
+
this.running = true;
|
|
478
|
+
this.log.debug("Screencast started");
|
|
479
|
+
} catch (error) {
|
|
480
|
+
if (this.cdp === cdp) this.cdp = null;
|
|
481
|
+
await cdp.detach().catch(() => void 0);
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
187
484
|
}
|
|
188
|
-
onCdpFrame(dataBase64, cdpSessionId, metadata) {
|
|
485
|
+
onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
|
|
189
486
|
if (this.outstanding) this.ackCdp(this.outstanding);
|
|
190
|
-
|
|
487
|
+
if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
|
|
488
|
+
cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
|
|
489
|
+
this.log.warn("Dropped oversized browser screencast frame");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const jpeg = Buffer.from(dataBase64, "base64");
|
|
493
|
+
if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
|
|
494
|
+
cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
|
|
495
|
+
this.log.warn("Dropped invalid browser screencast frame");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
|
|
499
|
+
const frameSeq = this.frameSeq;
|
|
191
500
|
const meta = {
|
|
192
|
-
deviceWidth: metadata.deviceWidth ??
|
|
193
|
-
deviceHeight: metadata.deviceHeight ??
|
|
501
|
+
deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
|
|
502
|
+
deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
|
|
194
503
|
frameSeq,
|
|
195
504
|
offsetTop: metadata.offsetTop,
|
|
196
505
|
pageScaleFactor: metadata.pageScaleFactor,
|
|
@@ -202,11 +511,19 @@ var ScreencastPump = class {
|
|
|
202
511
|
}, this.ackTimeoutMs);
|
|
203
512
|
timer.unref();
|
|
204
513
|
this.outstanding = {
|
|
514
|
+
cdp,
|
|
515
|
+
generation,
|
|
205
516
|
cdpSessionId,
|
|
206
517
|
frameSeq,
|
|
207
518
|
timer
|
|
208
519
|
};
|
|
209
|
-
|
|
520
|
+
try {
|
|
521
|
+
this.options.onFrame(jpeg, meta);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
524
|
+
const outstanding = this.outstanding;
|
|
525
|
+
if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
|
|
526
|
+
}
|
|
210
527
|
}
|
|
211
528
|
/** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
|
|
212
529
|
ackFromViewer(frameSeq) {
|
|
@@ -215,9 +532,10 @@ var ScreencastPump = class {
|
|
|
215
532
|
ackCdp(outstanding) {
|
|
216
533
|
clearTimeout(outstanding.timer);
|
|
217
534
|
this.outstanding = null;
|
|
218
|
-
if (this.cdp && this.running
|
|
535
|
+
if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
|
|
219
536
|
}
|
|
220
537
|
async stop() {
|
|
538
|
+
this.generation += 1;
|
|
221
539
|
this.running = false;
|
|
222
540
|
if (this.outstanding) {
|
|
223
541
|
clearTimeout(this.outstanding.timer);
|
|
@@ -242,17 +560,64 @@ const MOUSE_TYPE = {
|
|
|
242
560
|
mousepressed: "mousePressed",
|
|
243
561
|
mousereleased: "mouseReleased"
|
|
244
562
|
};
|
|
563
|
+
const VIRTUAL_KEYS = {
|
|
564
|
+
Backspace: 8,
|
|
565
|
+
Tab: 9,
|
|
566
|
+
Enter: 13,
|
|
567
|
+
NumpadEnter: 13,
|
|
568
|
+
Shift: 16,
|
|
569
|
+
Control: 17,
|
|
570
|
+
Alt: 18,
|
|
571
|
+
Pause: 19,
|
|
572
|
+
CapsLock: 20,
|
|
573
|
+
Escape: 27,
|
|
574
|
+
Space: 32,
|
|
575
|
+
" ": 32,
|
|
576
|
+
PageUp: 33,
|
|
577
|
+
PageDown: 34,
|
|
578
|
+
End: 35,
|
|
579
|
+
Home: 36,
|
|
580
|
+
ArrowLeft: 37,
|
|
581
|
+
ArrowUp: 38,
|
|
582
|
+
ArrowRight: 39,
|
|
583
|
+
ArrowDown: 40,
|
|
584
|
+
Insert: 45,
|
|
585
|
+
Delete: 46,
|
|
586
|
+
Meta: 91
|
|
587
|
+
};
|
|
588
|
+
function virtualKeyCode(input) {
|
|
589
|
+
const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
|
|
590
|
+
if (typeof named === "number") return named;
|
|
591
|
+
const letter = /^Key([A-Z])$/.exec(input.code ?? "");
|
|
592
|
+
if (letter) return letter[1].charCodeAt(0);
|
|
593
|
+
const digit = /^Digit([0-9])$/.exec(input.code ?? "");
|
|
594
|
+
if (digit) return digit[1].charCodeAt(0);
|
|
595
|
+
const key = input.key ?? "";
|
|
596
|
+
if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
|
|
597
|
+
const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
|
|
598
|
+
if (functionKey) return 111 + Number(functionKey[1]);
|
|
599
|
+
}
|
|
245
600
|
var InputInjector = class {
|
|
246
601
|
cdp = null;
|
|
247
602
|
viewport;
|
|
603
|
+
generation = 0;
|
|
248
604
|
constructor(viewport) {
|
|
249
|
-
this.viewport = viewport;
|
|
605
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
250
606
|
}
|
|
251
607
|
async attach(page) {
|
|
252
|
-
|
|
253
|
-
|
|
608
|
+
const generation = ++this.generation;
|
|
609
|
+
const old = this.cdp;
|
|
610
|
+
this.cdp = null;
|
|
611
|
+
if (old) await old.detach().catch(() => void 0);
|
|
612
|
+
const cdp = await page.createCDPSession();
|
|
613
|
+
if (generation !== this.generation) {
|
|
614
|
+
await cdp.detach().catch(() => void 0);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this.cdp = cdp;
|
|
254
618
|
}
|
|
255
619
|
async detach() {
|
|
620
|
+
this.generation += 1;
|
|
256
621
|
const cdp = this.cdp;
|
|
257
622
|
this.cdp = null;
|
|
258
623
|
if (cdp) try {
|
|
@@ -260,12 +625,12 @@ var InputInjector = class {
|
|
|
260
625
|
} catch {}
|
|
261
626
|
}
|
|
262
627
|
updateViewport(viewport) {
|
|
263
|
-
this.viewport = viewport;
|
|
628
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
264
629
|
}
|
|
265
630
|
toCssPx(nx, ny) {
|
|
266
631
|
return {
|
|
267
|
-
x: Math.round(clamp01(nx) * this.viewport.width),
|
|
268
|
-
y: Math.round(clamp01(ny) * this.viewport.height)
|
|
632
|
+
x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
|
|
633
|
+
y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
|
|
269
634
|
};
|
|
270
635
|
}
|
|
271
636
|
mouse(p) {
|
|
@@ -296,19 +661,18 @@ var InputInjector = class {
|
|
|
296
661
|
key(p) {
|
|
297
662
|
if (!this.cdp) return;
|
|
298
663
|
if (p.type === "char") {
|
|
299
|
-
this.cdp.send("Input.
|
|
300
|
-
type: "char",
|
|
301
|
-
text: p.text ?? "",
|
|
302
|
-
modifiers: p.modifiers ?? 0
|
|
303
|
-
}).catch(() => {});
|
|
664
|
+
this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
|
|
304
665
|
return;
|
|
305
666
|
}
|
|
667
|
+
const modifiers = p.modifiers ?? 0;
|
|
668
|
+
const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
|
|
306
669
|
this.cdp.send("Input.dispatchKeyEvent", {
|
|
307
|
-
type: p.type === "
|
|
670
|
+
type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
|
|
308
671
|
key: p.key,
|
|
309
672
|
code: p.code,
|
|
310
|
-
text
|
|
311
|
-
|
|
673
|
+
text,
|
|
674
|
+
windowsVirtualKeyCode: virtualKeyCode(p),
|
|
675
|
+
modifiers
|
|
312
676
|
}).catch(() => {});
|
|
313
677
|
}
|
|
314
678
|
};
|
|
@@ -318,64 +682,129 @@ function clamp01(n) {
|
|
|
318
682
|
}
|
|
319
683
|
//#endregion
|
|
320
684
|
//#region src/automation.ts
|
|
685
|
+
const OPERATION_TIMEOUT_MS = 3e4;
|
|
321
686
|
var BrowserAutomation = class {
|
|
687
|
+
operationTail = Promise.resolve();
|
|
688
|
+
stopping = new AbortController();
|
|
322
689
|
constructor(session, turn, isNavigationAllowed) {
|
|
323
690
|
this.session = session;
|
|
324
691
|
this.turn = turn;
|
|
325
692
|
this.isNavigationAllowed = isNavigationAllowed;
|
|
326
693
|
}
|
|
327
694
|
async navigate(url) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
695
|
+
return this.run(async () => {
|
|
696
|
+
const target = validateNavigationUrl(url);
|
|
697
|
+
let allowed = false;
|
|
698
|
+
try {
|
|
699
|
+
allowed = this.isNavigationAllowed(target);
|
|
700
|
+
} catch {}
|
|
701
|
+
if (!allowed) throw new Error("Navigation blocked by browser policy");
|
|
702
|
+
const page = await this.session.getActivePage();
|
|
703
|
+
await page.goto(target, {
|
|
704
|
+
waitUntil: "domcontentloaded",
|
|
705
|
+
timeout: OPERATION_TIMEOUT_MS
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
url: page.url(),
|
|
709
|
+
title: await page.title()
|
|
710
|
+
};
|
|
711
|
+
});
|
|
336
712
|
}
|
|
337
713
|
async click(selector) {
|
|
338
|
-
await this.
|
|
339
|
-
|
|
714
|
+
await this.run(async () => {
|
|
715
|
+
await (await this.session.getActivePage()).click(validateSelector(selector));
|
|
716
|
+
});
|
|
340
717
|
}
|
|
341
718
|
async type(selector, text) {
|
|
342
|
-
await this.
|
|
343
|
-
|
|
719
|
+
await this.run(async () => {
|
|
720
|
+
await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
|
|
721
|
+
});
|
|
344
722
|
}
|
|
345
723
|
async waitFor(opts) {
|
|
346
|
-
await this.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await
|
|
352
|
-
|
|
724
|
+
await this.run(async () => {
|
|
725
|
+
const normalized = validateWaitOptions(opts);
|
|
726
|
+
const page = await this.session.getActivePage();
|
|
727
|
+
if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
|
|
728
|
+
else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
|
|
729
|
+
else await new Promise((resolve) => {
|
|
730
|
+
setTimeout(resolve, normalized.ms ?? 0).unref();
|
|
731
|
+
});
|
|
732
|
+
});
|
|
353
733
|
}
|
|
354
734
|
/** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
|
|
355
735
|
* from the continuous screencast stream to viewers. */
|
|
356
736
|
async screenshot() {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
737
|
+
return this.run(async () => {
|
|
738
|
+
const image = await (await this.session.getActivePage()).screenshot({
|
|
739
|
+
type: "jpeg",
|
|
740
|
+
quality: 70,
|
|
741
|
+
encoding: "base64"
|
|
742
|
+
});
|
|
743
|
+
if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
|
|
744
|
+
return image;
|
|
362
745
|
});
|
|
363
746
|
}
|
|
364
747
|
/** Evaluate an expression in the page context via CDP (no eval on our side). */
|
|
365
748
|
async evaluate(expression) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
return (await cdp.send("Runtime.evaluate", {
|
|
370
|
-
expression,
|
|
371
|
-
returnByValue: true,
|
|
372
|
-
awaitPromise: true
|
|
373
|
-
})).result.value;
|
|
374
|
-
} finally {
|
|
749
|
+
return this.run(async () => {
|
|
750
|
+
const cdp = await (await this.session.getActivePage()).createCDPSession();
|
|
375
751
|
try {
|
|
376
|
-
await cdp.
|
|
377
|
-
|
|
378
|
-
|
|
752
|
+
const res = await cdp.send("Runtime.evaluate", {
|
|
753
|
+
expression: validateExpression(expression),
|
|
754
|
+
returnByValue: true,
|
|
755
|
+
awaitPromise: true,
|
|
756
|
+
timeout: OPERATION_TIMEOUT_MS,
|
|
757
|
+
disableBreaks: true
|
|
758
|
+
});
|
|
759
|
+
if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
|
|
760
|
+
return assertBoundedAutomationResult(res.result.value ?? null);
|
|
761
|
+
} finally {
|
|
762
|
+
try {
|
|
763
|
+
await cdp.detach();
|
|
764
|
+
} catch {}
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
/** Wait until all agent operations that were already queued have settled. */
|
|
769
|
+
async waitUntilIdle() {
|
|
770
|
+
await this.operationTail;
|
|
771
|
+
}
|
|
772
|
+
/** Trusted local adapters share the built-in automation queue and exact page.
|
|
773
|
+
* The callback must disconnect/await children in finally, including on abort.
|
|
774
|
+
* Do not invoke another automation operation or handoff from the callback. */
|
|
775
|
+
withCdpOperation(operation, options = {}) {
|
|
776
|
+
return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
|
|
777
|
+
}
|
|
778
|
+
/** Insert the claim at a precise queue position; later automation parks. */
|
|
779
|
+
async yieldToHuman(grant) {
|
|
780
|
+
await this.run(() => {
|
|
781
|
+
grant();
|
|
782
|
+
return Promise.resolve();
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
/** Abort first, then await callback cleanup before the owner closes Chrome. */
|
|
786
|
+
async shutdown() {
|
|
787
|
+
this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
|
|
788
|
+
this.turn.releaseHuman();
|
|
789
|
+
await this.operationTail;
|
|
790
|
+
}
|
|
791
|
+
run(operation, options = {}) {
|
|
792
|
+
const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
|
|
793
|
+
const result = this.operationTail.catch(() => void 0).then(async () => {
|
|
794
|
+
signal.throwIfAborted();
|
|
795
|
+
this.session.addHold();
|
|
796
|
+
try {
|
|
797
|
+
await this.turn.acquireAgent(signal);
|
|
798
|
+
signal.throwIfAborted();
|
|
799
|
+
const result = await operation(signal);
|
|
800
|
+
signal.throwIfAborted();
|
|
801
|
+
return result;
|
|
802
|
+
} finally {
|
|
803
|
+
this.session.removeHold();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
this.operationTail = result.then(() => void 0, () => void 0);
|
|
807
|
+
return result;
|
|
379
808
|
}
|
|
380
809
|
};
|
|
381
810
|
//#endregion
|
|
@@ -410,12 +839,29 @@ var BrowserSurface = class {
|
|
|
410
839
|
dpr: 1
|
|
411
840
|
};
|
|
412
841
|
handoff = null;
|
|
842
|
+
controllerSessionId = null;
|
|
843
|
+
pendingControllerSessionId = null;
|
|
844
|
+
claimGeneration = 0;
|
|
845
|
+
streamGeneration = 0;
|
|
846
|
+
streamQueue = Promise.resolve();
|
|
847
|
+
closed = false;
|
|
848
|
+
removePageListener;
|
|
413
849
|
log;
|
|
414
850
|
constructor(options, sendFrame) {
|
|
415
|
-
this.options = options;
|
|
416
851
|
this.sendFrame = sendFrame;
|
|
417
852
|
this.log = options.logger ?? noopLogger;
|
|
418
|
-
|
|
853
|
+
const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
|
|
854
|
+
try {
|
|
855
|
+
validateNavigationUrl(url);
|
|
856
|
+
return true;
|
|
857
|
+
} catch {
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
this.session = new BrowserSession({
|
|
862
|
+
...options,
|
|
863
|
+
isNavigationAllowed: navigationPolicy
|
|
864
|
+
});
|
|
419
865
|
this.turn = new TurnController({ onOwnerChange: () => {
|
|
420
866
|
this.broadcastState();
|
|
421
867
|
} });
|
|
@@ -426,56 +872,93 @@ var BrowserSurface = class {
|
|
|
426
872
|
logger: this.log
|
|
427
873
|
});
|
|
428
874
|
this.injector = new InputInjector(this.viewport);
|
|
429
|
-
this.automation = new BrowserAutomation(this.session, this.turn,
|
|
875
|
+
this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
|
|
876
|
+
this.removePageListener = this.session.onActivePageChange(async () => {
|
|
877
|
+
if (this.viewers.size > 0) {
|
|
878
|
+
await this.restartStreaming();
|
|
879
|
+
this.broadcastState();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
430
882
|
}
|
|
431
883
|
async openSession(sessionId, open) {
|
|
884
|
+
if (this.closed) throw new Error("Browser surface is shut down");
|
|
885
|
+
if (this.viewers.has(sessionId)) {
|
|
886
|
+
this.sendState(sessionId);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const firstViewer = this.viewers.size === 0;
|
|
432
890
|
this.viewers.add(sessionId);
|
|
433
891
|
this.session.addHold();
|
|
434
|
-
|
|
435
|
-
width
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
892
|
+
try {
|
|
893
|
+
if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
|
|
894
|
+
if (firstViewer) await this.restartStreaming();
|
|
895
|
+
this.sendState(sessionId);
|
|
896
|
+
} catch (error) {
|
|
897
|
+
this.viewers.delete(sessionId);
|
|
898
|
+
this.session.removeHold();
|
|
899
|
+
if (this.viewers.size === 0) {
|
|
900
|
+
this.streamGeneration += 1;
|
|
901
|
+
this.enqueueStreamCleanup();
|
|
902
|
+
}
|
|
903
|
+
throw error;
|
|
904
|
+
}
|
|
441
905
|
}
|
|
442
906
|
handleFrame(frame) {
|
|
907
|
+
if (this.closed || !this.viewers.has(frame.sessionId)) return;
|
|
443
908
|
switch (frame.type) {
|
|
444
909
|
case RemoteFrameType.SCREENCAST_ACK: {
|
|
445
|
-
const ack =
|
|
910
|
+
const ack = decodeScreencastAckPayload(frame.payload);
|
|
446
911
|
if (ack) this.pump.ackFromViewer(ack.frameSeq);
|
|
447
912
|
this.session.touch();
|
|
448
913
|
break;
|
|
449
914
|
}
|
|
450
915
|
case RemoteFrameType.RESIZE: {
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
916
|
+
const resize = decodeResizePayload(frame.payload);
|
|
917
|
+
if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
|
|
453
918
|
break;
|
|
454
919
|
}
|
|
455
920
|
case RemoteFrameType.INPUT_MOUSE:
|
|
456
|
-
if (this.turn.humanInControl) {
|
|
457
|
-
const
|
|
458
|
-
if (
|
|
921
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
922
|
+
const input = decodeMouseInputPayload(frame.payload);
|
|
923
|
+
if (input) this.injector.mouse(input);
|
|
459
924
|
}
|
|
460
925
|
break;
|
|
461
926
|
case RemoteFrameType.INPUT_WHEEL:
|
|
462
|
-
if (this.turn.humanInControl) {
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
927
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
928
|
+
const input = decodeWheelInputPayload(frame.payload);
|
|
929
|
+
if (input) this.injector.wheel(input);
|
|
465
930
|
}
|
|
466
931
|
break;
|
|
467
932
|
case RemoteFrameType.INPUT_KEY:
|
|
468
|
-
if (this.turn.humanInControl) {
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
933
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
934
|
+
const input = decodeKeyInputPayload(frame.payload);
|
|
935
|
+
if (input) this.injector.key(input);
|
|
471
936
|
}
|
|
472
937
|
break;
|
|
473
|
-
case RemoteFrameType.TAKEOVER_REQUEST:
|
|
474
|
-
if (this.
|
|
475
|
-
|
|
938
|
+
case RemoteFrameType.TAKEOVER_REQUEST: {
|
|
939
|
+
if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
|
|
940
|
+
this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
this.pendingControllerSessionId = frame.sessionId;
|
|
944
|
+
const claimGeneration = ++this.claimGeneration;
|
|
945
|
+
this.automation.yieldToHuman(() => {
|
|
946
|
+
if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
|
|
947
|
+
this.pendingControllerSessionId = null;
|
|
948
|
+
if (this.turn.grantHuman()) {
|
|
949
|
+
this.controllerSessionId = frame.sessionId;
|
|
950
|
+
this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
|
|
951
|
+
} else this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
952
|
+
}).catch((error) => {
|
|
953
|
+
if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
|
|
954
|
+
this.pendingControllerSessionId = null;
|
|
955
|
+
this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
|
|
956
|
+
this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
957
|
+
});
|
|
476
958
|
break;
|
|
959
|
+
}
|
|
477
960
|
case RemoteFrameType.RELEASE_CONTROL:
|
|
478
|
-
this.releaseToAgent();
|
|
961
|
+
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
479
962
|
break;
|
|
480
963
|
default: break;
|
|
481
964
|
}
|
|
@@ -483,9 +966,14 @@ var BrowserSurface = class {
|
|
|
483
966
|
closeSession(sessionId) {
|
|
484
967
|
if (!this.viewers.delete(sessionId)) return;
|
|
485
968
|
this.session.removeHold();
|
|
969
|
+
if (this.pendingControllerSessionId === sessionId) {
|
|
970
|
+
this.pendingControllerSessionId = null;
|
|
971
|
+
this.claimGeneration += 1;
|
|
972
|
+
}
|
|
973
|
+
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
486
974
|
if (this.viewers.size === 0) {
|
|
487
|
-
this.
|
|
488
|
-
|
|
975
|
+
this.streamGeneration += 1;
|
|
976
|
+
this.enqueueStreamCleanup();
|
|
489
977
|
}
|
|
490
978
|
}
|
|
491
979
|
/**
|
|
@@ -503,17 +991,28 @@ var BrowserSurface = class {
|
|
|
503
991
|
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
504
992
|
* the tab closed).
|
|
505
993
|
*/
|
|
506
|
-
async requestHandoff(timeoutMs) {
|
|
994
|
+
async requestHandoff(timeoutMs, signal) {
|
|
995
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
996
|
+
if (this.closed) throw new Error("Browser surface is shut down");
|
|
997
|
+
signal?.throwIfAborted();
|
|
507
998
|
if (this.handoff) this.releaseToAgent();
|
|
508
|
-
return new Promise((resolve) => {
|
|
999
|
+
return new Promise((resolve, reject) => {
|
|
1000
|
+
const settle = (result) => {
|
|
1001
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1002
|
+
resolve(result);
|
|
1003
|
+
};
|
|
509
1004
|
const timer = setTimeout(() => {
|
|
1005
|
+
if (this.handoff !== waiter) return;
|
|
510
1006
|
this.handoff = null;
|
|
1007
|
+
this.pendingControllerSessionId = null;
|
|
1008
|
+
this.claimGeneration += 1;
|
|
511
1009
|
if (this.turn.humanInControl) {
|
|
512
1010
|
this.turn.releaseHuman();
|
|
1011
|
+
this.controllerSessionId = null;
|
|
513
1012
|
this.broadcast(RemoteFrameType.CONTROL_REVOKED);
|
|
514
1013
|
}
|
|
515
1014
|
this.currentPageInfo().then(({ url, title }) => {
|
|
516
|
-
|
|
1015
|
+
settle({
|
|
517
1016
|
released: false,
|
|
518
1017
|
timedOut: true,
|
|
519
1018
|
url,
|
|
@@ -522,10 +1021,27 @@ var BrowserSurface = class {
|
|
|
522
1021
|
});
|
|
523
1022
|
}, timeoutMs);
|
|
524
1023
|
timer.unref();
|
|
525
|
-
|
|
526
|
-
resolve,
|
|
1024
|
+
const waiter = {
|
|
1025
|
+
resolve: settle,
|
|
527
1026
|
timer
|
|
528
1027
|
};
|
|
1028
|
+
const onAbort = () => {
|
|
1029
|
+
if (this.handoff !== waiter) return;
|
|
1030
|
+
this.handoff = null;
|
|
1031
|
+
clearTimeout(timer);
|
|
1032
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1033
|
+
this.pendingControllerSessionId = null;
|
|
1034
|
+
this.claimGeneration += 1;
|
|
1035
|
+
if (this.turn.humanInControl) {
|
|
1036
|
+
this.turn.releaseHuman();
|
|
1037
|
+
this.controllerSessionId = null;
|
|
1038
|
+
this.broadcast(RemoteFrameType.CONTROL_REVOKED);
|
|
1039
|
+
}
|
|
1040
|
+
reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
|
|
1041
|
+
};
|
|
1042
|
+
this.handoff = waiter;
|
|
1043
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1044
|
+
if (signal?.aborted) onAbort();
|
|
529
1045
|
});
|
|
530
1046
|
}
|
|
531
1047
|
/**
|
|
@@ -541,11 +1057,33 @@ var BrowserSurface = class {
|
|
|
541
1057
|
this.session.removeHold();
|
|
542
1058
|
}
|
|
543
1059
|
async shutdown() {
|
|
544
|
-
|
|
545
|
-
|
|
1060
|
+
if (this.closed) return;
|
|
1061
|
+
this.closed = true;
|
|
1062
|
+
this.removePageListener();
|
|
1063
|
+
this.streamGeneration += 1;
|
|
1064
|
+
this.pendingControllerSessionId = null;
|
|
1065
|
+
this.claimGeneration += 1;
|
|
1066
|
+
this.controllerSessionId = null;
|
|
1067
|
+
const waiter = this.handoff;
|
|
1068
|
+
this.handoff = null;
|
|
1069
|
+
if (waiter) {
|
|
1070
|
+
clearTimeout(waiter.timer);
|
|
1071
|
+
waiter.resolve({
|
|
1072
|
+
released: false,
|
|
1073
|
+
timedOut: true,
|
|
1074
|
+
url: "",
|
|
1075
|
+
title: ""
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
this.turn.releaseHuman();
|
|
1079
|
+
await this.automation.shutdown();
|
|
1080
|
+
await this.enqueueStreamCleanup();
|
|
546
1081
|
await this.session.shutdown();
|
|
547
1082
|
}
|
|
548
1083
|
releaseToAgent() {
|
|
1084
|
+
this.pendingControllerSessionId = null;
|
|
1085
|
+
this.claimGeneration += 1;
|
|
1086
|
+
this.controllerSessionId = null;
|
|
549
1087
|
this.turn.releaseHuman();
|
|
550
1088
|
const waiter = this.handoff;
|
|
551
1089
|
this.handoff = null;
|
|
@@ -561,51 +1099,82 @@ var BrowserSurface = class {
|
|
|
561
1099
|
});
|
|
562
1100
|
}
|
|
563
1101
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1102
|
+
restartStreaming() {
|
|
1103
|
+
const generation = ++this.streamGeneration;
|
|
1104
|
+
const run = this.streamQueue.catch(() => void 0).then(async () => {
|
|
1105
|
+
if (!this.shouldStream(generation)) return;
|
|
1106
|
+
try {
|
|
1107
|
+
const page = await this.session.getActivePage();
|
|
1108
|
+
if (!this.shouldStream(generation)) return;
|
|
1109
|
+
await this.injector.attach(page);
|
|
1110
|
+
this.injector.updateViewport(this.viewport);
|
|
1111
|
+
await this.pump.start(page, this.viewport);
|
|
1112
|
+
if (!this.shouldStream(generation)) {
|
|
1113
|
+
await this.pump.stop();
|
|
1114
|
+
await this.injector.detach();
|
|
1115
|
+
}
|
|
1116
|
+
} catch (error) {
|
|
1117
|
+
await this.pump.stop();
|
|
1118
|
+
await this.injector.detach();
|
|
1119
|
+
throw error;
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
this.streamQueue = run.catch((error) => {
|
|
1123
|
+
this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1124
|
+
});
|
|
1125
|
+
return run;
|
|
1126
|
+
}
|
|
1127
|
+
enqueueStreamCleanup() {
|
|
1128
|
+
const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
|
|
1129
|
+
await this.pump.stop();
|
|
1130
|
+
await this.injector.detach();
|
|
1131
|
+
});
|
|
1132
|
+
this.streamQueue = cleanup.catch(() => void 0);
|
|
1133
|
+
return cleanup;
|
|
1134
|
+
}
|
|
1135
|
+
shouldStream(generation) {
|
|
1136
|
+
return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
|
|
570
1137
|
}
|
|
571
1138
|
async applyResize(width, height, dpr) {
|
|
572
|
-
this.viewport =
|
|
573
|
-
width,
|
|
574
|
-
height,
|
|
575
|
-
dpr
|
|
576
|
-
};
|
|
1139
|
+
this.viewport = normalizeViewport(width, height, dpr);
|
|
577
1140
|
this.injector.updateViewport(this.viewport);
|
|
578
|
-
if (this.
|
|
579
|
-
const page = await this.session.getActivePage();
|
|
580
|
-
await this.pump.start(page, this.viewport);
|
|
581
|
-
}
|
|
1141
|
+
if (this.viewers.size > 0) await this.restartStreaming();
|
|
582
1142
|
}
|
|
583
1143
|
broadcastFrame(jpeg, meta) {
|
|
584
|
-
for (const sessionId of this.viewers)
|
|
1144
|
+
for (const sessionId of this.viewers) try {
|
|
1145
|
+
this.sendFrameSafe(encodeScreencastFrame(sessionId, meta, jpeg));
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1148
|
+
}
|
|
585
1149
|
}
|
|
586
1150
|
broadcast(type) {
|
|
587
|
-
for (const sessionId of this.viewers) this.
|
|
1151
|
+
for (const sessionId of this.viewers) this.sendFrameSafe(encodeFrame(type, sessionId));
|
|
588
1152
|
}
|
|
589
1153
|
broadcastState() {
|
|
590
1154
|
for (const sessionId of this.viewers) this.sendState(sessionId);
|
|
591
1155
|
}
|
|
592
1156
|
sendState(sessionId) {
|
|
593
1157
|
this.currentPageInfo().then(({ url, title }) => {
|
|
1158
|
+
if (!this.viewers.has(sessionId) || this.closed) return;
|
|
594
1159
|
const state = {
|
|
595
1160
|
surface: "browser",
|
|
596
1161
|
url,
|
|
597
1162
|
title,
|
|
598
1163
|
controller: this.turn.currentOwner
|
|
599
1164
|
};
|
|
600
|
-
|
|
1165
|
+
try {
|
|
1166
|
+
this.sendFrameSafe(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1169
|
+
}
|
|
601
1170
|
});
|
|
602
1171
|
}
|
|
603
1172
|
async currentPageInfo() {
|
|
604
1173
|
try {
|
|
605
1174
|
const page = await this.session.getActivePage();
|
|
606
1175
|
return {
|
|
607
|
-
url: page.url(),
|
|
608
|
-
title: await page.title()
|
|
1176
|
+
url: page.url().slice(0, 8192),
|
|
1177
|
+
title: (await page.title()).slice(0, 4096)
|
|
609
1178
|
};
|
|
610
1179
|
} catch {
|
|
611
1180
|
return {
|
|
@@ -614,6 +1183,13 @@ var BrowserSurface = class {
|
|
|
614
1183
|
};
|
|
615
1184
|
}
|
|
616
1185
|
}
|
|
1186
|
+
sendFrameSafe(frame) {
|
|
1187
|
+
try {
|
|
1188
|
+
this.sendFrame(frame);
|
|
1189
|
+
} catch (error) {
|
|
1190
|
+
this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
617
1193
|
};
|
|
618
1194
|
//#endregion
|
|
619
1195
|
export { BrowserAutomation, BrowserSession, BrowserSurface };
|