@alfe.ai/browser 0.1.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 +586 -143
- package/dist/index.d.cts +51 -12
- package/dist/index.d.ts +51 -12
- package/dist/index.js +588 -145
- 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
|
|
@@ -33,6 +162,14 @@ puppeteer_core = __toESM(puppeteer_core);
|
|
|
33
162
|
* new tabs are what gets screencast, not a stale main window.
|
|
34
163
|
*/
|
|
35
164
|
const DEFAULT_IDLE_MS = 300 * 1e3;
|
|
165
|
+
/**
|
|
166
|
+
* A page target is worth streaming only once it has a real, navigable http(s)
|
|
167
|
+
* URL. Transient `about:blank` (and `chrome://`, `data:`, blank string) targets
|
|
168
|
+
* are throwaways we must not flip the screencast to.
|
|
169
|
+
*/
|
|
170
|
+
function isNavigablePageUrl(url) {
|
|
171
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
172
|
+
}
|
|
36
173
|
const noopLogger$2 = {
|
|
37
174
|
info: () => {},
|
|
38
175
|
warn: () => {},
|
|
@@ -45,57 +182,101 @@ var BrowserSession = class {
|
|
|
45
182
|
launching = null;
|
|
46
183
|
holds = 0;
|
|
47
184
|
idleTimer = null;
|
|
185
|
+
generation = 0;
|
|
186
|
+
preparedPages = /* @__PURE__ */ new WeakSet();
|
|
187
|
+
activePageListeners = /* @__PURE__ */ new Set();
|
|
48
188
|
log;
|
|
49
189
|
idleMs;
|
|
50
190
|
constructor(options) {
|
|
51
191
|
this.options = options;
|
|
52
192
|
this.log = options.logger ?? noopLogger$2;
|
|
53
|
-
this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
|
|
193
|
+
this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
|
|
54
194
|
}
|
|
55
195
|
/** Launch Chrome if not already running (idempotent, concurrent-safe). */
|
|
56
196
|
async ensureLaunched() {
|
|
57
197
|
if (this.browser) return;
|
|
58
198
|
if (this.launching) return this.launching;
|
|
59
|
-
|
|
60
|
-
|
|
199
|
+
const generation = ++this.generation;
|
|
200
|
+
const launching = this.doLaunch(generation).finally(() => {
|
|
201
|
+
if (this.launching === launching) this.launching = null;
|
|
61
202
|
});
|
|
62
|
-
|
|
203
|
+
this.launching = launching;
|
|
204
|
+
return launching;
|
|
63
205
|
}
|
|
64
|
-
async doLaunch() {
|
|
206
|
+
async doLaunch(generation) {
|
|
65
207
|
const args = [
|
|
66
208
|
"--disable-blink-features=AutomationControlled",
|
|
67
209
|
...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
|
|
68
|
-
...this.options.extraArgs ?? []
|
|
210
|
+
...validateChromeArgs(this.options.extraArgs ?? [])
|
|
69
211
|
];
|
|
70
212
|
this.log.info(`Launching Chrome (${this.options.executablePath})`);
|
|
71
|
-
|
|
213
|
+
const browser = await puppeteer_core.default.launch({
|
|
72
214
|
executablePath: this.options.executablePath,
|
|
73
215
|
headless: this.options.headless ?? true,
|
|
74
216
|
userDataDir: this.options.userDataDir,
|
|
75
217
|
args
|
|
76
218
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
this.
|
|
89
|
-
|
|
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
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Adopt `page` as the streamed active page iff it has a real navigable
|
|
252
|
+
* http(s) URL. Returns whether it was adopted. Guards against flipping the
|
|
253
|
+
* screencast to a transient `about:blank` throwaway target.
|
|
254
|
+
*/
|
|
255
|
+
adoptIfNavigable(page) {
|
|
256
|
+
if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
|
|
257
|
+
if (this.activePage === page) return true;
|
|
258
|
+
this.setActivePage(page);
|
|
259
|
+
this.log.debug("Active page switched to new target");
|
|
260
|
+
return true;
|
|
90
261
|
}
|
|
91
262
|
/** The current active page, launching Chrome first if needed. */
|
|
92
263
|
async getActivePage() {
|
|
93
264
|
await this.ensureLaunched();
|
|
94
265
|
if (!this.activePage || this.activePage.isClosed()) {
|
|
95
266
|
if (!this.browser) throw new Error("Browser not available");
|
|
96
|
-
|
|
267
|
+
const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
|
|
268
|
+
await this.preparePage(page);
|
|
269
|
+
this.setActivePage(page);
|
|
97
270
|
}
|
|
98
|
-
|
|
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
|
+
};
|
|
99
280
|
}
|
|
100
281
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
101
282
|
addHold() {
|
|
@@ -130,6 +311,8 @@ var BrowserSession = class {
|
|
|
130
311
|
}
|
|
131
312
|
async shutdown() {
|
|
132
313
|
this.clearIdleTimer();
|
|
314
|
+
this.generation += 1;
|
|
315
|
+
this.launching = null;
|
|
133
316
|
const browser = this.browser;
|
|
134
317
|
this.browser = null;
|
|
135
318
|
this.activePage = null;
|
|
@@ -137,6 +320,40 @@ var BrowserSession = class {
|
|
|
137
320
|
await browser.close();
|
|
138
321
|
} catch {}
|
|
139
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
|
+
}
|
|
140
357
|
};
|
|
141
358
|
//#endregion
|
|
142
359
|
//#region src/screencast-pump.ts
|
|
@@ -151,6 +368,12 @@ var ScreencastPump = class {
|
|
|
151
368
|
running = false;
|
|
152
369
|
frameSeq = 0;
|
|
153
370
|
outstanding = null;
|
|
371
|
+
generation = 0;
|
|
372
|
+
viewport = {
|
|
373
|
+
width: 1280,
|
|
374
|
+
height: 720,
|
|
375
|
+
dpr: 1
|
|
376
|
+
};
|
|
154
377
|
log;
|
|
155
378
|
quality;
|
|
156
379
|
ackTimeoutMs;
|
|
@@ -159,6 +382,8 @@ var ScreencastPump = class {
|
|
|
159
382
|
this.log = options.logger ?? noopLogger$1;
|
|
160
383
|
this.quality = options.quality ?? 70;
|
|
161
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");
|
|
162
387
|
}
|
|
163
388
|
get isRunning() {
|
|
164
389
|
return this.running;
|
|
@@ -166,33 +391,60 @@ var ScreencastPump = class {
|
|
|
166
391
|
/** Start (or restart) the screencast on the given page. */
|
|
167
392
|
async start(page, viewport) {
|
|
168
393
|
await this.stop();
|
|
394
|
+
const generation = ++this.generation;
|
|
395
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
169
396
|
const cdp = await page.createCDPSession();
|
|
397
|
+
if (generation !== this.generation) {
|
|
398
|
+
await cdp.detach().catch(() => void 0);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
170
401
|
this.cdp = cdp;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
this.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|
|
189
429
|
}
|
|
190
|
-
onCdpFrame(dataBase64, cdpSessionId, metadata) {
|
|
430
|
+
onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
|
|
191
431
|
if (this.outstanding) this.ackCdp(this.outstanding);
|
|
192
|
-
|
|
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;
|
|
193
445
|
const meta = {
|
|
194
|
-
deviceWidth: metadata.deviceWidth ??
|
|
195
|
-
deviceHeight: metadata.deviceHeight ??
|
|
446
|
+
deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
|
|
447
|
+
deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
|
|
196
448
|
frameSeq,
|
|
197
449
|
offsetTop: metadata.offsetTop,
|
|
198
450
|
pageScaleFactor: metadata.pageScaleFactor,
|
|
@@ -204,11 +456,19 @@ var ScreencastPump = class {
|
|
|
204
456
|
}, this.ackTimeoutMs);
|
|
205
457
|
timer.unref();
|
|
206
458
|
this.outstanding = {
|
|
459
|
+
cdp,
|
|
460
|
+
generation,
|
|
207
461
|
cdpSessionId,
|
|
208
462
|
frameSeq,
|
|
209
463
|
timer
|
|
210
464
|
};
|
|
211
|
-
|
|
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
|
+
}
|
|
212
472
|
}
|
|
213
473
|
/** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
|
|
214
474
|
ackFromViewer(frameSeq) {
|
|
@@ -217,9 +477,10 @@ var ScreencastPump = class {
|
|
|
217
477
|
ackCdp(outstanding) {
|
|
218
478
|
clearTimeout(outstanding.timer);
|
|
219
479
|
this.outstanding = null;
|
|
220
|
-
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(() => {});
|
|
221
481
|
}
|
|
222
482
|
async stop() {
|
|
483
|
+
this.generation += 1;
|
|
223
484
|
this.running = false;
|
|
224
485
|
if (this.outstanding) {
|
|
225
486
|
clearTimeout(this.outstanding.timer);
|
|
@@ -247,14 +508,24 @@ const MOUSE_TYPE = {
|
|
|
247
508
|
var InputInjector = class {
|
|
248
509
|
cdp = null;
|
|
249
510
|
viewport;
|
|
511
|
+
generation = 0;
|
|
250
512
|
constructor(viewport) {
|
|
251
|
-
this.viewport = viewport;
|
|
513
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
252
514
|
}
|
|
253
515
|
async attach(page) {
|
|
254
|
-
|
|
255
|
-
|
|
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;
|
|
256
526
|
}
|
|
257
527
|
async detach() {
|
|
528
|
+
this.generation += 1;
|
|
258
529
|
const cdp = this.cdp;
|
|
259
530
|
this.cdp = null;
|
|
260
531
|
if (cdp) try {
|
|
@@ -262,12 +533,12 @@ var InputInjector = class {
|
|
|
262
533
|
} catch {}
|
|
263
534
|
}
|
|
264
535
|
updateViewport(viewport) {
|
|
265
|
-
this.viewport = viewport;
|
|
536
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
266
537
|
}
|
|
267
538
|
toCssPx(nx, ny) {
|
|
268
539
|
return {
|
|
269
|
-
x: Math.round(clamp01(nx) * this.viewport.width),
|
|
270
|
-
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))
|
|
271
542
|
};
|
|
272
543
|
}
|
|
273
544
|
mouse(p) {
|
|
@@ -320,64 +591,103 @@ function clamp01(n) {
|
|
|
320
591
|
}
|
|
321
592
|
//#endregion
|
|
322
593
|
//#region src/automation.ts
|
|
594
|
+
const OPERATION_TIMEOUT_MS = 3e4;
|
|
323
595
|
var BrowserAutomation = class {
|
|
596
|
+
operationTail = Promise.resolve();
|
|
324
597
|
constructor(session, turn, isNavigationAllowed) {
|
|
325
598
|
this.session = session;
|
|
326
599
|
this.turn = turn;
|
|
327
600
|
this.isNavigationAllowed = isNavigationAllowed;
|
|
328
601
|
}
|
|
329
602
|
async navigate(url) {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
});
|
|
338
620
|
}
|
|
339
621
|
async click(selector) {
|
|
340
|
-
await this.
|
|
341
|
-
|
|
622
|
+
await this.run(async () => {
|
|
623
|
+
await (await this.session.getActivePage()).click(validateSelector(selector));
|
|
624
|
+
});
|
|
342
625
|
}
|
|
343
626
|
async type(selector, text) {
|
|
344
|
-
await this.
|
|
345
|
-
|
|
627
|
+
await this.run(async () => {
|
|
628
|
+
await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
|
|
629
|
+
});
|
|
346
630
|
}
|
|
347
631
|
async waitFor(opts) {
|
|
348
|
-
await this.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
await
|
|
354
|
-
|
|
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
|
+
});
|
|
355
641
|
}
|
|
356
642
|
/** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
|
|
357
643
|
* from the continuous screencast stream to viewers. */
|
|
358
644
|
async screenshot() {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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;
|
|
364
653
|
});
|
|
365
654
|
}
|
|
366
655
|
/** Evaluate an expression in the page context via CDP (no eval on our side). */
|
|
367
656
|
async evaluate(expression) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
try {
|
|
371
|
-
return (await cdp.send("Runtime.evaluate", {
|
|
372
|
-
expression,
|
|
373
|
-
returnByValue: true,
|
|
374
|
-
awaitPromise: true
|
|
375
|
-
})).result.value;
|
|
376
|
-
} finally {
|
|
657
|
+
return this.run(async () => {
|
|
658
|
+
const cdp = await (await this.session.getActivePage()).createCDPSession();
|
|
377
659
|
try {
|
|
378
|
-
await cdp.
|
|
379
|
-
|
|
380
|
-
|
|
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;
|
|
381
691
|
}
|
|
382
692
|
};
|
|
383
693
|
//#endregion
|
|
@@ -412,12 +722,29 @@ var BrowserSurface = class {
|
|
|
412
722
|
dpr: 1
|
|
413
723
|
};
|
|
414
724
|
handoff = null;
|
|
725
|
+
controllerSessionId = null;
|
|
726
|
+
pendingControllerSessionId = null;
|
|
727
|
+
streamGeneration = 0;
|
|
728
|
+
streamQueue = Promise.resolve();
|
|
729
|
+
closed = false;
|
|
730
|
+
removePageListener;
|
|
415
731
|
log;
|
|
416
732
|
constructor(options, sendFrame) {
|
|
417
733
|
this.options = options;
|
|
418
734
|
this.sendFrame = sendFrame;
|
|
419
735
|
this.log = options.logger ?? noopLogger;
|
|
420
|
-
|
|
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
|
+
});
|
|
421
748
|
this.turn = new _alfe_ai_remote.TurnController({ onOwnerChange: () => {
|
|
422
749
|
this.broadcastState();
|
|
423
750
|
} });
|
|
@@ -428,56 +755,87 @@ var BrowserSurface = class {
|
|
|
428
755
|
logger: this.log
|
|
429
756
|
});
|
|
430
757
|
this.injector = new InputInjector(this.viewport);
|
|
431
|
-
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
|
+
});
|
|
432
762
|
}
|
|
433
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;
|
|
434
770
|
this.viewers.add(sessionId);
|
|
435
771
|
this.session.addHold();
|
|
436
|
-
|
|
437
|
-
width
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
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
|
+
}
|
|
443
785
|
}
|
|
444
786
|
handleFrame(frame) {
|
|
787
|
+
if (this.closed || !this.viewers.has(frame.sessionId)) return;
|
|
445
788
|
switch (frame.type) {
|
|
446
789
|
case _alfe_ai_remote.RemoteFrameType.SCREENCAST_ACK: {
|
|
447
|
-
const ack = (0, _alfe_ai_remote.
|
|
790
|
+
const ack = (0, _alfe_ai_remote.decodeScreencastAckPayload)(frame.payload);
|
|
448
791
|
if (ack) this.pump.ackFromViewer(ack.frameSeq);
|
|
449
792
|
this.session.touch();
|
|
450
793
|
break;
|
|
451
794
|
}
|
|
452
795
|
case _alfe_ai_remote.RemoteFrameType.RESIZE: {
|
|
453
|
-
const
|
|
454
|
-
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);
|
|
455
798
|
break;
|
|
456
799
|
}
|
|
457
800
|
case _alfe_ai_remote.RemoteFrameType.INPUT_MOUSE:
|
|
458
|
-
if (this.turn.humanInControl) {
|
|
459
|
-
const
|
|
460
|
-
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);
|
|
461
804
|
}
|
|
462
805
|
break;
|
|
463
806
|
case _alfe_ai_remote.RemoteFrameType.INPUT_WHEEL:
|
|
464
|
-
if (this.turn.humanInControl) {
|
|
465
|
-
const
|
|
466
|
-
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);
|
|
467
810
|
}
|
|
468
811
|
break;
|
|
469
812
|
case _alfe_ai_remote.RemoteFrameType.INPUT_KEY:
|
|
470
|
-
if (this.turn.humanInControl) {
|
|
471
|
-
const
|
|
472
|
-
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);
|
|
473
816
|
}
|
|
474
817
|
break;
|
|
475
818
|
case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST:
|
|
476
|
-
if (this.
|
|
477
|
-
|
|
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
|
+
});
|
|
478
836
|
break;
|
|
479
837
|
case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
|
|
480
|
-
this.releaseToAgent();
|
|
838
|
+
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
481
839
|
break;
|
|
482
840
|
default: break;
|
|
483
841
|
}
|
|
@@ -485,24 +843,40 @@ var BrowserSurface = class {
|
|
|
485
843
|
closeSession(sessionId) {
|
|
486
844
|
if (!this.viewers.delete(sessionId)) return;
|
|
487
845
|
this.session.removeHold();
|
|
846
|
+
if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
|
|
847
|
+
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
488
848
|
if (this.viewers.size === 0) {
|
|
489
|
-
this.
|
|
490
|
-
|
|
849
|
+
this.streamGeneration += 1;
|
|
850
|
+
this.enqueueStreamCleanup();
|
|
491
851
|
}
|
|
492
852
|
}
|
|
493
853
|
/**
|
|
494
|
-
* Agent tool entry point:
|
|
495
|
-
*
|
|
496
|
-
* final page URL/title so the agent
|
|
854
|
+
* Agent tool entry point: PARK the agent and wait for a human to take over
|
|
855
|
+
* and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
|
|
856
|
+
* `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
|
|
857
|
+
* resumes on the same live page.
|
|
858
|
+
*
|
|
859
|
+
* Crucially this does NOT grant the human turn up front. The turn is granted
|
|
860
|
+
* only when a human ACTUALLY takes control — i.e. when the controlling
|
|
861
|
+
* (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
|
|
862
|
+
* `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
|
|
863
|
+
* "human in control" before anyone had claimed, so a read-only viewer's
|
|
864
|
+
* teardown would fire `releaseToAgent()` and complete the session out from
|
|
865
|
+
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
866
|
+
* the tab closed).
|
|
497
867
|
*/
|
|
498
868
|
async requestHandoff(timeoutMs) {
|
|
499
|
-
|
|
500
|
-
this.
|
|
869
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
870
|
+
if (this.handoff) this.releaseToAgent();
|
|
501
871
|
return new Promise((resolve) => {
|
|
502
872
|
const timer = setTimeout(() => {
|
|
503
873
|
this.handoff = null;
|
|
504
|
-
this.
|
|
505
|
-
this.
|
|
874
|
+
this.pendingControllerSessionId = null;
|
|
875
|
+
if (this.turn.humanInControl) {
|
|
876
|
+
this.turn.releaseHuman();
|
|
877
|
+
this.controllerSessionId = null;
|
|
878
|
+
this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
|
|
879
|
+
}
|
|
506
880
|
this.currentPageInfo().then(({ url, title }) => {
|
|
507
881
|
resolve({
|
|
508
882
|
released: false,
|
|
@@ -519,12 +893,43 @@ var BrowserSurface = class {
|
|
|
519
893
|
};
|
|
520
894
|
});
|
|
521
895
|
}
|
|
896
|
+
/**
|
|
897
|
+
* Keep the shared Chrome alive across an awaiting-human window. Delegates to
|
|
898
|
+
* the session's hold counter (which also backs per-viewer holds). Idempotent
|
|
899
|
+
* and leak-safe when paired with {@link removeHold} in a `finally`.
|
|
900
|
+
*/
|
|
901
|
+
addHold() {
|
|
902
|
+
this.session.addHold();
|
|
903
|
+
}
|
|
904
|
+
/** Release a hold taken by {@link addHold}. */
|
|
905
|
+
removeHold() {
|
|
906
|
+
this.session.removeHold();
|
|
907
|
+
}
|
|
522
908
|
async shutdown() {
|
|
523
|
-
|
|
524
|
-
|
|
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();
|
|
525
928
|
await this.session.shutdown();
|
|
526
929
|
}
|
|
527
930
|
releaseToAgent() {
|
|
931
|
+
this.pendingControllerSessionId = null;
|
|
932
|
+
this.controllerSessionId = null;
|
|
528
933
|
this.turn.releaseHuman();
|
|
529
934
|
const waiter = this.handoff;
|
|
530
935
|
this.handoff = null;
|
|
@@ -540,51 +945,82 @@ var BrowserSurface = class {
|
|
|
540
945
|
});
|
|
541
946
|
}
|
|
542
947
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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;
|
|
549
983
|
}
|
|
550
984
|
async applyResize(width, height, dpr) {
|
|
551
|
-
this.viewport =
|
|
552
|
-
width,
|
|
553
|
-
height,
|
|
554
|
-
dpr
|
|
555
|
-
};
|
|
985
|
+
this.viewport = normalizeViewport(width, height, dpr);
|
|
556
986
|
this.injector.updateViewport(this.viewport);
|
|
557
|
-
if (this.
|
|
558
|
-
const page = await this.session.getActivePage();
|
|
559
|
-
await this.pump.start(page, this.viewport);
|
|
560
|
-
}
|
|
987
|
+
if (this.viewers.size > 0) await this.restartStreaming();
|
|
561
988
|
}
|
|
562
989
|
broadcastFrame(jpeg, meta) {
|
|
563
|
-
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
|
+
}
|
|
564
995
|
}
|
|
565
996
|
broadcast(type) {
|
|
566
|
-
for (const sessionId of this.viewers) this.
|
|
997
|
+
for (const sessionId of this.viewers) this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
|
|
567
998
|
}
|
|
568
999
|
broadcastState() {
|
|
569
1000
|
for (const sessionId of this.viewers) this.sendState(sessionId);
|
|
570
1001
|
}
|
|
571
1002
|
sendState(sessionId) {
|
|
572
1003
|
this.currentPageInfo().then(({ url, title }) => {
|
|
1004
|
+
if (!this.viewers.has(sessionId) || this.closed) return;
|
|
573
1005
|
const state = {
|
|
574
1006
|
surface: "browser",
|
|
575
1007
|
url,
|
|
576
1008
|
title,
|
|
577
1009
|
controller: this.turn.currentOwner
|
|
578
1010
|
};
|
|
579
|
-
|
|
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
|
+
}
|
|
580
1016
|
});
|
|
581
1017
|
}
|
|
582
1018
|
async currentPageInfo() {
|
|
583
1019
|
try {
|
|
584
1020
|
const page = await this.session.getActivePage();
|
|
585
1021
|
return {
|
|
586
|
-
url: page.url(),
|
|
587
|
-
title: await page.title()
|
|
1022
|
+
url: page.url().slice(0, 8192),
|
|
1023
|
+
title: (await page.title()).slice(0, 4096)
|
|
588
1024
|
};
|
|
589
1025
|
} catch {
|
|
590
1026
|
return {
|
|
@@ -593,6 +1029,13 @@ var BrowserSurface = class {
|
|
|
593
1029
|
};
|
|
594
1030
|
}
|
|
595
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
|
+
}
|
|
596
1039
|
};
|
|
597
1040
|
//#endregion
|
|
598
1041
|
exports.BrowserAutomation = BrowserAutomation;
|