@alfe.ai/browser 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,134 @@
1
- import { RemoteFrameType, TurnController, decodeJson, encodeFrame, encodeJsonFrame, encodeScreencastFrame } from "@alfe.ai/remote";
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,79 @@ 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
- this.launching = this.doLaunch().finally(() => {
44
- this.launching = null;
175
+ const generation = ++this.generation;
176
+ const launching = this.doLaunch(generation).finally(() => {
177
+ if (this.launching === launching) this.launching = null;
45
178
  });
46
- return this.launching;
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
- this.browser = await puppeteer.launch({
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
- this.activePage = (await this.browser.pages())[0] ?? await this.browser.newPage();
62
- this.browser.on("targetcreated", (target) => {
63
- if (target.type() !== TargetType.PAGE) return;
64
- target.page().then((page) => {
65
- if (!page) return;
66
- if (this.adoptIfNavigable(page)) return;
67
- page.on("framenavigated", (frame) => {
68
- if (frame === page.mainFrame()) this.adoptIfNavigable(page);
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
+ browser.on("targetdestroyed", (target) => {
222
+ const closedPage = this.activePage;
223
+ if (this.browser !== browser || !closedPage || target.type() !== TargetType.PAGE) return;
224
+ target.page().then(async (page) => {
225
+ if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
226
+ }).catch(() => {
227
+ this.log.warn("Could not restore the browser page after a popup closed");
69
228
  });
70
- }).catch(() => {});
71
- });
72
- this.browser.on("disconnected", () => {
73
- this.browser = null;
74
- this.activePage = null;
75
- });
229
+ });
230
+ } catch (error) {
231
+ await browser.close().catch(() => void 0);
232
+ throw error;
233
+ }
76
234
  }
77
235
  /**
78
236
  * Adopt `page` as the streamed active page iff it has a real navigable
@@ -82,18 +240,98 @@ var BrowserSession = class {
82
240
  adoptIfNavigable(page) {
83
241
  if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
84
242
  if (this.activePage === page) return true;
85
- this.activePage = page;
243
+ this.setActivePage(page);
86
244
  this.log.debug("Active page switched to new target");
87
245
  return true;
88
246
  }
247
+ /** A login popup can close while the agent is parked for human control.
248
+ * Recover here, without waiting for another automation call to discover
249
+ * that the active page is closed, and rebind the viewer to its opener. */
250
+ async restoreAfterClose(browser, closedPage, openerTarget) {
251
+ let opener = await openerTarget?.page();
252
+ while (this.browser === browser && this.activePage === closedPage) {
253
+ const pages = await browser.pages();
254
+ if (this.browser !== browser || this.activePage !== closedPage) return;
255
+ const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
256
+ opener = null;
257
+ try {
258
+ await this.preparePage(page);
259
+ } catch (error) {
260
+ if (page.isClosed()) continue;
261
+ throw error;
262
+ }
263
+ if (page.isClosed()) continue;
264
+ if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
265
+ return;
266
+ }
267
+ }
89
268
  /** The current active page, launching Chrome first if needed. */
90
269
  async getActivePage() {
91
270
  await this.ensureLaunched();
92
271
  if (!this.activePage || this.activePage.isClosed()) {
93
272
  if (!this.browser) throw new Error("Browser not available");
94
- this.activePage = (await this.browser.pages()).find((p) => !p.isClosed()) ?? await this.browser.newPage();
273
+ const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
274
+ await this.preparePage(page);
275
+ this.setActivePage(page);
276
+ }
277
+ const activePage = this.activePage;
278
+ if (activePage === null) throw new Error("Browser page not available");
279
+ return activePage;
280
+ }
281
+ onActivePageChange(listener) {
282
+ this.activePageListeners.add(listener);
283
+ return () => {
284
+ this.activePageListeners.delete(listener);
285
+ };
286
+ }
287
+ /** Internal to the serialized automation turn. The caller holds Chrome until
288
+ * the adapter has disconnected its client/awaited its child process exit. */
289
+ async withCdpTarget(operation, signal) {
290
+ signal.throwIfAborted();
291
+ const page = await this.getActivePage();
292
+ const browser = this.browser;
293
+ const generation = this.generation;
294
+ if (!browser) throw new Error("Browser is unavailable");
295
+ const disconnected = new AbortController();
296
+ const abort = () => {
297
+ disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
298
+ };
299
+ const operationSignal = AbortSignal.any([signal, disconnected.signal]);
300
+ const assertCurrent = () => {
301
+ operationSignal.throwIfAborted();
302
+ if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
303
+ };
304
+ browser.on("disconnected", abort);
305
+ page.on("close", abort);
306
+ try {
307
+ assertCurrent();
308
+ const cdp = await page.createCDPSession();
309
+ let targetId;
310
+ try {
311
+ const { targetInfo } = await cdp.send("Target.getTargetInfo");
312
+ targetId = targetInfo.targetId;
313
+ } finally {
314
+ await cdp.detach().catch(() => void 0);
315
+ }
316
+ assertCurrent();
317
+ const browserWSEndpoint = browser.wsEndpoint();
318
+ const endpoint = new URL(browserWSEndpoint);
319
+ if (endpoint.protocol !== "ws:" || ![
320
+ "127.0.0.1",
321
+ "[::1]",
322
+ "localhost"
323
+ ].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
324
+ const result = await operation({
325
+ browserWSEndpoint,
326
+ targetId,
327
+ signal: operationSignal
328
+ });
329
+ assertCurrent();
330
+ return result;
331
+ } finally {
332
+ browser.off("disconnected", abort);
333
+ page.off("close", abort);
95
334
  }
96
- return this.activePage;
97
335
  }
98
336
  /** Prevent idle shutdown while a viewer or op is active. */
99
337
  addHold() {
@@ -128,6 +366,8 @@ var BrowserSession = class {
128
366
  }
129
367
  async shutdown() {
130
368
  this.clearIdleTimer();
369
+ this.generation += 1;
370
+ this.launching = null;
131
371
  const browser = this.browser;
132
372
  this.browser = null;
133
373
  this.activePage = null;
@@ -135,6 +375,40 @@ var BrowserSession = class {
135
375
  await browser.close();
136
376
  } catch {}
137
377
  }
378
+ setActivePage(page) {
379
+ if (this.activePage === page) return;
380
+ this.activePage = page;
381
+ for (const listener of this.activePageListeners) try {
382
+ Promise.resolve(listener(page)).catch((error) => {
383
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
384
+ });
385
+ } catch (error) {
386
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
387
+ }
388
+ }
389
+ async preparePage(page) {
390
+ if (this.preparedPages.has(page)) return;
391
+ this.preparedPages.add(page);
392
+ const policy = this.options.isNavigationAllowed;
393
+ if (policy === void 0) return;
394
+ try {
395
+ await page.setRequestInterception(true);
396
+ page.on("request", (request) => {
397
+ if (request.isInterceptResolutionHandled()) return;
398
+ let allowed = false;
399
+ try {
400
+ allowed = policy(request.url());
401
+ } catch {}
402
+ (allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
403
+ });
404
+ } catch (error) {
405
+ this.preparedPages.delete(page);
406
+ throw error;
407
+ }
408
+ }
409
+ isLaunchCurrent(generation, browser) {
410
+ return generation === this.generation && browser.connected;
411
+ }
138
412
  };
139
413
  //#endregion
140
414
  //#region src/screencast-pump.ts
@@ -149,6 +423,12 @@ var ScreencastPump = class {
149
423
  running = false;
150
424
  frameSeq = 0;
151
425
  outstanding = null;
426
+ generation = 0;
427
+ viewport = {
428
+ width: 1280,
429
+ height: 720,
430
+ dpr: 1
431
+ };
152
432
  log;
153
433
  quality;
154
434
  ackTimeoutMs;
@@ -157,6 +437,8 @@ var ScreencastPump = class {
157
437
  this.log = options.logger ?? noopLogger$1;
158
438
  this.quality = options.quality ?? 70;
159
439
  this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
440
+ if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
441
+ 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
442
  }
161
443
  get isRunning() {
162
444
  return this.running;
@@ -164,33 +446,60 @@ var ScreencastPump = class {
164
446
  /** Start (or restart) the screencast on the given page. */
165
447
  async start(page, viewport) {
166
448
  await this.stop();
449
+ const generation = ++this.generation;
450
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
167
451
  const cdp = await page.createCDPSession();
452
+ if (generation !== this.generation) {
453
+ await cdp.detach().catch(() => void 0);
454
+ return;
455
+ }
168
456
  this.cdp = cdp;
169
- await cdp.send("Emulation.setDeviceMetricsOverride", {
170
- width: viewport.width,
171
- height: viewport.height,
172
- deviceScaleFactor: viewport.dpr,
173
- mobile: false
174
- });
175
- cdp.on("Page.screencastFrame", (event) => {
176
- this.onCdpFrame(event.data, event.sessionId, event.metadata);
177
- });
178
- await cdp.send("Page.startScreencast", {
179
- format: "jpeg",
180
- quality: this.quality,
181
- maxWidth: Math.round(viewport.width * viewport.dpr),
182
- maxHeight: Math.round(viewport.height * viewport.dpr),
183
- everyNthFrame: 1
184
- });
185
- this.running = true;
186
- this.log.debug("Screencast started");
457
+ try {
458
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
459
+ width: this.viewport.width,
460
+ height: this.viewport.height,
461
+ deviceScaleFactor: this.viewport.dpr,
462
+ mobile: false
463
+ });
464
+ if (generation !== this.generation || this.cdp !== cdp) return;
465
+ cdp.on("Page.screencastFrame", (event) => {
466
+ if (generation !== this.generation || this.cdp !== cdp) return;
467
+ this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
468
+ });
469
+ await cdp.send("Page.startScreencast", {
470
+ format: "jpeg",
471
+ quality: this.quality,
472
+ maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
473
+ maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
474
+ everyNthFrame: 1
475
+ });
476
+ if (generation !== this.generation || this.cdp !== cdp) return;
477
+ this.running = true;
478
+ this.log.debug("Screencast started");
479
+ } catch (error) {
480
+ if (this.cdp === cdp) this.cdp = null;
481
+ await cdp.detach().catch(() => void 0);
482
+ throw error;
483
+ }
187
484
  }
188
- onCdpFrame(dataBase64, cdpSessionId, metadata) {
485
+ onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
189
486
  if (this.outstanding) this.ackCdp(this.outstanding);
190
- const frameSeq = ++this.frameSeq;
487
+ if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
488
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
489
+ this.log.warn("Dropped oversized browser screencast frame");
490
+ return;
491
+ }
492
+ const jpeg = Buffer.from(dataBase64, "base64");
493
+ if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
494
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
495
+ this.log.warn("Dropped invalid browser screencast frame");
496
+ return;
497
+ }
498
+ this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
499
+ const frameSeq = this.frameSeq;
191
500
  const meta = {
192
- deviceWidth: metadata.deviceWidth ?? 0,
193
- deviceHeight: metadata.deviceHeight ?? 0,
501
+ deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
502
+ deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
194
503
  frameSeq,
195
504
  offsetTop: metadata.offsetTop,
196
505
  pageScaleFactor: metadata.pageScaleFactor,
@@ -202,11 +511,19 @@ var ScreencastPump = class {
202
511
  }, this.ackTimeoutMs);
203
512
  timer.unref();
204
513
  this.outstanding = {
514
+ cdp,
515
+ generation,
205
516
  cdpSessionId,
206
517
  frameSeq,
207
518
  timer
208
519
  };
209
- this.options.onFrame(Buffer.from(dataBase64, "base64"), meta);
520
+ try {
521
+ this.options.onFrame(jpeg, meta);
522
+ } catch (error) {
523
+ this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
524
+ const outstanding = this.outstanding;
525
+ if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
526
+ }
210
527
  }
211
528
  /** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
212
529
  ackFromViewer(frameSeq) {
@@ -215,9 +532,10 @@ var ScreencastPump = class {
215
532
  ackCdp(outstanding) {
216
533
  clearTimeout(outstanding.timer);
217
534
  this.outstanding = null;
218
- if (this.cdp && this.running) this.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
535
+ if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
219
536
  }
220
537
  async stop() {
538
+ this.generation += 1;
221
539
  this.running = false;
222
540
  if (this.outstanding) {
223
541
  clearTimeout(this.outstanding.timer);
@@ -242,17 +560,64 @@ const MOUSE_TYPE = {
242
560
  mousepressed: "mousePressed",
243
561
  mousereleased: "mouseReleased"
244
562
  };
563
+ const VIRTUAL_KEYS = {
564
+ Backspace: 8,
565
+ Tab: 9,
566
+ Enter: 13,
567
+ NumpadEnter: 13,
568
+ Shift: 16,
569
+ Control: 17,
570
+ Alt: 18,
571
+ Pause: 19,
572
+ CapsLock: 20,
573
+ Escape: 27,
574
+ Space: 32,
575
+ " ": 32,
576
+ PageUp: 33,
577
+ PageDown: 34,
578
+ End: 35,
579
+ Home: 36,
580
+ ArrowLeft: 37,
581
+ ArrowUp: 38,
582
+ ArrowRight: 39,
583
+ ArrowDown: 40,
584
+ Insert: 45,
585
+ Delete: 46,
586
+ Meta: 91
587
+ };
588
+ function virtualKeyCode(input) {
589
+ const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
590
+ if (typeof named === "number") return named;
591
+ const letter = /^Key([A-Z])$/.exec(input.code ?? "");
592
+ if (letter) return letter[1].charCodeAt(0);
593
+ const digit = /^Digit([0-9])$/.exec(input.code ?? "");
594
+ if (digit) return digit[1].charCodeAt(0);
595
+ const key = input.key ?? "";
596
+ if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
597
+ const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
598
+ if (functionKey) return 111 + Number(functionKey[1]);
599
+ }
245
600
  var InputInjector = class {
246
601
  cdp = null;
247
602
  viewport;
603
+ generation = 0;
248
604
  constructor(viewport) {
249
- this.viewport = viewport;
605
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
250
606
  }
251
607
  async attach(page) {
252
- await this.detach();
253
- this.cdp = await page.createCDPSession();
608
+ const generation = ++this.generation;
609
+ const old = this.cdp;
610
+ this.cdp = null;
611
+ if (old) await old.detach().catch(() => void 0);
612
+ const cdp = await page.createCDPSession();
613
+ if (generation !== this.generation) {
614
+ await cdp.detach().catch(() => void 0);
615
+ return;
616
+ }
617
+ this.cdp = cdp;
254
618
  }
255
619
  async detach() {
620
+ this.generation += 1;
256
621
  const cdp = this.cdp;
257
622
  this.cdp = null;
258
623
  if (cdp) try {
@@ -260,12 +625,12 @@ var InputInjector = class {
260
625
  } catch {}
261
626
  }
262
627
  updateViewport(viewport) {
263
- this.viewport = viewport;
628
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
264
629
  }
265
630
  toCssPx(nx, ny) {
266
631
  return {
267
- x: Math.round(clamp01(nx) * this.viewport.width),
268
- y: Math.round(clamp01(ny) * this.viewport.height)
632
+ x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
633
+ y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
269
634
  };
270
635
  }
271
636
  mouse(p) {
@@ -296,19 +661,18 @@ var InputInjector = class {
296
661
  key(p) {
297
662
  if (!this.cdp) return;
298
663
  if (p.type === "char") {
299
- this.cdp.send("Input.dispatchKeyEvent", {
300
- type: "char",
301
- text: p.text ?? "",
302
- modifiers: p.modifiers ?? 0
303
- }).catch(() => {});
664
+ this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
304
665
  return;
305
666
  }
667
+ const modifiers = p.modifiers ?? 0;
668
+ const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
306
669
  this.cdp.send("Input.dispatchKeyEvent", {
307
- type: p.type === "keydown" ? "keyDown" : "keyUp",
670
+ type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
308
671
  key: p.key,
309
672
  code: p.code,
310
- text: p.type === "keydown" ? p.text : void 0,
311
- modifiers: p.modifiers ?? 0
673
+ text,
674
+ windowsVirtualKeyCode: virtualKeyCode(p),
675
+ modifiers
312
676
  }).catch(() => {});
313
677
  }
314
678
  };
@@ -318,64 +682,129 @@ function clamp01(n) {
318
682
  }
319
683
  //#endregion
320
684
  //#region src/automation.ts
685
+ const OPERATION_TIMEOUT_MS = 3e4;
321
686
  var BrowserAutomation = class {
687
+ operationTail = Promise.resolve();
688
+ stopping = new AbortController();
322
689
  constructor(session, turn, isNavigationAllowed) {
323
690
  this.session = session;
324
691
  this.turn = turn;
325
692
  this.isNavigationAllowed = isNavigationAllowed;
326
693
  }
327
694
  async navigate(url) {
328
- await this.turn.acquireAgent();
329
- if (!this.isNavigationAllowed(url)) throw new Error(`Navigation to ${url} blocked by SSRF policy`);
330
- const page = await this.session.getActivePage();
331
- await page.goto(url, { waitUntil: "domcontentloaded" });
332
- return {
333
- url: page.url(),
334
- title: await page.title()
335
- };
695
+ return this.run(async () => {
696
+ const target = validateNavigationUrl(url);
697
+ let allowed = false;
698
+ try {
699
+ allowed = this.isNavigationAllowed(target);
700
+ } catch {}
701
+ if (!allowed) throw new Error("Navigation blocked by browser policy");
702
+ const page = await this.session.getActivePage();
703
+ await page.goto(target, {
704
+ waitUntil: "domcontentloaded",
705
+ timeout: OPERATION_TIMEOUT_MS
706
+ });
707
+ return {
708
+ url: page.url(),
709
+ title: await page.title()
710
+ };
711
+ });
336
712
  }
337
713
  async click(selector) {
338
- await this.turn.acquireAgent();
339
- await (await this.session.getActivePage()).click(selector);
714
+ await this.run(async () => {
715
+ await (await this.session.getActivePage()).click(validateSelector(selector));
716
+ });
340
717
  }
341
718
  async type(selector, text) {
342
- await this.turn.acquireAgent();
343
- await (await this.session.getActivePage()).type(selector, text);
719
+ await this.run(async () => {
720
+ await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
721
+ });
344
722
  }
345
723
  async waitFor(opts) {
346
- await this.turn.acquireAgent();
347
- const page = await this.session.getActivePage();
348
- if (opts.selector) await page.waitForSelector(opts.selector);
349
- else if (opts.urlPattern) {
350
- const pattern = opts.urlPattern;
351
- await page.waitForFunction((p) => window.location.href.includes(p), {}, pattern);
352
- } else if (typeof opts.ms === "number") await new Promise((resolve) => setTimeout(resolve, opts.ms));
724
+ await this.run(async () => {
725
+ const normalized = validateWaitOptions(opts);
726
+ const page = await this.session.getActivePage();
727
+ if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
728
+ else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
729
+ else await new Promise((resolve) => {
730
+ setTimeout(resolve, normalized.ms ?? 0).unref();
731
+ });
732
+ });
353
733
  }
354
734
  /** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
355
735
  * from the continuous screencast stream to viewers. */
356
736
  async screenshot() {
357
- await this.turn.acquireAgent();
358
- return (await this.session.getActivePage()).screenshot({
359
- type: "jpeg",
360
- quality: 70,
361
- encoding: "base64"
737
+ return this.run(async () => {
738
+ const image = await (await this.session.getActivePage()).screenshot({
739
+ type: "jpeg",
740
+ quality: 70,
741
+ encoding: "base64"
742
+ });
743
+ if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
744
+ return image;
362
745
  });
363
746
  }
364
747
  /** Evaluate an expression in the page context via CDP (no eval on our side). */
365
748
  async evaluate(expression) {
366
- await this.turn.acquireAgent();
367
- const cdp = await (await this.session.getActivePage()).createCDPSession();
368
- try {
369
- return (await cdp.send("Runtime.evaluate", {
370
- expression,
371
- returnByValue: true,
372
- awaitPromise: true
373
- })).result.value;
374
- } finally {
749
+ return this.run(async () => {
750
+ const cdp = await (await this.session.getActivePage()).createCDPSession();
375
751
  try {
376
- await cdp.detach();
377
- } catch {}
378
- }
752
+ const res = await cdp.send("Runtime.evaluate", {
753
+ expression: validateExpression(expression),
754
+ returnByValue: true,
755
+ awaitPromise: true,
756
+ timeout: OPERATION_TIMEOUT_MS,
757
+ disableBreaks: true
758
+ });
759
+ if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
760
+ return assertBoundedAutomationResult(res.result.value ?? null);
761
+ } finally {
762
+ try {
763
+ await cdp.detach();
764
+ } catch {}
765
+ }
766
+ });
767
+ }
768
+ /** Wait until all agent operations that were already queued have settled. */
769
+ async waitUntilIdle() {
770
+ await this.operationTail;
771
+ }
772
+ /** Trusted local adapters share the built-in automation queue and exact page.
773
+ * The callback must disconnect/await children in finally, including on abort.
774
+ * Do not invoke another automation operation or handoff from the callback. */
775
+ withCdpOperation(operation, options = {}) {
776
+ return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
777
+ }
778
+ /** Insert the claim at a precise queue position; later automation parks. */
779
+ async yieldToHuman(grant) {
780
+ await this.run(() => {
781
+ grant();
782
+ return Promise.resolve();
783
+ });
784
+ }
785
+ /** Abort first, then await callback cleanup before the owner closes Chrome. */
786
+ async shutdown() {
787
+ this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
788
+ this.turn.releaseHuman();
789
+ await this.operationTail;
790
+ }
791
+ run(operation, options = {}) {
792
+ const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
793
+ const result = this.operationTail.catch(() => void 0).then(async () => {
794
+ signal.throwIfAborted();
795
+ this.session.addHold();
796
+ try {
797
+ await this.turn.acquireAgent(signal);
798
+ signal.throwIfAborted();
799
+ const result = await operation(signal);
800
+ signal.throwIfAborted();
801
+ return result;
802
+ } finally {
803
+ this.session.removeHold();
804
+ }
805
+ });
806
+ this.operationTail = result.then(() => void 0, () => void 0);
807
+ return result;
379
808
  }
380
809
  };
381
810
  //#endregion
@@ -410,12 +839,29 @@ var BrowserSurface = class {
410
839
  dpr: 1
411
840
  };
412
841
  handoff = null;
842
+ controllerSessionId = null;
843
+ pendingControllerSessionId = null;
844
+ claimGeneration = 0;
845
+ streamGeneration = 0;
846
+ streamQueue = Promise.resolve();
847
+ closed = false;
848
+ removePageListener;
413
849
  log;
414
850
  constructor(options, sendFrame) {
415
- this.options = options;
416
851
  this.sendFrame = sendFrame;
417
852
  this.log = options.logger ?? noopLogger;
418
- this.session = new BrowserSession(options);
853
+ const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
854
+ try {
855
+ validateNavigationUrl(url);
856
+ return true;
857
+ } catch {
858
+ return false;
859
+ }
860
+ });
861
+ this.session = new BrowserSession({
862
+ ...options,
863
+ isNavigationAllowed: navigationPolicy
864
+ });
419
865
  this.turn = new TurnController({ onOwnerChange: () => {
420
866
  this.broadcastState();
421
867
  } });
@@ -426,56 +872,93 @@ var BrowserSurface = class {
426
872
  logger: this.log
427
873
  });
428
874
  this.injector = new InputInjector(this.viewport);
429
- this.automation = new BrowserAutomation(this.session, this.turn, options.isNavigationAllowed ?? (() => true));
875
+ this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
876
+ this.removePageListener = this.session.onActivePageChange(async () => {
877
+ if (this.viewers.size > 0) {
878
+ await this.restartStreaming();
879
+ this.broadcastState();
880
+ }
881
+ });
430
882
  }
431
883
  async openSession(sessionId, open) {
884
+ if (this.closed) throw new Error("Browser surface is shut down");
885
+ if (this.viewers.has(sessionId)) {
886
+ this.sendState(sessionId);
887
+ return;
888
+ }
889
+ const firstViewer = this.viewers.size === 0;
432
890
  this.viewers.add(sessionId);
433
891
  this.session.addHold();
434
- if (open.width && open.height) this.viewport = {
435
- width: open.width,
436
- height: open.height,
437
- dpr: open.dpr ?? 1
438
- };
439
- await this.ensureStreaming();
440
- this.sendState(sessionId);
892
+ try {
893
+ if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
894
+ if (firstViewer) await this.restartStreaming();
895
+ this.sendState(sessionId);
896
+ } catch (error) {
897
+ this.viewers.delete(sessionId);
898
+ this.session.removeHold();
899
+ if (this.viewers.size === 0) {
900
+ this.streamGeneration += 1;
901
+ this.enqueueStreamCleanup();
902
+ }
903
+ throw error;
904
+ }
441
905
  }
442
906
  handleFrame(frame) {
907
+ if (this.closed || !this.viewers.has(frame.sessionId)) return;
443
908
  switch (frame.type) {
444
909
  case RemoteFrameType.SCREENCAST_ACK: {
445
- const ack = decodeJson(frame.payload);
910
+ const ack = decodeScreencastAckPayload(frame.payload);
446
911
  if (ack) this.pump.ackFromViewer(ack.frameSeq);
447
912
  this.session.touch();
448
913
  break;
449
914
  }
450
915
  case RemoteFrameType.RESIZE: {
451
- const r = decodeJson(frame.payload);
452
- if (r) this.applyResize(r.width, r.height, r.dpr ?? 1);
916
+ const resize = decodeResizePayload(frame.payload);
917
+ if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
453
918
  break;
454
919
  }
455
920
  case RemoteFrameType.INPUT_MOUSE:
456
- if (this.turn.humanInControl) {
457
- const p = decodeJson(frame.payload);
458
- if (p) this.injector.mouse(p);
921
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
922
+ const input = decodeMouseInputPayload(frame.payload);
923
+ if (input) this.injector.mouse(input);
459
924
  }
460
925
  break;
461
926
  case RemoteFrameType.INPUT_WHEEL:
462
- if (this.turn.humanInControl) {
463
- const p = decodeJson(frame.payload);
464
- if (p) this.injector.wheel(p);
927
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
928
+ const input = decodeWheelInputPayload(frame.payload);
929
+ if (input) this.injector.wheel(input);
465
930
  }
466
931
  break;
467
932
  case RemoteFrameType.INPUT_KEY:
468
- if (this.turn.humanInControl) {
469
- const p = decodeJson(frame.payload);
470
- if (p) this.injector.key(p);
933
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
934
+ const input = decodeKeyInputPayload(frame.payload);
935
+ if (input) this.injector.key(input);
471
936
  }
472
937
  break;
473
- case RemoteFrameType.TAKEOVER_REQUEST:
474
- if (this.turn.grantHuman()) this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
475
- else this.sendFrame(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
938
+ case RemoteFrameType.TAKEOVER_REQUEST: {
939
+ if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
940
+ this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
941
+ break;
942
+ }
943
+ this.pendingControllerSessionId = frame.sessionId;
944
+ const claimGeneration = ++this.claimGeneration;
945
+ this.automation.yieldToHuman(() => {
946
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
947
+ this.pendingControllerSessionId = null;
948
+ if (this.turn.grantHuman()) {
949
+ this.controllerSessionId = frame.sessionId;
950
+ this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
951
+ } else this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
952
+ }).catch((error) => {
953
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
954
+ this.pendingControllerSessionId = null;
955
+ this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
956
+ this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
957
+ });
476
958
  break;
959
+ }
477
960
  case RemoteFrameType.RELEASE_CONTROL:
478
- this.releaseToAgent();
961
+ if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
479
962
  break;
480
963
  default: break;
481
964
  }
@@ -483,9 +966,14 @@ var BrowserSurface = class {
483
966
  closeSession(sessionId) {
484
967
  if (!this.viewers.delete(sessionId)) return;
485
968
  this.session.removeHold();
969
+ if (this.pendingControllerSessionId === sessionId) {
970
+ this.pendingControllerSessionId = null;
971
+ this.claimGeneration += 1;
972
+ }
973
+ if (this.controllerSessionId === sessionId) this.releaseToAgent();
486
974
  if (this.viewers.size === 0) {
487
- this.pump.stop();
488
- if (this.turn.humanInControl) this.releaseToAgent();
975
+ this.streamGeneration += 1;
976
+ this.enqueueStreamCleanup();
489
977
  }
490
978
  }
491
979
  /**
@@ -503,17 +991,28 @@ var BrowserSurface = class {
503
991
  * under the user (mislabeled 409 on the real claim; agent only resuming when
504
992
  * the tab closed).
505
993
  */
506
- async requestHandoff(timeoutMs) {
994
+ async requestHandoff(timeoutMs, signal) {
995
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
996
+ if (this.closed) throw new Error("Browser surface is shut down");
997
+ signal?.throwIfAborted();
507
998
  if (this.handoff) this.releaseToAgent();
508
- return new Promise((resolve) => {
999
+ return new Promise((resolve, reject) => {
1000
+ const settle = (result) => {
1001
+ signal?.removeEventListener("abort", onAbort);
1002
+ resolve(result);
1003
+ };
509
1004
  const timer = setTimeout(() => {
1005
+ if (this.handoff !== waiter) return;
510
1006
  this.handoff = null;
1007
+ this.pendingControllerSessionId = null;
1008
+ this.claimGeneration += 1;
511
1009
  if (this.turn.humanInControl) {
512
1010
  this.turn.releaseHuman();
1011
+ this.controllerSessionId = null;
513
1012
  this.broadcast(RemoteFrameType.CONTROL_REVOKED);
514
1013
  }
515
1014
  this.currentPageInfo().then(({ url, title }) => {
516
- resolve({
1015
+ settle({
517
1016
  released: false,
518
1017
  timedOut: true,
519
1018
  url,
@@ -522,10 +1021,27 @@ var BrowserSurface = class {
522
1021
  });
523
1022
  }, timeoutMs);
524
1023
  timer.unref();
525
- this.handoff = {
526
- resolve,
1024
+ const waiter = {
1025
+ resolve: settle,
527
1026
  timer
528
1027
  };
1028
+ const onAbort = () => {
1029
+ if (this.handoff !== waiter) return;
1030
+ this.handoff = null;
1031
+ clearTimeout(timer);
1032
+ signal?.removeEventListener("abort", onAbort);
1033
+ this.pendingControllerSessionId = null;
1034
+ this.claimGeneration += 1;
1035
+ if (this.turn.humanInControl) {
1036
+ this.turn.releaseHuman();
1037
+ this.controllerSessionId = null;
1038
+ this.broadcast(RemoteFrameType.CONTROL_REVOKED);
1039
+ }
1040
+ reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
1041
+ };
1042
+ this.handoff = waiter;
1043
+ signal?.addEventListener("abort", onAbort, { once: true });
1044
+ if (signal?.aborted) onAbort();
529
1045
  });
530
1046
  }
531
1047
  /**
@@ -541,11 +1057,33 @@ var BrowserSurface = class {
541
1057
  this.session.removeHold();
542
1058
  }
543
1059
  async shutdown() {
544
- await this.pump.stop();
545
- await this.injector.detach();
1060
+ if (this.closed) return;
1061
+ this.closed = true;
1062
+ this.removePageListener();
1063
+ this.streamGeneration += 1;
1064
+ this.pendingControllerSessionId = null;
1065
+ this.claimGeneration += 1;
1066
+ this.controllerSessionId = null;
1067
+ const waiter = this.handoff;
1068
+ this.handoff = null;
1069
+ if (waiter) {
1070
+ clearTimeout(waiter.timer);
1071
+ waiter.resolve({
1072
+ released: false,
1073
+ timedOut: true,
1074
+ url: "",
1075
+ title: ""
1076
+ });
1077
+ }
1078
+ this.turn.releaseHuman();
1079
+ await this.automation.shutdown();
1080
+ await this.enqueueStreamCleanup();
546
1081
  await this.session.shutdown();
547
1082
  }
548
1083
  releaseToAgent() {
1084
+ this.pendingControllerSessionId = null;
1085
+ this.claimGeneration += 1;
1086
+ this.controllerSessionId = null;
549
1087
  this.turn.releaseHuman();
550
1088
  const waiter = this.handoff;
551
1089
  this.handoff = null;
@@ -561,51 +1099,82 @@ var BrowserSurface = class {
561
1099
  });
562
1100
  }
563
1101
  }
564
- async ensureStreaming() {
565
- if (this.pump.isRunning || this.viewers.size === 0) return;
566
- const page = await this.session.getActivePage();
567
- await this.injector.attach(page);
568
- this.injector.updateViewport(this.viewport);
569
- await this.pump.start(page, this.viewport);
1102
+ restartStreaming() {
1103
+ const generation = ++this.streamGeneration;
1104
+ const run = this.streamQueue.catch(() => void 0).then(async () => {
1105
+ if (!this.shouldStream(generation)) return;
1106
+ try {
1107
+ const page = await this.session.getActivePage();
1108
+ if (!this.shouldStream(generation)) return;
1109
+ await this.injector.attach(page);
1110
+ this.injector.updateViewport(this.viewport);
1111
+ await this.pump.start(page, this.viewport);
1112
+ if (!this.shouldStream(generation)) {
1113
+ await this.pump.stop();
1114
+ await this.injector.detach();
1115
+ }
1116
+ } catch (error) {
1117
+ await this.pump.stop();
1118
+ await this.injector.detach();
1119
+ throw error;
1120
+ }
1121
+ });
1122
+ this.streamQueue = run.catch((error) => {
1123
+ this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
1124
+ });
1125
+ return run;
1126
+ }
1127
+ enqueueStreamCleanup() {
1128
+ const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
1129
+ await this.pump.stop();
1130
+ await this.injector.detach();
1131
+ });
1132
+ this.streamQueue = cleanup.catch(() => void 0);
1133
+ return cleanup;
1134
+ }
1135
+ shouldStream(generation) {
1136
+ return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
570
1137
  }
571
1138
  async applyResize(width, height, dpr) {
572
- this.viewport = {
573
- width,
574
- height,
575
- dpr
576
- };
1139
+ this.viewport = normalizeViewport(width, height, dpr);
577
1140
  this.injector.updateViewport(this.viewport);
578
- if (this.pump.isRunning) {
579
- const page = await this.session.getActivePage();
580
- await this.pump.start(page, this.viewport);
581
- }
1141
+ if (this.viewers.size > 0) await this.restartStreaming();
582
1142
  }
583
1143
  broadcastFrame(jpeg, meta) {
584
- for (const sessionId of this.viewers) this.sendFrame(encodeScreencastFrame(sessionId, meta, jpeg));
1144
+ for (const sessionId of this.viewers) try {
1145
+ this.sendFrameSafe(encodeScreencastFrame(sessionId, meta, jpeg));
1146
+ } catch (error) {
1147
+ this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
1148
+ }
585
1149
  }
586
1150
  broadcast(type) {
587
- for (const sessionId of this.viewers) this.sendFrame(encodeFrame(type, sessionId));
1151
+ for (const sessionId of this.viewers) this.sendFrameSafe(encodeFrame(type, sessionId));
588
1152
  }
589
1153
  broadcastState() {
590
1154
  for (const sessionId of this.viewers) this.sendState(sessionId);
591
1155
  }
592
1156
  sendState(sessionId) {
593
1157
  this.currentPageInfo().then(({ url, title }) => {
1158
+ if (!this.viewers.has(sessionId) || this.closed) return;
594
1159
  const state = {
595
1160
  surface: "browser",
596
1161
  url,
597
1162
  title,
598
1163
  controller: this.turn.currentOwner
599
1164
  };
600
- this.sendFrame(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
1165
+ try {
1166
+ this.sendFrameSafe(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
1167
+ } catch (error) {
1168
+ this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
1169
+ }
601
1170
  });
602
1171
  }
603
1172
  async currentPageInfo() {
604
1173
  try {
605
1174
  const page = await this.session.getActivePage();
606
1175
  return {
607
- url: page.url(),
608
- title: await page.title()
1176
+ url: page.url().slice(0, 8192),
1177
+ title: (await page.title()).slice(0, 4096)
609
1178
  };
610
1179
  } catch {
611
1180
  return {
@@ -614,6 +1183,13 @@ var BrowserSurface = class {
614
1183
  };
615
1184
  }
616
1185
  }
1186
+ sendFrameSafe(frame) {
1187
+ try {
1188
+ this.sendFrame(frame);
1189
+ } catch (error) {
1190
+ this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
1191
+ }
1192
+ }
617
1193
  };
618
1194
  //#endregion
619
1195
  export { BrowserAutomation, BrowserSession, BrowserSurface };