@alfe.ai/browser 0.2.0 → 0.2.1
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 +21 -0
- package/dist/index.cjs +537 -139
- package/dist/index.d.cts +24 -9
- package/dist/index.d.ts +24 -9
- package/dist/index.js +538 -140
- package/package.json +2 -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,70 @@ 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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.browser
|
|
98
|
-
this.
|
|
99
|
-
|
|
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
|
+
} catch (error) {
|
|
246
|
+
await browser.close().catch(() => void 0);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
100
249
|
}
|
|
101
250
|
/**
|
|
102
251
|
* Adopt `page` as the streamed active page iff it has a real navigable
|
|
@@ -106,7 +255,7 @@ var BrowserSession = class {
|
|
|
106
255
|
adoptIfNavigable(page) {
|
|
107
256
|
if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
|
|
108
257
|
if (this.activePage === page) return true;
|
|
109
|
-
this.
|
|
258
|
+
this.setActivePage(page);
|
|
110
259
|
this.log.debug("Active page switched to new target");
|
|
111
260
|
return true;
|
|
112
261
|
}
|
|
@@ -115,9 +264,19 @@ var BrowserSession = class {
|
|
|
115
264
|
await this.ensureLaunched();
|
|
116
265
|
if (!this.activePage || this.activePage.isClosed()) {
|
|
117
266
|
if (!this.browser) throw new Error("Browser not available");
|
|
118
|
-
|
|
267
|
+
const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
|
|
268
|
+
await this.preparePage(page);
|
|
269
|
+
this.setActivePage(page);
|
|
119
270
|
}
|
|
120
|
-
|
|
271
|
+
const activePage = this.activePage;
|
|
272
|
+
if (activePage === null) throw new Error("Browser page not available");
|
|
273
|
+
return activePage;
|
|
274
|
+
}
|
|
275
|
+
onActivePageChange(listener) {
|
|
276
|
+
this.activePageListeners.add(listener);
|
|
277
|
+
return () => {
|
|
278
|
+
this.activePageListeners.delete(listener);
|
|
279
|
+
};
|
|
121
280
|
}
|
|
122
281
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
123
282
|
addHold() {
|
|
@@ -152,6 +311,8 @@ var BrowserSession = class {
|
|
|
152
311
|
}
|
|
153
312
|
async shutdown() {
|
|
154
313
|
this.clearIdleTimer();
|
|
314
|
+
this.generation += 1;
|
|
315
|
+
this.launching = null;
|
|
155
316
|
const browser = this.browser;
|
|
156
317
|
this.browser = null;
|
|
157
318
|
this.activePage = null;
|
|
@@ -159,6 +320,40 @@ var BrowserSession = class {
|
|
|
159
320
|
await browser.close();
|
|
160
321
|
} catch {}
|
|
161
322
|
}
|
|
323
|
+
setActivePage(page) {
|
|
324
|
+
if (this.activePage === page) return;
|
|
325
|
+
this.activePage = page;
|
|
326
|
+
for (const listener of this.activePageListeners) try {
|
|
327
|
+
Promise.resolve(listener(page)).catch((error) => {
|
|
328
|
+
this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
329
|
+
});
|
|
330
|
+
} catch (error) {
|
|
331
|
+
this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async preparePage(page) {
|
|
335
|
+
if (this.preparedPages.has(page)) return;
|
|
336
|
+
this.preparedPages.add(page);
|
|
337
|
+
const policy = this.options.isNavigationAllowed;
|
|
338
|
+
if (policy === void 0) return;
|
|
339
|
+
try {
|
|
340
|
+
await page.setRequestInterception(true);
|
|
341
|
+
page.on("request", (request) => {
|
|
342
|
+
if (request.isInterceptResolutionHandled()) return;
|
|
343
|
+
let allowed = false;
|
|
344
|
+
try {
|
|
345
|
+
allowed = policy(request.url());
|
|
346
|
+
} catch {}
|
|
347
|
+
(allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
|
|
348
|
+
});
|
|
349
|
+
} catch (error) {
|
|
350
|
+
this.preparedPages.delete(page);
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
isLaunchCurrent(generation, browser) {
|
|
355
|
+
return generation === this.generation && browser.connected;
|
|
356
|
+
}
|
|
162
357
|
};
|
|
163
358
|
//#endregion
|
|
164
359
|
//#region src/screencast-pump.ts
|
|
@@ -173,6 +368,12 @@ var ScreencastPump = class {
|
|
|
173
368
|
running = false;
|
|
174
369
|
frameSeq = 0;
|
|
175
370
|
outstanding = null;
|
|
371
|
+
generation = 0;
|
|
372
|
+
viewport = {
|
|
373
|
+
width: 1280,
|
|
374
|
+
height: 720,
|
|
375
|
+
dpr: 1
|
|
376
|
+
};
|
|
176
377
|
log;
|
|
177
378
|
quality;
|
|
178
379
|
ackTimeoutMs;
|
|
@@ -181,6 +382,8 @@ var ScreencastPump = class {
|
|
|
181
382
|
this.log = options.logger ?? noopLogger$1;
|
|
182
383
|
this.quality = options.quality ?? 70;
|
|
183
384
|
this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
|
|
385
|
+
if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
|
|
386
|
+
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
387
|
}
|
|
185
388
|
get isRunning() {
|
|
186
389
|
return this.running;
|
|
@@ -188,33 +391,60 @@ var ScreencastPump = class {
|
|
|
188
391
|
/** Start (or restart) the screencast on the given page. */
|
|
189
392
|
async start(page, viewport) {
|
|
190
393
|
await this.stop();
|
|
394
|
+
const generation = ++this.generation;
|
|
395
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
191
396
|
const cdp = await page.createCDPSession();
|
|
397
|
+
if (generation !== this.generation) {
|
|
398
|
+
await cdp.detach().catch(() => void 0);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
192
401
|
this.cdp = cdp;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
this.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
402
|
+
try {
|
|
403
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
404
|
+
width: this.viewport.width,
|
|
405
|
+
height: this.viewport.height,
|
|
406
|
+
deviceScaleFactor: this.viewport.dpr,
|
|
407
|
+
mobile: false
|
|
408
|
+
});
|
|
409
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
410
|
+
cdp.on("Page.screencastFrame", (event) => {
|
|
411
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
412
|
+
this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
|
|
413
|
+
});
|
|
414
|
+
await cdp.send("Page.startScreencast", {
|
|
415
|
+
format: "jpeg",
|
|
416
|
+
quality: this.quality,
|
|
417
|
+
maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
|
|
418
|
+
maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
|
|
419
|
+
everyNthFrame: 1
|
|
420
|
+
});
|
|
421
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
422
|
+
this.running = true;
|
|
423
|
+
this.log.debug("Screencast started");
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (this.cdp === cdp) this.cdp = null;
|
|
426
|
+
await cdp.detach().catch(() => void 0);
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
211
429
|
}
|
|
212
|
-
onCdpFrame(dataBase64, cdpSessionId, metadata) {
|
|
430
|
+
onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
|
|
213
431
|
if (this.outstanding) this.ackCdp(this.outstanding);
|
|
214
|
-
|
|
432
|
+
if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
|
|
433
|
+
cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
|
|
434
|
+
this.log.warn("Dropped oversized browser screencast frame");
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const jpeg = Buffer.from(dataBase64, "base64");
|
|
438
|
+
if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
|
|
439
|
+
cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
|
|
440
|
+
this.log.warn("Dropped invalid browser screencast frame");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
|
|
444
|
+
const frameSeq = this.frameSeq;
|
|
215
445
|
const meta = {
|
|
216
|
-
deviceWidth: metadata.deviceWidth ??
|
|
217
|
-
deviceHeight: metadata.deviceHeight ??
|
|
446
|
+
deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
|
|
447
|
+
deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
|
|
218
448
|
frameSeq,
|
|
219
449
|
offsetTop: metadata.offsetTop,
|
|
220
450
|
pageScaleFactor: metadata.pageScaleFactor,
|
|
@@ -226,11 +456,19 @@ var ScreencastPump = class {
|
|
|
226
456
|
}, this.ackTimeoutMs);
|
|
227
457
|
timer.unref();
|
|
228
458
|
this.outstanding = {
|
|
459
|
+
cdp,
|
|
460
|
+
generation,
|
|
229
461
|
cdpSessionId,
|
|
230
462
|
frameSeq,
|
|
231
463
|
timer
|
|
232
464
|
};
|
|
233
|
-
|
|
465
|
+
try {
|
|
466
|
+
this.options.onFrame(jpeg, meta);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
469
|
+
const outstanding = this.outstanding;
|
|
470
|
+
if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
|
|
471
|
+
}
|
|
234
472
|
}
|
|
235
473
|
/** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
|
|
236
474
|
ackFromViewer(frameSeq) {
|
|
@@ -239,9 +477,10 @@ var ScreencastPump = class {
|
|
|
239
477
|
ackCdp(outstanding) {
|
|
240
478
|
clearTimeout(outstanding.timer);
|
|
241
479
|
this.outstanding = null;
|
|
242
|
-
if (this.cdp && this.running
|
|
480
|
+
if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
|
|
243
481
|
}
|
|
244
482
|
async stop() {
|
|
483
|
+
this.generation += 1;
|
|
245
484
|
this.running = false;
|
|
246
485
|
if (this.outstanding) {
|
|
247
486
|
clearTimeout(this.outstanding.timer);
|
|
@@ -269,14 +508,24 @@ const MOUSE_TYPE = {
|
|
|
269
508
|
var InputInjector = class {
|
|
270
509
|
cdp = null;
|
|
271
510
|
viewport;
|
|
511
|
+
generation = 0;
|
|
272
512
|
constructor(viewport) {
|
|
273
|
-
this.viewport = viewport;
|
|
513
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
274
514
|
}
|
|
275
515
|
async attach(page) {
|
|
276
|
-
|
|
277
|
-
|
|
516
|
+
const generation = ++this.generation;
|
|
517
|
+
const old = this.cdp;
|
|
518
|
+
this.cdp = null;
|
|
519
|
+
if (old) await old.detach().catch(() => void 0);
|
|
520
|
+
const cdp = await page.createCDPSession();
|
|
521
|
+
if (generation !== this.generation) {
|
|
522
|
+
await cdp.detach().catch(() => void 0);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
this.cdp = cdp;
|
|
278
526
|
}
|
|
279
527
|
async detach() {
|
|
528
|
+
this.generation += 1;
|
|
280
529
|
const cdp = this.cdp;
|
|
281
530
|
this.cdp = null;
|
|
282
531
|
if (cdp) try {
|
|
@@ -284,12 +533,12 @@ var InputInjector = class {
|
|
|
284
533
|
} catch {}
|
|
285
534
|
}
|
|
286
535
|
updateViewport(viewport) {
|
|
287
|
-
this.viewport = viewport;
|
|
536
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
288
537
|
}
|
|
289
538
|
toCssPx(nx, ny) {
|
|
290
539
|
return {
|
|
291
|
-
x: Math.round(clamp01(nx) * this.viewport.width),
|
|
292
|
-
y: Math.round(clamp01(ny) * this.viewport.height)
|
|
540
|
+
x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
|
|
541
|
+
y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
|
|
293
542
|
};
|
|
294
543
|
}
|
|
295
544
|
mouse(p) {
|
|
@@ -342,64 +591,103 @@ function clamp01(n) {
|
|
|
342
591
|
}
|
|
343
592
|
//#endregion
|
|
344
593
|
//#region src/automation.ts
|
|
594
|
+
const OPERATION_TIMEOUT_MS = 3e4;
|
|
345
595
|
var BrowserAutomation = class {
|
|
596
|
+
operationTail = Promise.resolve();
|
|
346
597
|
constructor(session, turn, isNavigationAllowed) {
|
|
347
598
|
this.session = session;
|
|
348
599
|
this.turn = turn;
|
|
349
600
|
this.isNavigationAllowed = isNavigationAllowed;
|
|
350
601
|
}
|
|
351
602
|
async navigate(url) {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
603
|
+
return this.run(async () => {
|
|
604
|
+
const target = validateNavigationUrl(url);
|
|
605
|
+
let allowed = false;
|
|
606
|
+
try {
|
|
607
|
+
allowed = this.isNavigationAllowed(target);
|
|
608
|
+
} catch {}
|
|
609
|
+
if (!allowed) throw new Error("Navigation blocked by browser policy");
|
|
610
|
+
const page = await this.session.getActivePage();
|
|
611
|
+
await page.goto(target, {
|
|
612
|
+
waitUntil: "domcontentloaded",
|
|
613
|
+
timeout: OPERATION_TIMEOUT_MS
|
|
614
|
+
});
|
|
615
|
+
return {
|
|
616
|
+
url: page.url(),
|
|
617
|
+
title: await page.title()
|
|
618
|
+
};
|
|
619
|
+
});
|
|
360
620
|
}
|
|
361
621
|
async click(selector) {
|
|
362
|
-
await this.
|
|
363
|
-
|
|
622
|
+
await this.run(async () => {
|
|
623
|
+
await (await this.session.getActivePage()).click(validateSelector(selector));
|
|
624
|
+
});
|
|
364
625
|
}
|
|
365
626
|
async type(selector, text) {
|
|
366
|
-
await this.
|
|
367
|
-
|
|
627
|
+
await this.run(async () => {
|
|
628
|
+
await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
|
|
629
|
+
});
|
|
368
630
|
}
|
|
369
631
|
async waitFor(opts) {
|
|
370
|
-
await this.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
await
|
|
376
|
-
|
|
632
|
+
await this.run(async () => {
|
|
633
|
+
const normalized = validateWaitOptions(opts);
|
|
634
|
+
const page = await this.session.getActivePage();
|
|
635
|
+
if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
|
|
636
|
+
else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
|
|
637
|
+
else await new Promise((resolve) => {
|
|
638
|
+
setTimeout(resolve, normalized.ms ?? 0).unref();
|
|
639
|
+
});
|
|
640
|
+
});
|
|
377
641
|
}
|
|
378
642
|
/** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
|
|
379
643
|
* from the continuous screencast stream to viewers. */
|
|
380
644
|
async screenshot() {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
645
|
+
return this.run(async () => {
|
|
646
|
+
const image = await (await this.session.getActivePage()).screenshot({
|
|
647
|
+
type: "jpeg",
|
|
648
|
+
quality: 70,
|
|
649
|
+
encoding: "base64"
|
|
650
|
+
});
|
|
651
|
+
if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
|
|
652
|
+
return image;
|
|
386
653
|
});
|
|
387
654
|
}
|
|
388
655
|
/** Evaluate an expression in the page context via CDP (no eval on our side). */
|
|
389
656
|
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 {
|
|
657
|
+
return this.run(async () => {
|
|
658
|
+
const cdp = await (await this.session.getActivePage()).createCDPSession();
|
|
399
659
|
try {
|
|
400
|
-
await cdp.
|
|
401
|
-
|
|
402
|
-
|
|
660
|
+
const res = await cdp.send("Runtime.evaluate", {
|
|
661
|
+
expression: validateExpression(expression),
|
|
662
|
+
returnByValue: true,
|
|
663
|
+
awaitPromise: true,
|
|
664
|
+
timeout: OPERATION_TIMEOUT_MS,
|
|
665
|
+
disableBreaks: true
|
|
666
|
+
});
|
|
667
|
+
if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
|
|
668
|
+
return assertBoundedAutomationResult(res.result.value ?? null);
|
|
669
|
+
} finally {
|
|
670
|
+
try {
|
|
671
|
+
await cdp.detach();
|
|
672
|
+
} catch {}
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/** Wait until all agent operations that were already queued have settled. */
|
|
677
|
+
async waitUntilIdle() {
|
|
678
|
+
await this.operationTail;
|
|
679
|
+
}
|
|
680
|
+
run(operation) {
|
|
681
|
+
const result = this.operationTail.catch(() => void 0).then(async () => {
|
|
682
|
+
await this.turn.acquireAgent();
|
|
683
|
+
try {
|
|
684
|
+
return await operation();
|
|
685
|
+
} finally {
|
|
686
|
+
this.session.touch();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
this.operationTail = result.then(() => void 0, () => void 0);
|
|
690
|
+
return result;
|
|
403
691
|
}
|
|
404
692
|
};
|
|
405
693
|
//#endregion
|
|
@@ -434,12 +722,29 @@ var BrowserSurface = class {
|
|
|
434
722
|
dpr: 1
|
|
435
723
|
};
|
|
436
724
|
handoff = null;
|
|
725
|
+
controllerSessionId = null;
|
|
726
|
+
pendingControllerSessionId = null;
|
|
727
|
+
streamGeneration = 0;
|
|
728
|
+
streamQueue = Promise.resolve();
|
|
729
|
+
closed = false;
|
|
730
|
+
removePageListener;
|
|
437
731
|
log;
|
|
438
732
|
constructor(options, sendFrame) {
|
|
439
733
|
this.options = options;
|
|
440
734
|
this.sendFrame = sendFrame;
|
|
441
735
|
this.log = options.logger ?? noopLogger;
|
|
442
|
-
|
|
736
|
+
const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
|
|
737
|
+
try {
|
|
738
|
+
validateNavigationUrl(url);
|
|
739
|
+
return true;
|
|
740
|
+
} catch {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
this.session = new BrowserSession({
|
|
745
|
+
...options,
|
|
746
|
+
isNavigationAllowed: navigationPolicy
|
|
747
|
+
});
|
|
443
748
|
this.turn = new _alfe_ai_remote.TurnController({ onOwnerChange: () => {
|
|
444
749
|
this.broadcastState();
|
|
445
750
|
} });
|
|
@@ -450,56 +755,87 @@ var BrowserSurface = class {
|
|
|
450
755
|
logger: this.log
|
|
451
756
|
});
|
|
452
757
|
this.injector = new InputInjector(this.viewport);
|
|
453
|
-
this.automation = new BrowserAutomation(this.session, this.turn,
|
|
758
|
+
this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
|
|
759
|
+
this.removePageListener = this.session.onActivePageChange(() => {
|
|
760
|
+
if (this.viewers.size > 0) this.restartStreaming();
|
|
761
|
+
});
|
|
454
762
|
}
|
|
455
763
|
async openSession(sessionId, open) {
|
|
764
|
+
if (this.closed) throw new Error("Browser surface is shut down");
|
|
765
|
+
if (this.viewers.has(sessionId)) {
|
|
766
|
+
this.sendState(sessionId);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
const firstViewer = this.viewers.size === 0;
|
|
456
770
|
this.viewers.add(sessionId);
|
|
457
771
|
this.session.addHold();
|
|
458
|
-
|
|
459
|
-
width
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
772
|
+
try {
|
|
773
|
+
if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
|
|
774
|
+
if (firstViewer) await this.restartStreaming();
|
|
775
|
+
this.sendState(sessionId);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
this.viewers.delete(sessionId);
|
|
778
|
+
this.session.removeHold();
|
|
779
|
+
if (this.viewers.size === 0) {
|
|
780
|
+
this.streamGeneration += 1;
|
|
781
|
+
this.enqueueStreamCleanup();
|
|
782
|
+
}
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
465
785
|
}
|
|
466
786
|
handleFrame(frame) {
|
|
787
|
+
if (this.closed || !this.viewers.has(frame.sessionId)) return;
|
|
467
788
|
switch (frame.type) {
|
|
468
789
|
case _alfe_ai_remote.RemoteFrameType.SCREENCAST_ACK: {
|
|
469
|
-
const ack = (0, _alfe_ai_remote.
|
|
790
|
+
const ack = (0, _alfe_ai_remote.decodeScreencastAckPayload)(frame.payload);
|
|
470
791
|
if (ack) this.pump.ackFromViewer(ack.frameSeq);
|
|
471
792
|
this.session.touch();
|
|
472
793
|
break;
|
|
473
794
|
}
|
|
474
795
|
case _alfe_ai_remote.RemoteFrameType.RESIZE: {
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
796
|
+
const resize = (0, _alfe_ai_remote.decodeResizePayload)(frame.payload);
|
|
797
|
+
if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
|
|
477
798
|
break;
|
|
478
799
|
}
|
|
479
800
|
case _alfe_ai_remote.RemoteFrameType.INPUT_MOUSE:
|
|
480
|
-
if (this.turn.humanInControl) {
|
|
481
|
-
const
|
|
482
|
-
if (
|
|
801
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
802
|
+
const input = (0, _alfe_ai_remote.decodeMouseInputPayload)(frame.payload);
|
|
803
|
+
if (input) this.injector.mouse(input);
|
|
483
804
|
}
|
|
484
805
|
break;
|
|
485
806
|
case _alfe_ai_remote.RemoteFrameType.INPUT_WHEEL:
|
|
486
|
-
if (this.turn.humanInControl) {
|
|
487
|
-
const
|
|
488
|
-
if (
|
|
807
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
808
|
+
const input = (0, _alfe_ai_remote.decodeWheelInputPayload)(frame.payload);
|
|
809
|
+
if (input) this.injector.wheel(input);
|
|
489
810
|
}
|
|
490
811
|
break;
|
|
491
812
|
case _alfe_ai_remote.RemoteFrameType.INPUT_KEY:
|
|
492
|
-
if (this.turn.humanInControl) {
|
|
493
|
-
const
|
|
494
|
-
if (
|
|
813
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
814
|
+
const input = (0, _alfe_ai_remote.decodeKeyInputPayload)(frame.payload);
|
|
815
|
+
if (input) this.injector.key(input);
|
|
495
816
|
}
|
|
496
817
|
break;
|
|
497
818
|
case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST:
|
|
498
|
-
if (this.
|
|
499
|
-
|
|
819
|
+
if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
|
|
820
|
+
this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
this.pendingControllerSessionId = frame.sessionId;
|
|
824
|
+
this.automation.waitUntilIdle().then(() => {
|
|
825
|
+
if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
|
|
826
|
+
this.pendingControllerSessionId = null;
|
|
827
|
+
if (this.turn.grantHuman()) {
|
|
828
|
+
this.controllerSessionId = frame.sessionId;
|
|
829
|
+
this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
|
|
830
|
+
} else this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
831
|
+
}).catch((error) => {
|
|
832
|
+
if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
|
|
833
|
+
this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
|
|
834
|
+
this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
835
|
+
});
|
|
500
836
|
break;
|
|
501
837
|
case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
|
|
502
|
-
this.releaseToAgent();
|
|
838
|
+
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
503
839
|
break;
|
|
504
840
|
default: break;
|
|
505
841
|
}
|
|
@@ -507,9 +843,11 @@ var BrowserSurface = class {
|
|
|
507
843
|
closeSession(sessionId) {
|
|
508
844
|
if (!this.viewers.delete(sessionId)) return;
|
|
509
845
|
this.session.removeHold();
|
|
846
|
+
if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
|
|
847
|
+
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
510
848
|
if (this.viewers.size === 0) {
|
|
511
|
-
this.
|
|
512
|
-
|
|
849
|
+
this.streamGeneration += 1;
|
|
850
|
+
this.enqueueStreamCleanup();
|
|
513
851
|
}
|
|
514
852
|
}
|
|
515
853
|
/**
|
|
@@ -528,12 +866,15 @@ var BrowserSurface = class {
|
|
|
528
866
|
* the tab closed).
|
|
529
867
|
*/
|
|
530
868
|
async requestHandoff(timeoutMs) {
|
|
869
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
531
870
|
if (this.handoff) this.releaseToAgent();
|
|
532
871
|
return new Promise((resolve) => {
|
|
533
872
|
const timer = setTimeout(() => {
|
|
534
873
|
this.handoff = null;
|
|
874
|
+
this.pendingControllerSessionId = null;
|
|
535
875
|
if (this.turn.humanInControl) {
|
|
536
876
|
this.turn.releaseHuman();
|
|
877
|
+
this.controllerSessionId = null;
|
|
537
878
|
this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
|
|
538
879
|
}
|
|
539
880
|
this.currentPageInfo().then(({ url, title }) => {
|
|
@@ -565,11 +906,30 @@ var BrowserSurface = class {
|
|
|
565
906
|
this.session.removeHold();
|
|
566
907
|
}
|
|
567
908
|
async shutdown() {
|
|
568
|
-
|
|
569
|
-
|
|
909
|
+
if (this.closed) return;
|
|
910
|
+
this.closed = true;
|
|
911
|
+
this.removePageListener();
|
|
912
|
+
this.streamGeneration += 1;
|
|
913
|
+
this.pendingControllerSessionId = null;
|
|
914
|
+
this.controllerSessionId = null;
|
|
915
|
+
const waiter = this.handoff;
|
|
916
|
+
this.handoff = null;
|
|
917
|
+
if (waiter) {
|
|
918
|
+
clearTimeout(waiter.timer);
|
|
919
|
+
waiter.resolve({
|
|
920
|
+
released: false,
|
|
921
|
+
timedOut: true,
|
|
922
|
+
url: "",
|
|
923
|
+
title: ""
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
this.turn.releaseHuman();
|
|
927
|
+
await this.enqueueStreamCleanup();
|
|
570
928
|
await this.session.shutdown();
|
|
571
929
|
}
|
|
572
930
|
releaseToAgent() {
|
|
931
|
+
this.pendingControllerSessionId = null;
|
|
932
|
+
this.controllerSessionId = null;
|
|
573
933
|
this.turn.releaseHuman();
|
|
574
934
|
const waiter = this.handoff;
|
|
575
935
|
this.handoff = null;
|
|
@@ -585,51 +945,82 @@ var BrowserSurface = class {
|
|
|
585
945
|
});
|
|
586
946
|
}
|
|
587
947
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
948
|
+
restartStreaming() {
|
|
949
|
+
const generation = ++this.streamGeneration;
|
|
950
|
+
const run = this.streamQueue.catch(() => void 0).then(async () => {
|
|
951
|
+
if (!this.shouldStream(generation)) return;
|
|
952
|
+
try {
|
|
953
|
+
const page = await this.session.getActivePage();
|
|
954
|
+
if (!this.shouldStream(generation)) return;
|
|
955
|
+
await this.injector.attach(page);
|
|
956
|
+
this.injector.updateViewport(this.viewport);
|
|
957
|
+
await this.pump.start(page, this.viewport);
|
|
958
|
+
if (!this.shouldStream(generation)) {
|
|
959
|
+
await this.pump.stop();
|
|
960
|
+
await this.injector.detach();
|
|
961
|
+
}
|
|
962
|
+
} catch (error) {
|
|
963
|
+
await this.pump.stop();
|
|
964
|
+
await this.injector.detach();
|
|
965
|
+
throw error;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
this.streamQueue = run.catch((error) => {
|
|
969
|
+
this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
970
|
+
});
|
|
971
|
+
return run;
|
|
972
|
+
}
|
|
973
|
+
enqueueStreamCleanup() {
|
|
974
|
+
const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
|
|
975
|
+
await this.pump.stop();
|
|
976
|
+
await this.injector.detach();
|
|
977
|
+
});
|
|
978
|
+
this.streamQueue = cleanup.catch(() => void 0);
|
|
979
|
+
return cleanup;
|
|
980
|
+
}
|
|
981
|
+
shouldStream(generation) {
|
|
982
|
+
return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
|
|
594
983
|
}
|
|
595
984
|
async applyResize(width, height, dpr) {
|
|
596
|
-
this.viewport =
|
|
597
|
-
width,
|
|
598
|
-
height,
|
|
599
|
-
dpr
|
|
600
|
-
};
|
|
985
|
+
this.viewport = normalizeViewport(width, height, dpr);
|
|
601
986
|
this.injector.updateViewport(this.viewport);
|
|
602
|
-
if (this.
|
|
603
|
-
const page = await this.session.getActivePage();
|
|
604
|
-
await this.pump.start(page, this.viewport);
|
|
605
|
-
}
|
|
987
|
+
if (this.viewers.size > 0) await this.restartStreaming();
|
|
606
988
|
}
|
|
607
989
|
broadcastFrame(jpeg, meta) {
|
|
608
|
-
for (const sessionId of this.viewers)
|
|
990
|
+
for (const sessionId of this.viewers) try {
|
|
991
|
+
this.sendFrameSafe((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
|
|
992
|
+
} catch (error) {
|
|
993
|
+
this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
994
|
+
}
|
|
609
995
|
}
|
|
610
996
|
broadcast(type) {
|
|
611
|
-
for (const sessionId of this.viewers) this.
|
|
997
|
+
for (const sessionId of this.viewers) this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
|
|
612
998
|
}
|
|
613
999
|
broadcastState() {
|
|
614
1000
|
for (const sessionId of this.viewers) this.sendState(sessionId);
|
|
615
1001
|
}
|
|
616
1002
|
sendState(sessionId) {
|
|
617
1003
|
this.currentPageInfo().then(({ url, title }) => {
|
|
1004
|
+
if (!this.viewers.has(sessionId) || this.closed) return;
|
|
618
1005
|
const state = {
|
|
619
1006
|
surface: "browser",
|
|
620
1007
|
url,
|
|
621
1008
|
title,
|
|
622
1009
|
controller: this.turn.currentOwner
|
|
623
1010
|
};
|
|
624
|
-
|
|
1011
|
+
try {
|
|
1012
|
+
this.sendFrameSafe((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1015
|
+
}
|
|
625
1016
|
});
|
|
626
1017
|
}
|
|
627
1018
|
async currentPageInfo() {
|
|
628
1019
|
try {
|
|
629
1020
|
const page = await this.session.getActivePage();
|
|
630
1021
|
return {
|
|
631
|
-
url: page.url(),
|
|
632
|
-
title: await page.title()
|
|
1022
|
+
url: page.url().slice(0, 8192),
|
|
1023
|
+
title: (await page.title()).slice(0, 4096)
|
|
633
1024
|
};
|
|
634
1025
|
} catch {
|
|
635
1026
|
return {
|
|
@@ -638,6 +1029,13 @@ var BrowserSurface = class {
|
|
|
638
1029
|
};
|
|
639
1030
|
}
|
|
640
1031
|
}
|
|
1032
|
+
sendFrameSafe(frame) {
|
|
1033
|
+
try {
|
|
1034
|
+
this.sendFrame(frame);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
641
1039
|
};
|
|
642
1040
|
//#endregion
|
|
643
1041
|
exports.BrowserAutomation = BrowserAutomation;
|