@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.js
CHANGED
|
@@ -1,5 +1,134 @@
|
|
|
1
|
-
import { RemoteFrameType, TurnController,
|
|
2
|
-
import puppeteer from "puppeteer-core";
|
|
1
|
+
import { RemoteFrameType, TurnController, decodeKeyInputPayload, decodeMouseInputPayload, decodeResizePayload, decodeScreencastAckPayload, decodeWheelInputPayload, encodeFrame, encodeJsonFrame, encodeScreencastFrame } from "@alfe.ai/remote";
|
|
2
|
+
import puppeteer, { TargetType } from "puppeteer-core";
|
|
3
|
+
//#region src/boundary.ts
|
|
4
|
+
const MAX_VIEWPORT_WIDTH = 4096;
|
|
5
|
+
const MAX_VIEWPORT_HEIGHT = 4096;
|
|
6
|
+
const MAX_SELECTOR_CHARS = 4096;
|
|
7
|
+
const MAX_EXPRESSION_CHARS = 128 * 1024;
|
|
8
|
+
const MAX_NAVIGATION_URL_CHARS = 8192;
|
|
9
|
+
const MAX_WAIT_MS = 12e4;
|
|
10
|
+
const MAX_RESULT_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_RESULT_DEPTH = 10;
|
|
12
|
+
const MAX_RESULT_NODES = 2e4;
|
|
13
|
+
const MAX_RESULT_ARRAY_ITEMS = 1e4;
|
|
14
|
+
const MAX_RESULT_STRING_CHARS = 25e4;
|
|
15
|
+
const UNSAFE_KEYS = new Set([
|
|
16
|
+
"__proto__",
|
|
17
|
+
"constructor",
|
|
18
|
+
"prototype"
|
|
19
|
+
]);
|
|
20
|
+
const FORBIDDEN_CHROME_ARG_PREFIXES = [
|
|
21
|
+
"--allow-file-access-from-files",
|
|
22
|
+
"--disable-web-security",
|
|
23
|
+
"--host-resolver-rules",
|
|
24
|
+
"--load-extension",
|
|
25
|
+
"--remote-debugging-address",
|
|
26
|
+
"--remote-debugging-pipe",
|
|
27
|
+
"--remote-debugging-port",
|
|
28
|
+
"--user-data-dir"
|
|
29
|
+
];
|
|
30
|
+
function normalizeViewport(width, height, dpr) {
|
|
31
|
+
if (!Number.isInteger(width) || width < 1 || width > 4096) throw new Error(`viewport width must be an integer from 1 to ${String(MAX_VIEWPORT_WIDTH)}`);
|
|
32
|
+
if (!Number.isInteger(height) || height < 1 || height > 4096) throw new Error(`viewport height must be an integer from 1 to ${String(MAX_VIEWPORT_HEIGHT)}`);
|
|
33
|
+
if (!Number.isFinite(dpr) || dpr < .1 || dpr > 4) throw new Error(`viewport dpr must be from 0.1 to ${String(4)}`);
|
|
34
|
+
if (width * height * dpr * dpr > 33554432) throw new Error("viewport exceeds the device-pixel budget");
|
|
35
|
+
return {
|
|
36
|
+
width,
|
|
37
|
+
height,
|
|
38
|
+
dpr
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function validateIdleShutdownMs(value) {
|
|
42
|
+
if (!Number.isInteger(value) || value < 1e3 || value > 1440 * 60 * 1e3) throw new Error("idle shutdown must be an integer from 1000 to 86400000ms");
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function validateChromeArgs(values) {
|
|
46
|
+
if (values.length > 64) throw new Error("too many extra Chrome arguments");
|
|
47
|
+
return values.map((value) => {
|
|
48
|
+
const arg = validateString("Chrome argument", value, 4096);
|
|
49
|
+
const key = arg.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
50
|
+
if (FORBIDDEN_CHROME_ARG_PREFIXES.includes(key)) throw new Error(`Chrome argument ${key} is owned by the browser runtime`);
|
|
51
|
+
return arg;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function validateNavigationUrl(value) {
|
|
55
|
+
const raw = validateString("navigation URL", value, MAX_NAVIGATION_URL_CHARS);
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error("navigation URL must be an absolute HTTP(S) URL");
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") throw new Error("navigation URL must be an absolute HTTP(S) URL without credentials");
|
|
63
|
+
return parsed.href;
|
|
64
|
+
}
|
|
65
|
+
function validateSelector(value) {
|
|
66
|
+
return validateString("selector", value, MAX_SELECTOR_CHARS);
|
|
67
|
+
}
|
|
68
|
+
function validateTypeText(value) {
|
|
69
|
+
if (value.length > 32768) throw new Error("type text exceeds the character limit");
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function validateExpression(value) {
|
|
73
|
+
return validateString("expression", value, MAX_EXPRESSION_CHARS);
|
|
74
|
+
}
|
|
75
|
+
function validateWaitOptions(opts) {
|
|
76
|
+
if ([
|
|
77
|
+
opts.selector !== void 0,
|
|
78
|
+
opts.ms !== void 0,
|
|
79
|
+
opts.urlPattern !== void 0
|
|
80
|
+
].filter(Boolean).length !== 1) throw new Error("waitFor requires exactly one selector, ms, or urlPattern");
|
|
81
|
+
if (opts.selector !== void 0) return { selector: validateSelector(opts.selector) };
|
|
82
|
+
if (opts.urlPattern !== void 0) return { urlPattern: validateString("URL pattern", opts.urlPattern, MAX_NAVIGATION_URL_CHARS) };
|
|
83
|
+
if (!Number.isInteger(opts.ms) || (opts.ms ?? 0) < 0 || (opts.ms ?? 0) > 12e4) throw new Error(`wait duration must be an integer from 0 to ${String(MAX_WAIT_MS)}ms`);
|
|
84
|
+
return { ms: opts.ms };
|
|
85
|
+
}
|
|
86
|
+
function assertBoundedAutomationResult(value) {
|
|
87
|
+
cloneJson(value, 0, { nodes: 0 });
|
|
88
|
+
let encoded;
|
|
89
|
+
try {
|
|
90
|
+
encoded = JSON.stringify(value);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("browser evaluation result must be JSON serializable");
|
|
93
|
+
}
|
|
94
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_RESULT_BYTES) throw new Error("browser evaluation result exceeds the byte limit");
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
function validateString(label, value, maxChars) {
|
|
98
|
+
if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function cloneJson(value, depth, state) {
|
|
102
|
+
state.nodes += 1;
|
|
103
|
+
if (state.nodes > MAX_RESULT_NODES) throw new Error("browser evaluation result contains too many values");
|
|
104
|
+
if (depth > MAX_RESULT_DEPTH) throw new Error("browser evaluation result exceeds the depth limit");
|
|
105
|
+
if (value === null || typeof value === "boolean") return value;
|
|
106
|
+
if (typeof value === "number") {
|
|
107
|
+
if (!Number.isFinite(value)) throw new Error("browser evaluation result contains a non-finite number");
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
if (value.length > MAX_RESULT_STRING_CHARS) throw new Error("browser evaluation result contains an oversized string");
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
if (value.length > MAX_RESULT_ARRAY_ITEMS) throw new Error("browser evaluation result contains an oversized array");
|
|
116
|
+
return value.map((entry) => cloneJson(entry, depth + 1, state));
|
|
117
|
+
}
|
|
118
|
+
if (typeof value !== "object") throw new Error("browser evaluation result contains a non-JSON value");
|
|
119
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
120
|
+
if (UNSAFE_KEYS.has(key) || key.length < 1 || key.length > 256) throw new Error("browser evaluation result contains an unsafe property name");
|
|
121
|
+
cloneJson(nested, depth + 1, state);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function containsControlCharacter(value) {
|
|
126
|
+
return Array.from(value).some((character) => {
|
|
127
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
128
|
+
return codePoint < 32 || codePoint === 127;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
3
132
|
//#region src/browser-session.ts
|
|
4
133
|
/**
|
|
5
134
|
* BrowserSession — owns the single headless Chrome instance the agent
|
|
@@ -9,6 +138,14 @@ import puppeteer from "puppeteer-core";
|
|
|
9
138
|
* new tabs are what gets screencast, not a stale main window.
|
|
10
139
|
*/
|
|
11
140
|
const DEFAULT_IDLE_MS = 300 * 1e3;
|
|
141
|
+
/**
|
|
142
|
+
* A page target is worth streaming only once it has a real, navigable http(s)
|
|
143
|
+
* URL. Transient `about:blank` (and `chrome://`, `data:`, blank string) targets
|
|
144
|
+
* are throwaways we must not flip the screencast to.
|
|
145
|
+
*/
|
|
146
|
+
function isNavigablePageUrl(url) {
|
|
147
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
148
|
+
}
|
|
12
149
|
const noopLogger$2 = {
|
|
13
150
|
info: () => {},
|
|
14
151
|
warn: () => {},
|
|
@@ -21,57 +158,101 @@ var BrowserSession = class {
|
|
|
21
158
|
launching = null;
|
|
22
159
|
holds = 0;
|
|
23
160
|
idleTimer = null;
|
|
161
|
+
generation = 0;
|
|
162
|
+
preparedPages = /* @__PURE__ */ new WeakSet();
|
|
163
|
+
activePageListeners = /* @__PURE__ */ new Set();
|
|
24
164
|
log;
|
|
25
165
|
idleMs;
|
|
26
166
|
constructor(options) {
|
|
27
167
|
this.options = options;
|
|
28
168
|
this.log = options.logger ?? noopLogger$2;
|
|
29
|
-
this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
|
|
169
|
+
this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
|
|
30
170
|
}
|
|
31
171
|
/** Launch Chrome if not already running (idempotent, concurrent-safe). */
|
|
32
172
|
async ensureLaunched() {
|
|
33
173
|
if (this.browser) return;
|
|
34
174
|
if (this.launching) return this.launching;
|
|
35
|
-
|
|
36
|
-
|
|
175
|
+
const generation = ++this.generation;
|
|
176
|
+
const launching = this.doLaunch(generation).finally(() => {
|
|
177
|
+
if (this.launching === launching) this.launching = null;
|
|
37
178
|
});
|
|
38
|
-
|
|
179
|
+
this.launching = launching;
|
|
180
|
+
return launching;
|
|
39
181
|
}
|
|
40
|
-
async doLaunch() {
|
|
182
|
+
async doLaunch(generation) {
|
|
41
183
|
const args = [
|
|
42
184
|
"--disable-blink-features=AutomationControlled",
|
|
43
185
|
...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
|
|
44
|
-
...this.options.extraArgs ?? []
|
|
186
|
+
...validateChromeArgs(this.options.extraArgs ?? [])
|
|
45
187
|
];
|
|
46
188
|
this.log.info(`Launching Chrome (${this.options.executablePath})`);
|
|
47
|
-
|
|
189
|
+
const browser = await puppeteer.launch({
|
|
48
190
|
executablePath: this.options.executablePath,
|
|
49
191
|
headless: this.options.headless ?? true,
|
|
50
192
|
userDataDir: this.options.userDataDir,
|
|
51
193
|
args
|
|
52
194
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
this.
|
|
65
|
-
|
|
195
|
+
try {
|
|
196
|
+
if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
|
|
197
|
+
browser.on("disconnected", () => {
|
|
198
|
+
if (this.browser !== browser) return;
|
|
199
|
+
this.generation += 1;
|
|
200
|
+
this.browser = null;
|
|
201
|
+
this.activePage = null;
|
|
202
|
+
});
|
|
203
|
+
const pages = await browser.pages();
|
|
204
|
+
const initialPage = pages[0] ?? await browser.newPage();
|
|
205
|
+
await Promise.all(pages.map((page) => this.preparePage(page)));
|
|
206
|
+
await this.preparePage(initialPage);
|
|
207
|
+
if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
|
|
208
|
+
this.browser = browser;
|
|
209
|
+
this.setActivePage(initialPage);
|
|
210
|
+
browser.on("targetcreated", (target) => {
|
|
211
|
+
if (this.browser !== browser || target.type() !== TargetType.PAGE) return;
|
|
212
|
+
target.page().then(async (page) => {
|
|
213
|
+
if (!page || this.browser !== browser) return;
|
|
214
|
+
await this.preparePage(page);
|
|
215
|
+
if (this.adoptIfNavigable(page)) return;
|
|
216
|
+
page.on("framenavigated", (frame) => {
|
|
217
|
+
if (this.browser === browser && frame === page.mainFrame()) this.adoptIfNavigable(page);
|
|
218
|
+
});
|
|
219
|
+
}).catch(() => {});
|
|
220
|
+
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
await browser.close().catch(() => void 0);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Adopt `page` as the streamed active page iff it has a real navigable
|
|
228
|
+
* http(s) URL. Returns whether it was adopted. Guards against flipping the
|
|
229
|
+
* screencast to a transient `about:blank` throwaway target.
|
|
230
|
+
*/
|
|
231
|
+
adoptIfNavigable(page) {
|
|
232
|
+
if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
|
|
233
|
+
if (this.activePage === page) return true;
|
|
234
|
+
this.setActivePage(page);
|
|
235
|
+
this.log.debug("Active page switched to new target");
|
|
236
|
+
return true;
|
|
66
237
|
}
|
|
67
238
|
/** The current active page, launching Chrome first if needed. */
|
|
68
239
|
async getActivePage() {
|
|
69
240
|
await this.ensureLaunched();
|
|
70
241
|
if (!this.activePage || this.activePage.isClosed()) {
|
|
71
242
|
if (!this.browser) throw new Error("Browser not available");
|
|
72
|
-
|
|
243
|
+
const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
|
|
244
|
+
await this.preparePage(page);
|
|
245
|
+
this.setActivePage(page);
|
|
73
246
|
}
|
|
74
|
-
|
|
247
|
+
const activePage = this.activePage;
|
|
248
|
+
if (activePage === null) throw new Error("Browser page not available");
|
|
249
|
+
return activePage;
|
|
250
|
+
}
|
|
251
|
+
onActivePageChange(listener) {
|
|
252
|
+
this.activePageListeners.add(listener);
|
|
253
|
+
return () => {
|
|
254
|
+
this.activePageListeners.delete(listener);
|
|
255
|
+
};
|
|
75
256
|
}
|
|
76
257
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
77
258
|
addHold() {
|
|
@@ -106,6 +287,8 @@ var BrowserSession = class {
|
|
|
106
287
|
}
|
|
107
288
|
async shutdown() {
|
|
108
289
|
this.clearIdleTimer();
|
|
290
|
+
this.generation += 1;
|
|
291
|
+
this.launching = null;
|
|
109
292
|
const browser = this.browser;
|
|
110
293
|
this.browser = null;
|
|
111
294
|
this.activePage = null;
|
|
@@ -113,6 +296,40 @@ var BrowserSession = class {
|
|
|
113
296
|
await browser.close();
|
|
114
297
|
} catch {}
|
|
115
298
|
}
|
|
299
|
+
setActivePage(page) {
|
|
300
|
+
if (this.activePage === page) return;
|
|
301
|
+
this.activePage = page;
|
|
302
|
+
for (const listener of this.activePageListeners) try {
|
|
303
|
+
Promise.resolve(listener(page)).catch((error) => {
|
|
304
|
+
this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
305
|
+
});
|
|
306
|
+
} catch (error) {
|
|
307
|
+
this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async preparePage(page) {
|
|
311
|
+
if (this.preparedPages.has(page)) return;
|
|
312
|
+
this.preparedPages.add(page);
|
|
313
|
+
const policy = this.options.isNavigationAllowed;
|
|
314
|
+
if (policy === void 0) return;
|
|
315
|
+
try {
|
|
316
|
+
await page.setRequestInterception(true);
|
|
317
|
+
page.on("request", (request) => {
|
|
318
|
+
if (request.isInterceptResolutionHandled()) return;
|
|
319
|
+
let allowed = false;
|
|
320
|
+
try {
|
|
321
|
+
allowed = policy(request.url());
|
|
322
|
+
} catch {}
|
|
323
|
+
(allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
|
|
324
|
+
});
|
|
325
|
+
} catch (error) {
|
|
326
|
+
this.preparedPages.delete(page);
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
isLaunchCurrent(generation, browser) {
|
|
331
|
+
return generation === this.generation && browser.connected;
|
|
332
|
+
}
|
|
116
333
|
};
|
|
117
334
|
//#endregion
|
|
118
335
|
//#region src/screencast-pump.ts
|
|
@@ -127,6 +344,12 @@ var ScreencastPump = class {
|
|
|
127
344
|
running = false;
|
|
128
345
|
frameSeq = 0;
|
|
129
346
|
outstanding = null;
|
|
347
|
+
generation = 0;
|
|
348
|
+
viewport = {
|
|
349
|
+
width: 1280,
|
|
350
|
+
height: 720,
|
|
351
|
+
dpr: 1
|
|
352
|
+
};
|
|
130
353
|
log;
|
|
131
354
|
quality;
|
|
132
355
|
ackTimeoutMs;
|
|
@@ -135,6 +358,8 @@ var ScreencastPump = class {
|
|
|
135
358
|
this.log = options.logger ?? noopLogger$1;
|
|
136
359
|
this.quality = options.quality ?? 70;
|
|
137
360
|
this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
|
|
361
|
+
if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
|
|
362
|
+
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");
|
|
138
363
|
}
|
|
139
364
|
get isRunning() {
|
|
140
365
|
return this.running;
|
|
@@ -142,33 +367,60 @@ var ScreencastPump = class {
|
|
|
142
367
|
/** Start (or restart) the screencast on the given page. */
|
|
143
368
|
async start(page, viewport) {
|
|
144
369
|
await this.stop();
|
|
370
|
+
const generation = ++this.generation;
|
|
371
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
145
372
|
const cdp = await page.createCDPSession();
|
|
373
|
+
if (generation !== this.generation) {
|
|
374
|
+
await cdp.detach().catch(() => void 0);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
146
377
|
this.cdp = cdp;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
this.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
378
|
+
try {
|
|
379
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
380
|
+
width: this.viewport.width,
|
|
381
|
+
height: this.viewport.height,
|
|
382
|
+
deviceScaleFactor: this.viewport.dpr,
|
|
383
|
+
mobile: false
|
|
384
|
+
});
|
|
385
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
386
|
+
cdp.on("Page.screencastFrame", (event) => {
|
|
387
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
388
|
+
this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
|
|
389
|
+
});
|
|
390
|
+
await cdp.send("Page.startScreencast", {
|
|
391
|
+
format: "jpeg",
|
|
392
|
+
quality: this.quality,
|
|
393
|
+
maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
|
|
394
|
+
maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
|
|
395
|
+
everyNthFrame: 1
|
|
396
|
+
});
|
|
397
|
+
if (generation !== this.generation || this.cdp !== cdp) return;
|
|
398
|
+
this.running = true;
|
|
399
|
+
this.log.debug("Screencast started");
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (this.cdp === cdp) this.cdp = null;
|
|
402
|
+
await cdp.detach().catch(() => void 0);
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
165
405
|
}
|
|
166
|
-
onCdpFrame(dataBase64, cdpSessionId, metadata) {
|
|
406
|
+
onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
|
|
167
407
|
if (this.outstanding) this.ackCdp(this.outstanding);
|
|
168
|
-
|
|
408
|
+
if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
|
|
409
|
+
cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
|
|
410
|
+
this.log.warn("Dropped oversized browser screencast frame");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const jpeg = Buffer.from(dataBase64, "base64");
|
|
414
|
+
if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
|
|
415
|
+
cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
|
|
416
|
+
this.log.warn("Dropped invalid browser screencast frame");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
|
|
420
|
+
const frameSeq = this.frameSeq;
|
|
169
421
|
const meta = {
|
|
170
|
-
deviceWidth: metadata.deviceWidth ??
|
|
171
|
-
deviceHeight: metadata.deviceHeight ??
|
|
422
|
+
deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
|
|
423
|
+
deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
|
|
172
424
|
frameSeq,
|
|
173
425
|
offsetTop: metadata.offsetTop,
|
|
174
426
|
pageScaleFactor: metadata.pageScaleFactor,
|
|
@@ -180,11 +432,19 @@ var ScreencastPump = class {
|
|
|
180
432
|
}, this.ackTimeoutMs);
|
|
181
433
|
timer.unref();
|
|
182
434
|
this.outstanding = {
|
|
435
|
+
cdp,
|
|
436
|
+
generation,
|
|
183
437
|
cdpSessionId,
|
|
184
438
|
frameSeq,
|
|
185
439
|
timer
|
|
186
440
|
};
|
|
187
|
-
|
|
441
|
+
try {
|
|
442
|
+
this.options.onFrame(jpeg, meta);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
445
|
+
const outstanding = this.outstanding;
|
|
446
|
+
if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
|
|
447
|
+
}
|
|
188
448
|
}
|
|
189
449
|
/** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
|
|
190
450
|
ackFromViewer(frameSeq) {
|
|
@@ -193,9 +453,10 @@ var ScreencastPump = class {
|
|
|
193
453
|
ackCdp(outstanding) {
|
|
194
454
|
clearTimeout(outstanding.timer);
|
|
195
455
|
this.outstanding = null;
|
|
196
|
-
if (this.cdp && this.running
|
|
456
|
+
if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
|
|
197
457
|
}
|
|
198
458
|
async stop() {
|
|
459
|
+
this.generation += 1;
|
|
199
460
|
this.running = false;
|
|
200
461
|
if (this.outstanding) {
|
|
201
462
|
clearTimeout(this.outstanding.timer);
|
|
@@ -223,14 +484,24 @@ const MOUSE_TYPE = {
|
|
|
223
484
|
var InputInjector = class {
|
|
224
485
|
cdp = null;
|
|
225
486
|
viewport;
|
|
487
|
+
generation = 0;
|
|
226
488
|
constructor(viewport) {
|
|
227
|
-
this.viewport = viewport;
|
|
489
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
228
490
|
}
|
|
229
491
|
async attach(page) {
|
|
230
|
-
|
|
231
|
-
|
|
492
|
+
const generation = ++this.generation;
|
|
493
|
+
const old = this.cdp;
|
|
494
|
+
this.cdp = null;
|
|
495
|
+
if (old) await old.detach().catch(() => void 0);
|
|
496
|
+
const cdp = await page.createCDPSession();
|
|
497
|
+
if (generation !== this.generation) {
|
|
498
|
+
await cdp.detach().catch(() => void 0);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
this.cdp = cdp;
|
|
232
502
|
}
|
|
233
503
|
async detach() {
|
|
504
|
+
this.generation += 1;
|
|
234
505
|
const cdp = this.cdp;
|
|
235
506
|
this.cdp = null;
|
|
236
507
|
if (cdp) try {
|
|
@@ -238,12 +509,12 @@ var InputInjector = class {
|
|
|
238
509
|
} catch {}
|
|
239
510
|
}
|
|
240
511
|
updateViewport(viewport) {
|
|
241
|
-
this.viewport = viewport;
|
|
512
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
242
513
|
}
|
|
243
514
|
toCssPx(nx, ny) {
|
|
244
515
|
return {
|
|
245
|
-
x: Math.round(clamp01(nx) * this.viewport.width),
|
|
246
|
-
y: Math.round(clamp01(ny) * this.viewport.height)
|
|
516
|
+
x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
|
|
517
|
+
y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
|
|
247
518
|
};
|
|
248
519
|
}
|
|
249
520
|
mouse(p) {
|
|
@@ -296,64 +567,103 @@ function clamp01(n) {
|
|
|
296
567
|
}
|
|
297
568
|
//#endregion
|
|
298
569
|
//#region src/automation.ts
|
|
570
|
+
const OPERATION_TIMEOUT_MS = 3e4;
|
|
299
571
|
var BrowserAutomation = class {
|
|
572
|
+
operationTail = Promise.resolve();
|
|
300
573
|
constructor(session, turn, isNavigationAllowed) {
|
|
301
574
|
this.session = session;
|
|
302
575
|
this.turn = turn;
|
|
303
576
|
this.isNavigationAllowed = isNavigationAllowed;
|
|
304
577
|
}
|
|
305
578
|
async navigate(url) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
579
|
+
return this.run(async () => {
|
|
580
|
+
const target = validateNavigationUrl(url);
|
|
581
|
+
let allowed = false;
|
|
582
|
+
try {
|
|
583
|
+
allowed = this.isNavigationAllowed(target);
|
|
584
|
+
} catch {}
|
|
585
|
+
if (!allowed) throw new Error("Navigation blocked by browser policy");
|
|
586
|
+
const page = await this.session.getActivePage();
|
|
587
|
+
await page.goto(target, {
|
|
588
|
+
waitUntil: "domcontentloaded",
|
|
589
|
+
timeout: OPERATION_TIMEOUT_MS
|
|
590
|
+
});
|
|
591
|
+
return {
|
|
592
|
+
url: page.url(),
|
|
593
|
+
title: await page.title()
|
|
594
|
+
};
|
|
595
|
+
});
|
|
314
596
|
}
|
|
315
597
|
async click(selector) {
|
|
316
|
-
await this.
|
|
317
|
-
|
|
598
|
+
await this.run(async () => {
|
|
599
|
+
await (await this.session.getActivePage()).click(validateSelector(selector));
|
|
600
|
+
});
|
|
318
601
|
}
|
|
319
602
|
async type(selector, text) {
|
|
320
|
-
await this.
|
|
321
|
-
|
|
603
|
+
await this.run(async () => {
|
|
604
|
+
await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
|
|
605
|
+
});
|
|
322
606
|
}
|
|
323
607
|
async waitFor(opts) {
|
|
324
|
-
await this.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
await
|
|
330
|
-
|
|
608
|
+
await this.run(async () => {
|
|
609
|
+
const normalized = validateWaitOptions(opts);
|
|
610
|
+
const page = await this.session.getActivePage();
|
|
611
|
+
if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
|
|
612
|
+
else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
|
|
613
|
+
else await new Promise((resolve) => {
|
|
614
|
+
setTimeout(resolve, normalized.ms ?? 0).unref();
|
|
615
|
+
});
|
|
616
|
+
});
|
|
331
617
|
}
|
|
332
618
|
/** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
|
|
333
619
|
* from the continuous screencast stream to viewers. */
|
|
334
620
|
async screenshot() {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
621
|
+
return this.run(async () => {
|
|
622
|
+
const image = await (await this.session.getActivePage()).screenshot({
|
|
623
|
+
type: "jpeg",
|
|
624
|
+
quality: 70,
|
|
625
|
+
encoding: "base64"
|
|
626
|
+
});
|
|
627
|
+
if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
|
|
628
|
+
return image;
|
|
340
629
|
});
|
|
341
630
|
}
|
|
342
631
|
/** Evaluate an expression in the page context via CDP (no eval on our side). */
|
|
343
632
|
async evaluate(expression) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
try {
|
|
347
|
-
return (await cdp.send("Runtime.evaluate", {
|
|
348
|
-
expression,
|
|
349
|
-
returnByValue: true,
|
|
350
|
-
awaitPromise: true
|
|
351
|
-
})).result.value;
|
|
352
|
-
} finally {
|
|
633
|
+
return this.run(async () => {
|
|
634
|
+
const cdp = await (await this.session.getActivePage()).createCDPSession();
|
|
353
635
|
try {
|
|
354
|
-
await cdp.
|
|
355
|
-
|
|
356
|
-
|
|
636
|
+
const res = await cdp.send("Runtime.evaluate", {
|
|
637
|
+
expression: validateExpression(expression),
|
|
638
|
+
returnByValue: true,
|
|
639
|
+
awaitPromise: true,
|
|
640
|
+
timeout: OPERATION_TIMEOUT_MS,
|
|
641
|
+
disableBreaks: true
|
|
642
|
+
});
|
|
643
|
+
if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
|
|
644
|
+
return assertBoundedAutomationResult(res.result.value ?? null);
|
|
645
|
+
} finally {
|
|
646
|
+
try {
|
|
647
|
+
await cdp.detach();
|
|
648
|
+
} catch {}
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/** Wait until all agent operations that were already queued have settled. */
|
|
653
|
+
async waitUntilIdle() {
|
|
654
|
+
await this.operationTail;
|
|
655
|
+
}
|
|
656
|
+
run(operation) {
|
|
657
|
+
const result = this.operationTail.catch(() => void 0).then(async () => {
|
|
658
|
+
await this.turn.acquireAgent();
|
|
659
|
+
try {
|
|
660
|
+
return await operation();
|
|
661
|
+
} finally {
|
|
662
|
+
this.session.touch();
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
this.operationTail = result.then(() => void 0, () => void 0);
|
|
666
|
+
return result;
|
|
357
667
|
}
|
|
358
668
|
};
|
|
359
669
|
//#endregion
|
|
@@ -388,12 +698,29 @@ var BrowserSurface = class {
|
|
|
388
698
|
dpr: 1
|
|
389
699
|
};
|
|
390
700
|
handoff = null;
|
|
701
|
+
controllerSessionId = null;
|
|
702
|
+
pendingControllerSessionId = null;
|
|
703
|
+
streamGeneration = 0;
|
|
704
|
+
streamQueue = Promise.resolve();
|
|
705
|
+
closed = false;
|
|
706
|
+
removePageListener;
|
|
391
707
|
log;
|
|
392
708
|
constructor(options, sendFrame) {
|
|
393
709
|
this.options = options;
|
|
394
710
|
this.sendFrame = sendFrame;
|
|
395
711
|
this.log = options.logger ?? noopLogger;
|
|
396
|
-
|
|
712
|
+
const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
|
|
713
|
+
try {
|
|
714
|
+
validateNavigationUrl(url);
|
|
715
|
+
return true;
|
|
716
|
+
} catch {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
this.session = new BrowserSession({
|
|
721
|
+
...options,
|
|
722
|
+
isNavigationAllowed: navigationPolicy
|
|
723
|
+
});
|
|
397
724
|
this.turn = new TurnController({ onOwnerChange: () => {
|
|
398
725
|
this.broadcastState();
|
|
399
726
|
} });
|
|
@@ -404,56 +731,87 @@ var BrowserSurface = class {
|
|
|
404
731
|
logger: this.log
|
|
405
732
|
});
|
|
406
733
|
this.injector = new InputInjector(this.viewport);
|
|
407
|
-
this.automation = new BrowserAutomation(this.session, this.turn,
|
|
734
|
+
this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
|
|
735
|
+
this.removePageListener = this.session.onActivePageChange(() => {
|
|
736
|
+
if (this.viewers.size > 0) this.restartStreaming();
|
|
737
|
+
});
|
|
408
738
|
}
|
|
409
739
|
async openSession(sessionId, open) {
|
|
740
|
+
if (this.closed) throw new Error("Browser surface is shut down");
|
|
741
|
+
if (this.viewers.has(sessionId)) {
|
|
742
|
+
this.sendState(sessionId);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const firstViewer = this.viewers.size === 0;
|
|
410
746
|
this.viewers.add(sessionId);
|
|
411
747
|
this.session.addHold();
|
|
412
|
-
|
|
413
|
-
width
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
|
|
748
|
+
try {
|
|
749
|
+
if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
|
|
750
|
+
if (firstViewer) await this.restartStreaming();
|
|
751
|
+
this.sendState(sessionId);
|
|
752
|
+
} catch (error) {
|
|
753
|
+
this.viewers.delete(sessionId);
|
|
754
|
+
this.session.removeHold();
|
|
755
|
+
if (this.viewers.size === 0) {
|
|
756
|
+
this.streamGeneration += 1;
|
|
757
|
+
this.enqueueStreamCleanup();
|
|
758
|
+
}
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
419
761
|
}
|
|
420
762
|
handleFrame(frame) {
|
|
763
|
+
if (this.closed || !this.viewers.has(frame.sessionId)) return;
|
|
421
764
|
switch (frame.type) {
|
|
422
765
|
case RemoteFrameType.SCREENCAST_ACK: {
|
|
423
|
-
const ack =
|
|
766
|
+
const ack = decodeScreencastAckPayload(frame.payload);
|
|
424
767
|
if (ack) this.pump.ackFromViewer(ack.frameSeq);
|
|
425
768
|
this.session.touch();
|
|
426
769
|
break;
|
|
427
770
|
}
|
|
428
771
|
case RemoteFrameType.RESIZE: {
|
|
429
|
-
const
|
|
430
|
-
if (
|
|
772
|
+
const resize = decodeResizePayload(frame.payload);
|
|
773
|
+
if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
|
|
431
774
|
break;
|
|
432
775
|
}
|
|
433
776
|
case RemoteFrameType.INPUT_MOUSE:
|
|
434
|
-
if (this.turn.humanInControl) {
|
|
435
|
-
const
|
|
436
|
-
if (
|
|
777
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
778
|
+
const input = decodeMouseInputPayload(frame.payload);
|
|
779
|
+
if (input) this.injector.mouse(input);
|
|
437
780
|
}
|
|
438
781
|
break;
|
|
439
782
|
case RemoteFrameType.INPUT_WHEEL:
|
|
440
|
-
if (this.turn.humanInControl) {
|
|
441
|
-
const
|
|
442
|
-
if (
|
|
783
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
784
|
+
const input = decodeWheelInputPayload(frame.payload);
|
|
785
|
+
if (input) this.injector.wheel(input);
|
|
443
786
|
}
|
|
444
787
|
break;
|
|
445
788
|
case RemoteFrameType.INPUT_KEY:
|
|
446
|
-
if (this.turn.humanInControl) {
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
789
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
790
|
+
const input = decodeKeyInputPayload(frame.payload);
|
|
791
|
+
if (input) this.injector.key(input);
|
|
449
792
|
}
|
|
450
793
|
break;
|
|
451
794
|
case RemoteFrameType.TAKEOVER_REQUEST:
|
|
452
|
-
if (this.
|
|
453
|
-
|
|
795
|
+
if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
|
|
796
|
+
this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
this.pendingControllerSessionId = frame.sessionId;
|
|
800
|
+
this.automation.waitUntilIdle().then(() => {
|
|
801
|
+
if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
|
|
802
|
+
this.pendingControllerSessionId = null;
|
|
803
|
+
if (this.turn.grantHuman()) {
|
|
804
|
+
this.controllerSessionId = frame.sessionId;
|
|
805
|
+
this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
|
|
806
|
+
} else this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
807
|
+
}).catch((error) => {
|
|
808
|
+
if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
|
|
809
|
+
this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
|
|
810
|
+
this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
|
|
811
|
+
});
|
|
454
812
|
break;
|
|
455
813
|
case RemoteFrameType.RELEASE_CONTROL:
|
|
456
|
-
this.releaseToAgent();
|
|
814
|
+
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
457
815
|
break;
|
|
458
816
|
default: break;
|
|
459
817
|
}
|
|
@@ -461,24 +819,40 @@ var BrowserSurface = class {
|
|
|
461
819
|
closeSession(sessionId) {
|
|
462
820
|
if (!this.viewers.delete(sessionId)) return;
|
|
463
821
|
this.session.removeHold();
|
|
822
|
+
if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
|
|
823
|
+
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
464
824
|
if (this.viewers.size === 0) {
|
|
465
|
-
this.
|
|
466
|
-
|
|
825
|
+
this.streamGeneration += 1;
|
|
826
|
+
this.enqueueStreamCleanup();
|
|
467
827
|
}
|
|
468
828
|
}
|
|
469
829
|
/**
|
|
470
|
-
* Agent tool entry point:
|
|
471
|
-
*
|
|
472
|
-
* final page URL/title so the agent
|
|
830
|
+
* Agent tool entry point: PARK the agent and wait for a human to take over
|
|
831
|
+
* and hand back (RELEASE_CONTROL / viewer teardown after a claim) or for
|
|
832
|
+
* `timeoutMs` to elapse. Resolves with the final page URL/title so the agent
|
|
833
|
+
* resumes on the same live page.
|
|
834
|
+
*
|
|
835
|
+
* Crucially this does NOT grant the human turn up front. The turn is granted
|
|
836
|
+
* only when a human ACTUALLY takes control — i.e. when the controlling
|
|
837
|
+
* (`canControl:true`) viewer sends a `TAKEOVER_REQUEST` frame (see
|
|
838
|
+
* `handleFrame` → `grantHuman()`). Granting at tool-call time made the session
|
|
839
|
+
* "human in control" before anyone had claimed, so a read-only viewer's
|
|
840
|
+
* teardown would fire `releaseToAgent()` and complete the session out from
|
|
841
|
+
* under the user (mislabeled 409 on the real claim; agent only resuming when
|
|
842
|
+
* the tab closed).
|
|
473
843
|
*/
|
|
474
844
|
async requestHandoff(timeoutMs) {
|
|
475
|
-
|
|
476
|
-
this.
|
|
845
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
846
|
+
if (this.handoff) this.releaseToAgent();
|
|
477
847
|
return new Promise((resolve) => {
|
|
478
848
|
const timer = setTimeout(() => {
|
|
479
849
|
this.handoff = null;
|
|
480
|
-
this.
|
|
481
|
-
this.
|
|
850
|
+
this.pendingControllerSessionId = null;
|
|
851
|
+
if (this.turn.humanInControl) {
|
|
852
|
+
this.turn.releaseHuman();
|
|
853
|
+
this.controllerSessionId = null;
|
|
854
|
+
this.broadcast(RemoteFrameType.CONTROL_REVOKED);
|
|
855
|
+
}
|
|
482
856
|
this.currentPageInfo().then(({ url, title }) => {
|
|
483
857
|
resolve({
|
|
484
858
|
released: false,
|
|
@@ -495,12 +869,43 @@ var BrowserSurface = class {
|
|
|
495
869
|
};
|
|
496
870
|
});
|
|
497
871
|
}
|
|
872
|
+
/**
|
|
873
|
+
* Keep the shared Chrome alive across an awaiting-human window. Delegates to
|
|
874
|
+
* the session's hold counter (which also backs per-viewer holds). Idempotent
|
|
875
|
+
* and leak-safe when paired with {@link removeHold} in a `finally`.
|
|
876
|
+
*/
|
|
877
|
+
addHold() {
|
|
878
|
+
this.session.addHold();
|
|
879
|
+
}
|
|
880
|
+
/** Release a hold taken by {@link addHold}. */
|
|
881
|
+
removeHold() {
|
|
882
|
+
this.session.removeHold();
|
|
883
|
+
}
|
|
498
884
|
async shutdown() {
|
|
499
|
-
|
|
500
|
-
|
|
885
|
+
if (this.closed) return;
|
|
886
|
+
this.closed = true;
|
|
887
|
+
this.removePageListener();
|
|
888
|
+
this.streamGeneration += 1;
|
|
889
|
+
this.pendingControllerSessionId = null;
|
|
890
|
+
this.controllerSessionId = null;
|
|
891
|
+
const waiter = this.handoff;
|
|
892
|
+
this.handoff = null;
|
|
893
|
+
if (waiter) {
|
|
894
|
+
clearTimeout(waiter.timer);
|
|
895
|
+
waiter.resolve({
|
|
896
|
+
released: false,
|
|
897
|
+
timedOut: true,
|
|
898
|
+
url: "",
|
|
899
|
+
title: ""
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
this.turn.releaseHuman();
|
|
903
|
+
await this.enqueueStreamCleanup();
|
|
501
904
|
await this.session.shutdown();
|
|
502
905
|
}
|
|
503
906
|
releaseToAgent() {
|
|
907
|
+
this.pendingControllerSessionId = null;
|
|
908
|
+
this.controllerSessionId = null;
|
|
504
909
|
this.turn.releaseHuman();
|
|
505
910
|
const waiter = this.handoff;
|
|
506
911
|
this.handoff = null;
|
|
@@ -516,51 +921,82 @@ var BrowserSurface = class {
|
|
|
516
921
|
});
|
|
517
922
|
}
|
|
518
923
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
924
|
+
restartStreaming() {
|
|
925
|
+
const generation = ++this.streamGeneration;
|
|
926
|
+
const run = this.streamQueue.catch(() => void 0).then(async () => {
|
|
927
|
+
if (!this.shouldStream(generation)) return;
|
|
928
|
+
try {
|
|
929
|
+
const page = await this.session.getActivePage();
|
|
930
|
+
if (!this.shouldStream(generation)) return;
|
|
931
|
+
await this.injector.attach(page);
|
|
932
|
+
this.injector.updateViewport(this.viewport);
|
|
933
|
+
await this.pump.start(page, this.viewport);
|
|
934
|
+
if (!this.shouldStream(generation)) {
|
|
935
|
+
await this.pump.stop();
|
|
936
|
+
await this.injector.detach();
|
|
937
|
+
}
|
|
938
|
+
} catch (error) {
|
|
939
|
+
await this.pump.stop();
|
|
940
|
+
await this.injector.detach();
|
|
941
|
+
throw error;
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
this.streamQueue = run.catch((error) => {
|
|
945
|
+
this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
946
|
+
});
|
|
947
|
+
return run;
|
|
948
|
+
}
|
|
949
|
+
enqueueStreamCleanup() {
|
|
950
|
+
const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
|
|
951
|
+
await this.pump.stop();
|
|
952
|
+
await this.injector.detach();
|
|
953
|
+
});
|
|
954
|
+
this.streamQueue = cleanup.catch(() => void 0);
|
|
955
|
+
return cleanup;
|
|
956
|
+
}
|
|
957
|
+
shouldStream(generation) {
|
|
958
|
+
return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
|
|
525
959
|
}
|
|
526
960
|
async applyResize(width, height, dpr) {
|
|
527
|
-
this.viewport =
|
|
528
|
-
width,
|
|
529
|
-
height,
|
|
530
|
-
dpr
|
|
531
|
-
};
|
|
961
|
+
this.viewport = normalizeViewport(width, height, dpr);
|
|
532
962
|
this.injector.updateViewport(this.viewport);
|
|
533
|
-
if (this.
|
|
534
|
-
const page = await this.session.getActivePage();
|
|
535
|
-
await this.pump.start(page, this.viewport);
|
|
536
|
-
}
|
|
963
|
+
if (this.viewers.size > 0) await this.restartStreaming();
|
|
537
964
|
}
|
|
538
965
|
broadcastFrame(jpeg, meta) {
|
|
539
|
-
for (const sessionId of this.viewers)
|
|
966
|
+
for (const sessionId of this.viewers) try {
|
|
967
|
+
this.sendFrameSafe(encodeScreencastFrame(sessionId, meta, jpeg));
|
|
968
|
+
} catch (error) {
|
|
969
|
+
this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
970
|
+
}
|
|
540
971
|
}
|
|
541
972
|
broadcast(type) {
|
|
542
|
-
for (const sessionId of this.viewers) this.
|
|
973
|
+
for (const sessionId of this.viewers) this.sendFrameSafe(encodeFrame(type, sessionId));
|
|
543
974
|
}
|
|
544
975
|
broadcastState() {
|
|
545
976
|
for (const sessionId of this.viewers) this.sendState(sessionId);
|
|
546
977
|
}
|
|
547
978
|
sendState(sessionId) {
|
|
548
979
|
this.currentPageInfo().then(({ url, title }) => {
|
|
980
|
+
if (!this.viewers.has(sessionId) || this.closed) return;
|
|
549
981
|
const state = {
|
|
550
982
|
surface: "browser",
|
|
551
983
|
url,
|
|
552
984
|
title,
|
|
553
985
|
controller: this.turn.currentOwner
|
|
554
986
|
};
|
|
555
|
-
|
|
987
|
+
try {
|
|
988
|
+
this.sendFrameSafe(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
|
|
989
|
+
} catch (error) {
|
|
990
|
+
this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
991
|
+
}
|
|
556
992
|
});
|
|
557
993
|
}
|
|
558
994
|
async currentPageInfo() {
|
|
559
995
|
try {
|
|
560
996
|
const page = await this.session.getActivePage();
|
|
561
997
|
return {
|
|
562
|
-
url: page.url(),
|
|
563
|
-
title: await page.title()
|
|
998
|
+
url: page.url().slice(0, 8192),
|
|
999
|
+
title: (await page.title()).slice(0, 4096)
|
|
564
1000
|
};
|
|
565
1001
|
} catch {
|
|
566
1002
|
return {
|
|
@@ -569,6 +1005,13 @@ var BrowserSurface = class {
|
|
|
569
1005
|
};
|
|
570
1006
|
}
|
|
571
1007
|
}
|
|
1008
|
+
sendFrameSafe(frame) {
|
|
1009
|
+
try {
|
|
1010
|
+
this.sendFrame(frame);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
572
1015
|
};
|
|
573
1016
|
//#endregion
|
|
574
1017
|
export { BrowserAutomation, BrowserSession, BrowserSurface };
|