@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.js
CHANGED
|
@@ -1,5 +1,134 @@
|
|
|
1
|
-
import { RemoteFrameType, TurnController,
|
|
1
|
+
import { RemoteFrameType, TurnController, decodeKeyInputPayload, decodeMouseInputPayload, decodeResizePayload, decodeScreencastAckPayload, decodeWheelInputPayload, encodeFrame, encodeJsonFrame, encodeScreencastFrame } from "@alfe.ai/remote";
|
|
2
2
|
import puppeteer, { TargetType } from "puppeteer-core";
|
|
3
|
+
//#region src/boundary.ts
|
|
4
|
+
const MAX_VIEWPORT_WIDTH = 4096;
|
|
5
|
+
const MAX_VIEWPORT_HEIGHT = 4096;
|
|
6
|
+
const MAX_SELECTOR_CHARS = 4096;
|
|
7
|
+
const MAX_EXPRESSION_CHARS = 128 * 1024;
|
|
8
|
+
const MAX_NAVIGATION_URL_CHARS = 8192;
|
|
9
|
+
const MAX_WAIT_MS = 12e4;
|
|
10
|
+
const MAX_RESULT_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_RESULT_DEPTH = 10;
|
|
12
|
+
const MAX_RESULT_NODES = 2e4;
|
|
13
|
+
const MAX_RESULT_ARRAY_ITEMS = 1e4;
|
|
14
|
+
const MAX_RESULT_STRING_CHARS = 25e4;
|
|
15
|
+
const UNSAFE_KEYS = new Set([
|
|
16
|
+
"__proto__",
|
|
17
|
+
"constructor",
|
|
18
|
+
"prototype"
|
|
19
|
+
]);
|
|
20
|
+
const FORBIDDEN_CHROME_ARG_PREFIXES = [
|
|
21
|
+
"--allow-file-access-from-files",
|
|
22
|
+
"--disable-web-security",
|
|
23
|
+
"--host-resolver-rules",
|
|
24
|
+
"--load-extension",
|
|
25
|
+
"--remote-debugging-address",
|
|
26
|
+
"--remote-debugging-pipe",
|
|
27
|
+
"--remote-debugging-port",
|
|
28
|
+
"--user-data-dir"
|
|
29
|
+
];
|
|
30
|
+
function normalizeViewport(width, height, dpr) {
|
|
31
|
+
if (!Number.isInteger(width) || width < 1 || width > 4096) throw new Error(`viewport width must be an integer from 1 to ${String(MAX_VIEWPORT_WIDTH)}`);
|
|
32
|
+
if (!Number.isInteger(height) || height < 1 || height > 4096) throw new Error(`viewport height must be an integer from 1 to ${String(MAX_VIEWPORT_HEIGHT)}`);
|
|
33
|
+
if (!Number.isFinite(dpr) || dpr < .1 || dpr > 4) throw new Error(`viewport dpr must be from 0.1 to ${String(4)}`);
|
|
34
|
+
if (width * height * dpr * dpr > 33554432) throw new Error("viewport exceeds the device-pixel budget");
|
|
35
|
+
return {
|
|
36
|
+
width,
|
|
37
|
+
height,
|
|
38
|
+
dpr
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function validateIdleShutdownMs(value) {
|
|
42
|
+
if (!Number.isInteger(value) || value < 1e3 || value > 1440 * 60 * 1e3) throw new Error("idle shutdown must be an integer from 1000 to 86400000ms");
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function validateChromeArgs(values) {
|
|
46
|
+
if (values.length > 64) throw new Error("too many extra Chrome arguments");
|
|
47
|
+
return values.map((value) => {
|
|
48
|
+
const arg = validateString("Chrome argument", value, 4096);
|
|
49
|
+
const key = arg.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
50
|
+
if (FORBIDDEN_CHROME_ARG_PREFIXES.includes(key)) throw new Error(`Chrome argument ${key} is owned by the browser runtime`);
|
|
51
|
+
return arg;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function validateNavigationUrl(value) {
|
|
55
|
+
const raw = validateString("navigation URL", value, MAX_NAVIGATION_URL_CHARS);
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = new URL(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error("navigation URL must be an absolute HTTP(S) URL");
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") throw new Error("navigation URL must be an absolute HTTP(S) URL without credentials");
|
|
63
|
+
return parsed.href;
|
|
64
|
+
}
|
|
65
|
+
function validateSelector(value) {
|
|
66
|
+
return validateString("selector", value, MAX_SELECTOR_CHARS);
|
|
67
|
+
}
|
|
68
|
+
function validateTypeText(value) {
|
|
69
|
+
if (value.length > 32768) throw new Error("type text exceeds the character limit");
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
function validateExpression(value) {
|
|
73
|
+
return validateString("expression", value, MAX_EXPRESSION_CHARS);
|
|
74
|
+
}
|
|
75
|
+
function validateWaitOptions(opts) {
|
|
76
|
+
if ([
|
|
77
|
+
opts.selector !== void 0,
|
|
78
|
+
opts.ms !== void 0,
|
|
79
|
+
opts.urlPattern !== void 0
|
|
80
|
+
].filter(Boolean).length !== 1) throw new Error("waitFor requires exactly one selector, ms, or urlPattern");
|
|
81
|
+
if (opts.selector !== void 0) return { selector: validateSelector(opts.selector) };
|
|
82
|
+
if (opts.urlPattern !== void 0) return { urlPattern: validateString("URL pattern", opts.urlPattern, MAX_NAVIGATION_URL_CHARS) };
|
|
83
|
+
if (!Number.isInteger(opts.ms) || (opts.ms ?? 0) < 0 || (opts.ms ?? 0) > 12e4) throw new Error(`wait duration must be an integer from 0 to ${String(MAX_WAIT_MS)}ms`);
|
|
84
|
+
return { ms: opts.ms };
|
|
85
|
+
}
|
|
86
|
+
function assertBoundedAutomationResult(value) {
|
|
87
|
+
cloneJson(value, 0, { nodes: 0 });
|
|
88
|
+
let encoded;
|
|
89
|
+
try {
|
|
90
|
+
encoded = JSON.stringify(value);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error("browser evaluation result must be JSON serializable");
|
|
93
|
+
}
|
|
94
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_RESULT_BYTES) throw new Error("browser evaluation result exceeds the byte limit");
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
function validateString(label, value, maxChars) {
|
|
98
|
+
if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function cloneJson(value, depth, state) {
|
|
102
|
+
state.nodes += 1;
|
|
103
|
+
if (state.nodes > MAX_RESULT_NODES) throw new Error("browser evaluation result contains too many values");
|
|
104
|
+
if (depth > MAX_RESULT_DEPTH) throw new Error("browser evaluation result exceeds the depth limit");
|
|
105
|
+
if (value === null || typeof value === "boolean") return value;
|
|
106
|
+
if (typeof value === "number") {
|
|
107
|
+
if (!Number.isFinite(value)) throw new Error("browser evaluation result contains a non-finite number");
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
if (value.length > MAX_RESULT_STRING_CHARS) throw new Error("browser evaluation result contains an oversized string");
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(value)) {
|
|
115
|
+
if (value.length > MAX_RESULT_ARRAY_ITEMS) throw new Error("browser evaluation result contains an oversized array");
|
|
116
|
+
return value.map((entry) => cloneJson(entry, depth + 1, state));
|
|
117
|
+
}
|
|
118
|
+
if (typeof value !== "object") throw new Error("browser evaluation result contains a non-JSON value");
|
|
119
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
120
|
+
if (UNSAFE_KEYS.has(key) || key.length < 1 || key.length > 256) throw new Error("browser evaluation result contains an unsafe property name");
|
|
121
|
+
cloneJson(nested, depth + 1, state);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function containsControlCharacter(value) {
|
|
126
|
+
return Array.from(value).some((character) => {
|
|
127
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
128
|
+
return codePoint < 32 || codePoint === 127;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
3
132
|
//#region src/browser-session.ts
|
|
4
133
|
/**
|
|
5
134
|
* BrowserSession — owns the single headless Chrome instance the agent
|
|
@@ -29,50 +158,70 @@ var BrowserSession = class {
|
|
|
29
158
|
launching = null;
|
|
30
159
|
holds = 0;
|
|
31
160
|
idleTimer = null;
|
|
161
|
+
generation = 0;
|
|
162
|
+
preparedPages = /* @__PURE__ */ new WeakSet();
|
|
163
|
+
activePageListeners = /* @__PURE__ */ new Set();
|
|
32
164
|
log;
|
|
33
165
|
idleMs;
|
|
34
166
|
constructor(options) {
|
|
35
167
|
this.options = options;
|
|
36
168
|
this.log = options.logger ?? noopLogger$2;
|
|
37
|
-
this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
|
|
169
|
+
this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
|
|
38
170
|
}
|
|
39
171
|
/** Launch Chrome if not already running (idempotent, concurrent-safe). */
|
|
40
172
|
async ensureLaunched() {
|
|
41
173
|
if (this.browser) return;
|
|
42
174
|
if (this.launching) return this.launching;
|
|
43
|
-
|
|
44
|
-
|
|
175
|
+
const generation = ++this.generation;
|
|
176
|
+
const launching = this.doLaunch(generation).finally(() => {
|
|
177
|
+
if (this.launching === launching) this.launching = null;
|
|
45
178
|
});
|
|
46
|
-
|
|
179
|
+
this.launching = launching;
|
|
180
|
+
return launching;
|
|
47
181
|
}
|
|
48
|
-
async doLaunch() {
|
|
182
|
+
async doLaunch(generation) {
|
|
49
183
|
const args = [
|
|
50
184
|
"--disable-blink-features=AutomationControlled",
|
|
51
185
|
...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
|
|
52
|
-
...this.options.extraArgs ?? []
|
|
186
|
+
...validateChromeArgs(this.options.extraArgs ?? [])
|
|
53
187
|
];
|
|
54
188
|
this.log.info(`Launching Chrome (${this.options.executablePath})`);
|
|
55
|
-
|
|
189
|
+
const browser = await puppeteer.launch({
|
|
56
190
|
executablePath: this.options.executablePath,
|
|
57
191
|
headless: this.options.headless ?? true,
|
|
58
192
|
userDataDir: this.options.userDataDir,
|
|
59
193
|
args
|
|
60
194
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.browser
|
|
74
|
-
this.
|
|
75
|
-
|
|
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
|
+
}
|
|
76
225
|
}
|
|
77
226
|
/**
|
|
78
227
|
* Adopt `page` as the streamed active page iff it has a real navigable
|
|
@@ -82,7 +231,7 @@ var BrowserSession = class {
|
|
|
82
231
|
adoptIfNavigable(page) {
|
|
83
232
|
if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
|
|
84
233
|
if (this.activePage === page) return true;
|
|
85
|
-
this.
|
|
234
|
+
this.setActivePage(page);
|
|
86
235
|
this.log.debug("Active page switched to new target");
|
|
87
236
|
return true;
|
|
88
237
|
}
|
|
@@ -91,9 +240,19 @@ var BrowserSession = class {
|
|
|
91
240
|
await this.ensureLaunched();
|
|
92
241
|
if (!this.activePage || this.activePage.isClosed()) {
|
|
93
242
|
if (!this.browser) throw new Error("Browser not available");
|
|
94
|
-
|
|
243
|
+
const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
|
|
244
|
+
await this.preparePage(page);
|
|
245
|
+
this.setActivePage(page);
|
|
95
246
|
}
|
|
96
|
-
|
|
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
|
+
};
|
|
97
256
|
}
|
|
98
257
|
/** Prevent idle shutdown while a viewer or op is active. */
|
|
99
258
|
addHold() {
|
|
@@ -128,6 +287,8 @@ var BrowserSession = class {
|
|
|
128
287
|
}
|
|
129
288
|
async shutdown() {
|
|
130
289
|
this.clearIdleTimer();
|
|
290
|
+
this.generation += 1;
|
|
291
|
+
this.launching = null;
|
|
131
292
|
const browser = this.browser;
|
|
132
293
|
this.browser = null;
|
|
133
294
|
this.activePage = null;
|
|
@@ -135,6 +296,40 @@ var BrowserSession = class {
|
|
|
135
296
|
await browser.close();
|
|
136
297
|
} catch {}
|
|
137
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
|
+
}
|
|
138
333
|
};
|
|
139
334
|
//#endregion
|
|
140
335
|
//#region src/screencast-pump.ts
|
|
@@ -149,6 +344,12 @@ var ScreencastPump = class {
|
|
|
149
344
|
running = false;
|
|
150
345
|
frameSeq = 0;
|
|
151
346
|
outstanding = null;
|
|
347
|
+
generation = 0;
|
|
348
|
+
viewport = {
|
|
349
|
+
width: 1280,
|
|
350
|
+
height: 720,
|
|
351
|
+
dpr: 1
|
|
352
|
+
};
|
|
152
353
|
log;
|
|
153
354
|
quality;
|
|
154
355
|
ackTimeoutMs;
|
|
@@ -157,6 +358,8 @@ var ScreencastPump = class {
|
|
|
157
358
|
this.log = options.logger ?? noopLogger$1;
|
|
158
359
|
this.quality = options.quality ?? 70;
|
|
159
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");
|
|
160
363
|
}
|
|
161
364
|
get isRunning() {
|
|
162
365
|
return this.running;
|
|
@@ -164,33 +367,60 @@ var ScreencastPump = class {
|
|
|
164
367
|
/** Start (or restart) the screencast on the given page. */
|
|
165
368
|
async start(page, viewport) {
|
|
166
369
|
await this.stop();
|
|
370
|
+
const generation = ++this.generation;
|
|
371
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
167
372
|
const cdp = await page.createCDPSession();
|
|
373
|
+
if (generation !== this.generation) {
|
|
374
|
+
await cdp.detach().catch(() => void 0);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
168
377
|
this.cdp = cdp;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
}
|
|
187
405
|
}
|
|
188
|
-
onCdpFrame(dataBase64, cdpSessionId, metadata) {
|
|
406
|
+
onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
|
|
189
407
|
if (this.outstanding) this.ackCdp(this.outstanding);
|
|
190
|
-
|
|
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;
|
|
191
421
|
const meta = {
|
|
192
|
-
deviceWidth: metadata.deviceWidth ??
|
|
193
|
-
deviceHeight: metadata.deviceHeight ??
|
|
422
|
+
deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
|
|
423
|
+
deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
|
|
194
424
|
frameSeq,
|
|
195
425
|
offsetTop: metadata.offsetTop,
|
|
196
426
|
pageScaleFactor: metadata.pageScaleFactor,
|
|
@@ -202,11 +432,19 @@ var ScreencastPump = class {
|
|
|
202
432
|
}, this.ackTimeoutMs);
|
|
203
433
|
timer.unref();
|
|
204
434
|
this.outstanding = {
|
|
435
|
+
cdp,
|
|
436
|
+
generation,
|
|
205
437
|
cdpSessionId,
|
|
206
438
|
frameSeq,
|
|
207
439
|
timer
|
|
208
440
|
};
|
|
209
|
-
|
|
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
|
+
}
|
|
210
448
|
}
|
|
211
449
|
/** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
|
|
212
450
|
ackFromViewer(frameSeq) {
|
|
@@ -215,9 +453,10 @@ var ScreencastPump = class {
|
|
|
215
453
|
ackCdp(outstanding) {
|
|
216
454
|
clearTimeout(outstanding.timer);
|
|
217
455
|
this.outstanding = null;
|
|
218
|
-
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(() => {});
|
|
219
457
|
}
|
|
220
458
|
async stop() {
|
|
459
|
+
this.generation += 1;
|
|
221
460
|
this.running = false;
|
|
222
461
|
if (this.outstanding) {
|
|
223
462
|
clearTimeout(this.outstanding.timer);
|
|
@@ -245,14 +484,24 @@ const MOUSE_TYPE = {
|
|
|
245
484
|
var InputInjector = class {
|
|
246
485
|
cdp = null;
|
|
247
486
|
viewport;
|
|
487
|
+
generation = 0;
|
|
248
488
|
constructor(viewport) {
|
|
249
|
-
this.viewport = viewport;
|
|
489
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
250
490
|
}
|
|
251
491
|
async attach(page) {
|
|
252
|
-
|
|
253
|
-
|
|
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;
|
|
254
502
|
}
|
|
255
503
|
async detach() {
|
|
504
|
+
this.generation += 1;
|
|
256
505
|
const cdp = this.cdp;
|
|
257
506
|
this.cdp = null;
|
|
258
507
|
if (cdp) try {
|
|
@@ -260,12 +509,12 @@ var InputInjector = class {
|
|
|
260
509
|
} catch {}
|
|
261
510
|
}
|
|
262
511
|
updateViewport(viewport) {
|
|
263
|
-
this.viewport = viewport;
|
|
512
|
+
this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
|
|
264
513
|
}
|
|
265
514
|
toCssPx(nx, ny) {
|
|
266
515
|
return {
|
|
267
|
-
x: Math.round(clamp01(nx) * this.viewport.width),
|
|
268
|
-
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))
|
|
269
518
|
};
|
|
270
519
|
}
|
|
271
520
|
mouse(p) {
|
|
@@ -318,64 +567,103 @@ function clamp01(n) {
|
|
|
318
567
|
}
|
|
319
568
|
//#endregion
|
|
320
569
|
//#region src/automation.ts
|
|
570
|
+
const OPERATION_TIMEOUT_MS = 3e4;
|
|
321
571
|
var BrowserAutomation = class {
|
|
572
|
+
operationTail = Promise.resolve();
|
|
322
573
|
constructor(session, turn, isNavigationAllowed) {
|
|
323
574
|
this.session = session;
|
|
324
575
|
this.turn = turn;
|
|
325
576
|
this.isNavigationAllowed = isNavigationAllowed;
|
|
326
577
|
}
|
|
327
578
|
async navigate(url) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
});
|
|
336
596
|
}
|
|
337
597
|
async click(selector) {
|
|
338
|
-
await this.
|
|
339
|
-
|
|
598
|
+
await this.run(async () => {
|
|
599
|
+
await (await this.session.getActivePage()).click(validateSelector(selector));
|
|
600
|
+
});
|
|
340
601
|
}
|
|
341
602
|
async type(selector, text) {
|
|
342
|
-
await this.
|
|
343
|
-
|
|
603
|
+
await this.run(async () => {
|
|
604
|
+
await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
|
|
605
|
+
});
|
|
344
606
|
}
|
|
345
607
|
async waitFor(opts) {
|
|
346
|
-
await this.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
await
|
|
352
|
-
|
|
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
|
+
});
|
|
353
617
|
}
|
|
354
618
|
/** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
|
|
355
619
|
* from the continuous screencast stream to viewers. */
|
|
356
620
|
async screenshot() {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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;
|
|
362
629
|
});
|
|
363
630
|
}
|
|
364
631
|
/** Evaluate an expression in the page context via CDP (no eval on our side). */
|
|
365
632
|
async evaluate(expression) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
return (await cdp.send("Runtime.evaluate", {
|
|
370
|
-
expression,
|
|
371
|
-
returnByValue: true,
|
|
372
|
-
awaitPromise: true
|
|
373
|
-
})).result.value;
|
|
374
|
-
} finally {
|
|
633
|
+
return this.run(async () => {
|
|
634
|
+
const cdp = await (await this.session.getActivePage()).createCDPSession();
|
|
375
635
|
try {
|
|
376
|
-
await cdp.
|
|
377
|
-
|
|
378
|
-
|
|
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;
|
|
379
667
|
}
|
|
380
668
|
};
|
|
381
669
|
//#endregion
|
|
@@ -410,12 +698,29 @@ var BrowserSurface = class {
|
|
|
410
698
|
dpr: 1
|
|
411
699
|
};
|
|
412
700
|
handoff = null;
|
|
701
|
+
controllerSessionId = null;
|
|
702
|
+
pendingControllerSessionId = null;
|
|
703
|
+
streamGeneration = 0;
|
|
704
|
+
streamQueue = Promise.resolve();
|
|
705
|
+
closed = false;
|
|
706
|
+
removePageListener;
|
|
413
707
|
log;
|
|
414
708
|
constructor(options, sendFrame) {
|
|
415
709
|
this.options = options;
|
|
416
710
|
this.sendFrame = sendFrame;
|
|
417
711
|
this.log = options.logger ?? noopLogger;
|
|
418
|
-
|
|
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
|
+
});
|
|
419
724
|
this.turn = new TurnController({ onOwnerChange: () => {
|
|
420
725
|
this.broadcastState();
|
|
421
726
|
} });
|
|
@@ -426,56 +731,87 @@ var BrowserSurface = class {
|
|
|
426
731
|
logger: this.log
|
|
427
732
|
});
|
|
428
733
|
this.injector = new InputInjector(this.viewport);
|
|
429
|
-
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
|
+
});
|
|
430
738
|
}
|
|
431
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;
|
|
432
746
|
this.viewers.add(sessionId);
|
|
433
747
|
this.session.addHold();
|
|
434
|
-
|
|
435
|
-
width
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
}
|
|
441
761
|
}
|
|
442
762
|
handleFrame(frame) {
|
|
763
|
+
if (this.closed || !this.viewers.has(frame.sessionId)) return;
|
|
443
764
|
switch (frame.type) {
|
|
444
765
|
case RemoteFrameType.SCREENCAST_ACK: {
|
|
445
|
-
const ack =
|
|
766
|
+
const ack = decodeScreencastAckPayload(frame.payload);
|
|
446
767
|
if (ack) this.pump.ackFromViewer(ack.frameSeq);
|
|
447
768
|
this.session.touch();
|
|
448
769
|
break;
|
|
449
770
|
}
|
|
450
771
|
case RemoteFrameType.RESIZE: {
|
|
451
|
-
const
|
|
452
|
-
if (
|
|
772
|
+
const resize = decodeResizePayload(frame.payload);
|
|
773
|
+
if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
|
|
453
774
|
break;
|
|
454
775
|
}
|
|
455
776
|
case RemoteFrameType.INPUT_MOUSE:
|
|
456
|
-
if (this.turn.humanInControl) {
|
|
457
|
-
const
|
|
458
|
-
if (
|
|
777
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
778
|
+
const input = decodeMouseInputPayload(frame.payload);
|
|
779
|
+
if (input) this.injector.mouse(input);
|
|
459
780
|
}
|
|
460
781
|
break;
|
|
461
782
|
case RemoteFrameType.INPUT_WHEEL:
|
|
462
|
-
if (this.turn.humanInControl) {
|
|
463
|
-
const
|
|
464
|
-
if (
|
|
783
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
784
|
+
const input = decodeWheelInputPayload(frame.payload);
|
|
785
|
+
if (input) this.injector.wheel(input);
|
|
465
786
|
}
|
|
466
787
|
break;
|
|
467
788
|
case RemoteFrameType.INPUT_KEY:
|
|
468
|
-
if (this.turn.humanInControl) {
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
789
|
+
if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
|
|
790
|
+
const input = decodeKeyInputPayload(frame.payload);
|
|
791
|
+
if (input) this.injector.key(input);
|
|
471
792
|
}
|
|
472
793
|
break;
|
|
473
794
|
case RemoteFrameType.TAKEOVER_REQUEST:
|
|
474
|
-
if (this.
|
|
475
|
-
|
|
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
|
+
});
|
|
476
812
|
break;
|
|
477
813
|
case RemoteFrameType.RELEASE_CONTROL:
|
|
478
|
-
this.releaseToAgent();
|
|
814
|
+
if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
|
|
479
815
|
break;
|
|
480
816
|
default: break;
|
|
481
817
|
}
|
|
@@ -483,9 +819,11 @@ var BrowserSurface = class {
|
|
|
483
819
|
closeSession(sessionId) {
|
|
484
820
|
if (!this.viewers.delete(sessionId)) return;
|
|
485
821
|
this.session.removeHold();
|
|
822
|
+
if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
|
|
823
|
+
if (this.controllerSessionId === sessionId) this.releaseToAgent();
|
|
486
824
|
if (this.viewers.size === 0) {
|
|
487
|
-
this.
|
|
488
|
-
|
|
825
|
+
this.streamGeneration += 1;
|
|
826
|
+
this.enqueueStreamCleanup();
|
|
489
827
|
}
|
|
490
828
|
}
|
|
491
829
|
/**
|
|
@@ -504,12 +842,15 @@ var BrowserSurface = class {
|
|
|
504
842
|
* the tab closed).
|
|
505
843
|
*/
|
|
506
844
|
async requestHandoff(timeoutMs) {
|
|
845
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
|
|
507
846
|
if (this.handoff) this.releaseToAgent();
|
|
508
847
|
return new Promise((resolve) => {
|
|
509
848
|
const timer = setTimeout(() => {
|
|
510
849
|
this.handoff = null;
|
|
850
|
+
this.pendingControllerSessionId = null;
|
|
511
851
|
if (this.turn.humanInControl) {
|
|
512
852
|
this.turn.releaseHuman();
|
|
853
|
+
this.controllerSessionId = null;
|
|
513
854
|
this.broadcast(RemoteFrameType.CONTROL_REVOKED);
|
|
514
855
|
}
|
|
515
856
|
this.currentPageInfo().then(({ url, title }) => {
|
|
@@ -541,11 +882,30 @@ var BrowserSurface = class {
|
|
|
541
882
|
this.session.removeHold();
|
|
542
883
|
}
|
|
543
884
|
async shutdown() {
|
|
544
|
-
|
|
545
|
-
|
|
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();
|
|
546
904
|
await this.session.shutdown();
|
|
547
905
|
}
|
|
548
906
|
releaseToAgent() {
|
|
907
|
+
this.pendingControllerSessionId = null;
|
|
908
|
+
this.controllerSessionId = null;
|
|
549
909
|
this.turn.releaseHuman();
|
|
550
910
|
const waiter = this.handoff;
|
|
551
911
|
this.handoff = null;
|
|
@@ -561,51 +921,82 @@ var BrowserSurface = class {
|
|
|
561
921
|
});
|
|
562
922
|
}
|
|
563
923
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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;
|
|
570
959
|
}
|
|
571
960
|
async applyResize(width, height, dpr) {
|
|
572
|
-
this.viewport =
|
|
573
|
-
width,
|
|
574
|
-
height,
|
|
575
|
-
dpr
|
|
576
|
-
};
|
|
961
|
+
this.viewport = normalizeViewport(width, height, dpr);
|
|
577
962
|
this.injector.updateViewport(this.viewport);
|
|
578
|
-
if (this.
|
|
579
|
-
const page = await this.session.getActivePage();
|
|
580
|
-
await this.pump.start(page, this.viewport);
|
|
581
|
-
}
|
|
963
|
+
if (this.viewers.size > 0) await this.restartStreaming();
|
|
582
964
|
}
|
|
583
965
|
broadcastFrame(jpeg, meta) {
|
|
584
|
-
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
|
+
}
|
|
585
971
|
}
|
|
586
972
|
broadcast(type) {
|
|
587
|
-
for (const sessionId of this.viewers) this.
|
|
973
|
+
for (const sessionId of this.viewers) this.sendFrameSafe(encodeFrame(type, sessionId));
|
|
588
974
|
}
|
|
589
975
|
broadcastState() {
|
|
590
976
|
for (const sessionId of this.viewers) this.sendState(sessionId);
|
|
591
977
|
}
|
|
592
978
|
sendState(sessionId) {
|
|
593
979
|
this.currentPageInfo().then(({ url, title }) => {
|
|
980
|
+
if (!this.viewers.has(sessionId) || this.closed) return;
|
|
594
981
|
const state = {
|
|
595
982
|
surface: "browser",
|
|
596
983
|
url,
|
|
597
984
|
title,
|
|
598
985
|
controller: this.turn.currentOwner
|
|
599
986
|
};
|
|
600
|
-
|
|
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
|
+
}
|
|
601
992
|
});
|
|
602
993
|
}
|
|
603
994
|
async currentPageInfo() {
|
|
604
995
|
try {
|
|
605
996
|
const page = await this.session.getActivePage();
|
|
606
997
|
return {
|
|
607
|
-
url: page.url(),
|
|
608
|
-
title: await page.title()
|
|
998
|
+
url: page.url().slice(0, 8192),
|
|
999
|
+
title: (await page.title()).slice(0, 4096)
|
|
609
1000
|
};
|
|
610
1001
|
} catch {
|
|
611
1002
|
return {
|
|
@@ -614,6 +1005,13 @@ var BrowserSurface = class {
|
|
|
614
1005
|
};
|
|
615
1006
|
}
|
|
616
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
|
+
}
|
|
617
1015
|
};
|
|
618
1016
|
//#endregion
|
|
619
1017
|
export { BrowserAutomation, BrowserSession, BrowserSurface };
|