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