@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.cjs CHANGED
@@ -24,6 +24,135 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  let _alfe_ai_remote = require("@alfe.ai/remote");
25
25
  let puppeteer_core = require("puppeteer-core");
26
26
  puppeteer_core = __toESM(puppeteer_core);
27
+ //#region src/boundary.ts
28
+ const MAX_VIEWPORT_WIDTH = 4096;
29
+ const MAX_VIEWPORT_HEIGHT = 4096;
30
+ const MAX_SELECTOR_CHARS = 4096;
31
+ const MAX_EXPRESSION_CHARS = 128 * 1024;
32
+ const MAX_NAVIGATION_URL_CHARS = 8192;
33
+ const MAX_WAIT_MS = 12e4;
34
+ const MAX_RESULT_BYTES = 1024 * 1024;
35
+ const MAX_RESULT_DEPTH = 10;
36
+ const MAX_RESULT_NODES = 2e4;
37
+ const MAX_RESULT_ARRAY_ITEMS = 1e4;
38
+ const MAX_RESULT_STRING_CHARS = 25e4;
39
+ const UNSAFE_KEYS = new Set([
40
+ "__proto__",
41
+ "constructor",
42
+ "prototype"
43
+ ]);
44
+ const FORBIDDEN_CHROME_ARG_PREFIXES = [
45
+ "--allow-file-access-from-files",
46
+ "--disable-web-security",
47
+ "--host-resolver-rules",
48
+ "--load-extension",
49
+ "--remote-debugging-address",
50
+ "--remote-debugging-pipe",
51
+ "--remote-debugging-port",
52
+ "--user-data-dir"
53
+ ];
54
+ function normalizeViewport(width, height, dpr) {
55
+ if (!Number.isInteger(width) || width < 1 || width > 4096) throw new Error(`viewport width must be an integer from 1 to ${String(MAX_VIEWPORT_WIDTH)}`);
56
+ if (!Number.isInteger(height) || height < 1 || height > 4096) throw new Error(`viewport height must be an integer from 1 to ${String(MAX_VIEWPORT_HEIGHT)}`);
57
+ if (!Number.isFinite(dpr) || dpr < .1 || dpr > 4) throw new Error(`viewport dpr must be from 0.1 to ${String(4)}`);
58
+ if (width * height * dpr * dpr > 33554432) throw new Error("viewport exceeds the device-pixel budget");
59
+ return {
60
+ width,
61
+ height,
62
+ dpr
63
+ };
64
+ }
65
+ function validateIdleShutdownMs(value) {
66
+ if (!Number.isInteger(value) || value < 1e3 || value > 1440 * 60 * 1e3) throw new Error("idle shutdown must be an integer from 1000 to 86400000ms");
67
+ return value;
68
+ }
69
+ function validateChromeArgs(values) {
70
+ if (values.length > 64) throw new Error("too many extra Chrome arguments");
71
+ return values.map((value) => {
72
+ const arg = validateString("Chrome argument", value, 4096);
73
+ const key = arg.split("=", 1)[0]?.toLowerCase() ?? "";
74
+ if (FORBIDDEN_CHROME_ARG_PREFIXES.includes(key)) throw new Error(`Chrome argument ${key} is owned by the browser runtime`);
75
+ return arg;
76
+ });
77
+ }
78
+ function validateNavigationUrl(value) {
79
+ const raw = validateString("navigation URL", value, MAX_NAVIGATION_URL_CHARS);
80
+ let parsed;
81
+ try {
82
+ parsed = new URL(raw);
83
+ } catch {
84
+ throw new Error("navigation URL must be an absolute HTTP(S) URL");
85
+ }
86
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") throw new Error("navigation URL must be an absolute HTTP(S) URL without credentials");
87
+ return parsed.href;
88
+ }
89
+ function validateSelector(value) {
90
+ return validateString("selector", value, MAX_SELECTOR_CHARS);
91
+ }
92
+ function validateTypeText(value) {
93
+ if (value.length > 32768) throw new Error("type text exceeds the character limit");
94
+ return value;
95
+ }
96
+ function validateExpression(value) {
97
+ return validateString("expression", value, MAX_EXPRESSION_CHARS);
98
+ }
99
+ function validateWaitOptions(opts) {
100
+ if ([
101
+ opts.selector !== void 0,
102
+ opts.ms !== void 0,
103
+ opts.urlPattern !== void 0
104
+ ].filter(Boolean).length !== 1) throw new Error("waitFor requires exactly one selector, ms, or urlPattern");
105
+ if (opts.selector !== void 0) return { selector: validateSelector(opts.selector) };
106
+ if (opts.urlPattern !== void 0) return { urlPattern: validateString("URL pattern", opts.urlPattern, MAX_NAVIGATION_URL_CHARS) };
107
+ if (!Number.isInteger(opts.ms) || (opts.ms ?? 0) < 0 || (opts.ms ?? 0) > 12e4) throw new Error(`wait duration must be an integer from 0 to ${String(MAX_WAIT_MS)}ms`);
108
+ return { ms: opts.ms };
109
+ }
110
+ function assertBoundedAutomationResult(value) {
111
+ cloneJson(value, 0, { nodes: 0 });
112
+ let encoded;
113
+ try {
114
+ encoded = JSON.stringify(value);
115
+ } catch {
116
+ throw new Error("browser evaluation result must be JSON serializable");
117
+ }
118
+ if (Buffer.byteLength(encoded, "utf8") > MAX_RESULT_BYTES) throw new Error("browser evaluation result exceeds the byte limit");
119
+ return value;
120
+ }
121
+ function validateString(label, value, maxChars) {
122
+ if (value.length < 1 || value.length > maxChars || containsControlCharacter(value)) throw new Error(`${label} must contain 1 to ${String(maxChars)} non-control characters`);
123
+ return value;
124
+ }
125
+ function cloneJson(value, depth, state) {
126
+ state.nodes += 1;
127
+ if (state.nodes > MAX_RESULT_NODES) throw new Error("browser evaluation result contains too many values");
128
+ if (depth > MAX_RESULT_DEPTH) throw new Error("browser evaluation result exceeds the depth limit");
129
+ if (value === null || typeof value === "boolean") return value;
130
+ if (typeof value === "number") {
131
+ if (!Number.isFinite(value)) throw new Error("browser evaluation result contains a non-finite number");
132
+ return value;
133
+ }
134
+ if (typeof value === "string") {
135
+ if (value.length > MAX_RESULT_STRING_CHARS) throw new Error("browser evaluation result contains an oversized string");
136
+ return value;
137
+ }
138
+ if (Array.isArray(value)) {
139
+ if (value.length > MAX_RESULT_ARRAY_ITEMS) throw new Error("browser evaluation result contains an oversized array");
140
+ return value.map((entry) => cloneJson(entry, depth + 1, state));
141
+ }
142
+ if (typeof value !== "object") throw new Error("browser evaluation result contains a non-JSON value");
143
+ for (const [key, nested] of Object.entries(value)) {
144
+ if (UNSAFE_KEYS.has(key) || key.length < 1 || key.length > 256) throw new Error("browser evaluation result contains an unsafe property name");
145
+ cloneJson(nested, depth + 1, state);
146
+ }
147
+ return value;
148
+ }
149
+ function containsControlCharacter(value) {
150
+ return Array.from(value).some((character) => {
151
+ const codePoint = character.codePointAt(0) ?? 0;
152
+ return codePoint < 32 || codePoint === 127;
153
+ });
154
+ }
155
+ //#endregion
27
156
  //#region src/browser-session.ts
28
157
  /**
29
158
  * BrowserSession — owns the single headless Chrome instance the agent
@@ -53,50 +182,79 @@ var BrowserSession = class {
53
182
  launching = null;
54
183
  holds = 0;
55
184
  idleTimer = null;
185
+ generation = 0;
186
+ preparedPages = /* @__PURE__ */ new WeakSet();
187
+ activePageListeners = /* @__PURE__ */ new Set();
56
188
  log;
57
189
  idleMs;
58
190
  constructor(options) {
59
191
  this.options = options;
60
192
  this.log = options.logger ?? noopLogger$2;
61
- this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
193
+ this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
62
194
  }
63
195
  /** Launch Chrome if not already running (idempotent, concurrent-safe). */
64
196
  async ensureLaunched() {
65
197
  if (this.browser) return;
66
198
  if (this.launching) return this.launching;
67
- this.launching = this.doLaunch().finally(() => {
68
- this.launching = null;
199
+ const generation = ++this.generation;
200
+ const launching = this.doLaunch(generation).finally(() => {
201
+ if (this.launching === launching) this.launching = null;
69
202
  });
70
- return this.launching;
203
+ this.launching = launching;
204
+ return launching;
71
205
  }
72
- async doLaunch() {
206
+ async doLaunch(generation) {
73
207
  const args = [
74
208
  "--disable-blink-features=AutomationControlled",
75
209
  ...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
76
- ...this.options.extraArgs ?? []
210
+ ...validateChromeArgs(this.options.extraArgs ?? [])
77
211
  ];
78
212
  this.log.info(`Launching Chrome (${this.options.executablePath})`);
79
- this.browser = await puppeteer_core.default.launch({
213
+ const browser = await puppeteer_core.default.launch({
80
214
  executablePath: this.options.executablePath,
81
215
  headless: this.options.headless ?? true,
82
216
  userDataDir: this.options.userDataDir,
83
217
  args
84
218
  });
85
- this.activePage = (await this.browser.pages())[0] ?? await this.browser.newPage();
86
- this.browser.on("targetcreated", (target) => {
87
- if (target.type() !== puppeteer_core.TargetType.PAGE) return;
88
- target.page().then((page) => {
89
- if (!page) return;
90
- if (this.adoptIfNavigable(page)) return;
91
- page.on("framenavigated", (frame) => {
92
- if (frame === page.mainFrame()) this.adoptIfNavigable(page);
219
+ try {
220
+ if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
221
+ browser.on("disconnected", () => {
222
+ if (this.browser !== browser) return;
223
+ this.generation += 1;
224
+ this.browser = null;
225
+ this.activePage = null;
226
+ });
227
+ const pages = await browser.pages();
228
+ const initialPage = pages[0] ?? await browser.newPage();
229
+ await Promise.all(pages.map((page) => this.preparePage(page)));
230
+ await this.preparePage(initialPage);
231
+ if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
232
+ this.browser = browser;
233
+ this.setActivePage(initialPage);
234
+ browser.on("targetcreated", (target) => {
235
+ if (this.browser !== browser || target.type() !== puppeteer_core.TargetType.PAGE) return;
236
+ target.page().then(async (page) => {
237
+ if (!page || this.browser !== browser) return;
238
+ await this.preparePage(page);
239
+ if (this.adoptIfNavigable(page)) return;
240
+ page.on("framenavigated", (frame) => {
241
+ if (this.browser === browser && frame === page.mainFrame()) this.adoptIfNavigable(page);
242
+ });
243
+ }).catch(() => {});
244
+ });
245
+ browser.on("targetdestroyed", (target) => {
246
+ const closedPage = this.activePage;
247
+ if (this.browser !== browser || !closedPage || target.type() !== puppeteer_core.TargetType.PAGE) return;
248
+ target.page().then(async (page) => {
249
+ if (page === closedPage && this.browser === browser && this.activePage === closedPage) await this.restoreAfterClose(browser, closedPage, target.opener());
250
+ }).catch(() => {
251
+ this.log.warn("Could not restore the browser page after a popup closed");
93
252
  });
94
- }).catch(() => {});
95
- });
96
- this.browser.on("disconnected", () => {
97
- this.browser = null;
98
- this.activePage = null;
99
- });
253
+ });
254
+ } catch (error) {
255
+ await browser.close().catch(() => void 0);
256
+ throw error;
257
+ }
100
258
  }
101
259
  /**
102
260
  * Adopt `page` as the streamed active page iff it has a real navigable
@@ -106,18 +264,98 @@ var BrowserSession = class {
106
264
  adoptIfNavigable(page) {
107
265
  if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
108
266
  if (this.activePage === page) return true;
109
- this.activePage = page;
267
+ this.setActivePage(page);
110
268
  this.log.debug("Active page switched to new target");
111
269
  return true;
112
270
  }
271
+ /** A login popup can close while the agent is parked for human control.
272
+ * Recover here, without waiting for another automation call to discover
273
+ * that the active page is closed, and rebind the viewer to its opener. */
274
+ async restoreAfterClose(browser, closedPage, openerTarget) {
275
+ let opener = await openerTarget?.page();
276
+ while (this.browser === browser && this.activePage === closedPage) {
277
+ const pages = await browser.pages();
278
+ if (this.browser !== browser || this.activePage !== closedPage) return;
279
+ const page = opener && !opener.isClosed() ? opener : pages.find((candidate) => !candidate.isClosed() && isNavigablePageUrl(candidate.url())) ?? pages.find((candidate) => !candidate.isClosed()) ?? await browser.newPage();
280
+ opener = null;
281
+ try {
282
+ await this.preparePage(page);
283
+ } catch (error) {
284
+ if (page.isClosed()) continue;
285
+ throw error;
286
+ }
287
+ if (page.isClosed()) continue;
288
+ if (this.browser === browser && this.activePage === closedPage) this.setActivePage(page);
289
+ return;
290
+ }
291
+ }
113
292
  /** The current active page, launching Chrome first if needed. */
114
293
  async getActivePage() {
115
294
  await this.ensureLaunched();
116
295
  if (!this.activePage || this.activePage.isClosed()) {
117
296
  if (!this.browser) throw new Error("Browser not available");
118
- this.activePage = (await this.browser.pages()).find((p) => !p.isClosed()) ?? await this.browser.newPage();
297
+ const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
298
+ await this.preparePage(page);
299
+ this.setActivePage(page);
300
+ }
301
+ const activePage = this.activePage;
302
+ if (activePage === null) throw new Error("Browser page not available");
303
+ return activePage;
304
+ }
305
+ onActivePageChange(listener) {
306
+ this.activePageListeners.add(listener);
307
+ return () => {
308
+ this.activePageListeners.delete(listener);
309
+ };
310
+ }
311
+ /** Internal to the serialized automation turn. The caller holds Chrome until
312
+ * the adapter has disconnected its client/awaited its child process exit. */
313
+ async withCdpTarget(operation, signal) {
314
+ signal.throwIfAborted();
315
+ const page = await this.getActivePage();
316
+ const browser = this.browser;
317
+ const generation = this.generation;
318
+ if (!browser) throw new Error("Browser is unavailable");
319
+ const disconnected = new AbortController();
320
+ const abort = () => {
321
+ disconnected.abort(/* @__PURE__ */ new Error("Browser operation interrupted"));
322
+ };
323
+ const operationSignal = AbortSignal.any([signal, disconnected.signal]);
324
+ const assertCurrent = () => {
325
+ operationSignal.throwIfAborted();
326
+ if (this.browser !== browser || this.generation !== generation || !browser.connected || page.isClosed()) throw new Error("Browser operation interrupted");
327
+ };
328
+ browser.on("disconnected", abort);
329
+ page.on("close", abort);
330
+ try {
331
+ assertCurrent();
332
+ const cdp = await page.createCDPSession();
333
+ let targetId;
334
+ try {
335
+ const { targetInfo } = await cdp.send("Target.getTargetInfo");
336
+ targetId = targetInfo.targetId;
337
+ } finally {
338
+ await cdp.detach().catch(() => void 0);
339
+ }
340
+ assertCurrent();
341
+ const browserWSEndpoint = browser.wsEndpoint();
342
+ const endpoint = new URL(browserWSEndpoint);
343
+ if (endpoint.protocol !== "ws:" || ![
344
+ "127.0.0.1",
345
+ "[::1]",
346
+ "localhost"
347
+ ].includes(endpoint.hostname) || endpoint.username !== "" || endpoint.password !== "" || !endpoint.pathname.startsWith("/devtools/browser/")) throw new Error("Browser local attachment is unavailable");
348
+ const result = await operation({
349
+ browserWSEndpoint,
350
+ targetId,
351
+ signal: operationSignal
352
+ });
353
+ assertCurrent();
354
+ return result;
355
+ } finally {
356
+ browser.off("disconnected", abort);
357
+ page.off("close", abort);
119
358
  }
120
- return this.activePage;
121
359
  }
122
360
  /** Prevent idle shutdown while a viewer or op is active. */
123
361
  addHold() {
@@ -152,6 +390,8 @@ var BrowserSession = class {
152
390
  }
153
391
  async shutdown() {
154
392
  this.clearIdleTimer();
393
+ this.generation += 1;
394
+ this.launching = null;
155
395
  const browser = this.browser;
156
396
  this.browser = null;
157
397
  this.activePage = null;
@@ -159,6 +399,40 @@ var BrowserSession = class {
159
399
  await browser.close();
160
400
  } catch {}
161
401
  }
402
+ setActivePage(page) {
403
+ if (this.activePage === page) return;
404
+ this.activePage = page;
405
+ for (const listener of this.activePageListeners) try {
406
+ Promise.resolve(listener(page)).catch((error) => {
407
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
408
+ });
409
+ } catch (error) {
410
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
411
+ }
412
+ }
413
+ async preparePage(page) {
414
+ if (this.preparedPages.has(page)) return;
415
+ this.preparedPages.add(page);
416
+ const policy = this.options.isNavigationAllowed;
417
+ if (policy === void 0) return;
418
+ try {
419
+ await page.setRequestInterception(true);
420
+ page.on("request", (request) => {
421
+ if (request.isInterceptResolutionHandled()) return;
422
+ let allowed = false;
423
+ try {
424
+ allowed = policy(request.url());
425
+ } catch {}
426
+ (allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
427
+ });
428
+ } catch (error) {
429
+ this.preparedPages.delete(page);
430
+ throw error;
431
+ }
432
+ }
433
+ isLaunchCurrent(generation, browser) {
434
+ return generation === this.generation && browser.connected;
435
+ }
162
436
  };
163
437
  //#endregion
164
438
  //#region src/screencast-pump.ts
@@ -173,6 +447,12 @@ var ScreencastPump = class {
173
447
  running = false;
174
448
  frameSeq = 0;
175
449
  outstanding = null;
450
+ generation = 0;
451
+ viewport = {
452
+ width: 1280,
453
+ height: 720,
454
+ dpr: 1
455
+ };
176
456
  log;
177
457
  quality;
178
458
  ackTimeoutMs;
@@ -181,6 +461,8 @@ var ScreencastPump = class {
181
461
  this.log = options.logger ?? noopLogger$1;
182
462
  this.quality = options.quality ?? 70;
183
463
  this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
464
+ if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
465
+ if (!Number.isInteger(this.ackTimeoutMs) || this.ackTimeoutMs < 100 || this.ackTimeoutMs > 3e4) throw new Error("Screencast ack timeout must be an integer from 100 to 30000ms");
184
466
  }
185
467
  get isRunning() {
186
468
  return this.running;
@@ -188,33 +470,60 @@ var ScreencastPump = class {
188
470
  /** Start (or restart) the screencast on the given page. */
189
471
  async start(page, viewport) {
190
472
  await this.stop();
473
+ const generation = ++this.generation;
474
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
191
475
  const cdp = await page.createCDPSession();
476
+ if (generation !== this.generation) {
477
+ await cdp.detach().catch(() => void 0);
478
+ return;
479
+ }
192
480
  this.cdp = cdp;
193
- await cdp.send("Emulation.setDeviceMetricsOverride", {
194
- width: viewport.width,
195
- height: viewport.height,
196
- deviceScaleFactor: viewport.dpr,
197
- mobile: false
198
- });
199
- cdp.on("Page.screencastFrame", (event) => {
200
- this.onCdpFrame(event.data, event.sessionId, event.metadata);
201
- });
202
- await cdp.send("Page.startScreencast", {
203
- format: "jpeg",
204
- quality: this.quality,
205
- maxWidth: Math.round(viewport.width * viewport.dpr),
206
- maxHeight: Math.round(viewport.height * viewport.dpr),
207
- everyNthFrame: 1
208
- });
209
- this.running = true;
210
- this.log.debug("Screencast started");
481
+ try {
482
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
483
+ width: this.viewport.width,
484
+ height: this.viewport.height,
485
+ deviceScaleFactor: this.viewport.dpr,
486
+ mobile: false
487
+ });
488
+ if (generation !== this.generation || this.cdp !== cdp) return;
489
+ cdp.on("Page.screencastFrame", (event) => {
490
+ if (generation !== this.generation || this.cdp !== cdp) return;
491
+ this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
492
+ });
493
+ await cdp.send("Page.startScreencast", {
494
+ format: "jpeg",
495
+ quality: this.quality,
496
+ maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
497
+ maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
498
+ everyNthFrame: 1
499
+ });
500
+ if (generation !== this.generation || this.cdp !== cdp) return;
501
+ this.running = true;
502
+ this.log.debug("Screencast started");
503
+ } catch (error) {
504
+ if (this.cdp === cdp) this.cdp = null;
505
+ await cdp.detach().catch(() => void 0);
506
+ throw error;
507
+ }
211
508
  }
212
- onCdpFrame(dataBase64, cdpSessionId, metadata) {
509
+ onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
213
510
  if (this.outstanding) this.ackCdp(this.outstanding);
214
- const frameSeq = ++this.frameSeq;
511
+ if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
512
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
513
+ this.log.warn("Dropped oversized browser screencast frame");
514
+ return;
515
+ }
516
+ const jpeg = Buffer.from(dataBase64, "base64");
517
+ if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
518
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
519
+ this.log.warn("Dropped invalid browser screencast frame");
520
+ return;
521
+ }
522
+ this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
523
+ const frameSeq = this.frameSeq;
215
524
  const meta = {
216
- deviceWidth: metadata.deviceWidth ?? 0,
217
- deviceHeight: metadata.deviceHeight ?? 0,
525
+ deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
526
+ deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
218
527
  frameSeq,
219
528
  offsetTop: metadata.offsetTop,
220
529
  pageScaleFactor: metadata.pageScaleFactor,
@@ -226,11 +535,19 @@ var ScreencastPump = class {
226
535
  }, this.ackTimeoutMs);
227
536
  timer.unref();
228
537
  this.outstanding = {
538
+ cdp,
539
+ generation,
229
540
  cdpSessionId,
230
541
  frameSeq,
231
542
  timer
232
543
  };
233
- this.options.onFrame(Buffer.from(dataBase64, "base64"), meta);
544
+ try {
545
+ this.options.onFrame(jpeg, meta);
546
+ } catch (error) {
547
+ this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
548
+ const outstanding = this.outstanding;
549
+ if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
550
+ }
234
551
  }
235
552
  /** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
236
553
  ackFromViewer(frameSeq) {
@@ -239,9 +556,10 @@ var ScreencastPump = class {
239
556
  ackCdp(outstanding) {
240
557
  clearTimeout(outstanding.timer);
241
558
  this.outstanding = null;
242
- if (this.cdp && this.running) this.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
559
+ if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
243
560
  }
244
561
  async stop() {
562
+ this.generation += 1;
245
563
  this.running = false;
246
564
  if (this.outstanding) {
247
565
  clearTimeout(this.outstanding.timer);
@@ -266,17 +584,64 @@ const MOUSE_TYPE = {
266
584
  mousepressed: "mousePressed",
267
585
  mousereleased: "mouseReleased"
268
586
  };
587
+ const VIRTUAL_KEYS = {
588
+ Backspace: 8,
589
+ Tab: 9,
590
+ Enter: 13,
591
+ NumpadEnter: 13,
592
+ Shift: 16,
593
+ Control: 17,
594
+ Alt: 18,
595
+ Pause: 19,
596
+ CapsLock: 20,
597
+ Escape: 27,
598
+ Space: 32,
599
+ " ": 32,
600
+ PageUp: 33,
601
+ PageDown: 34,
602
+ End: 35,
603
+ Home: 36,
604
+ ArrowLeft: 37,
605
+ ArrowUp: 38,
606
+ ArrowRight: 39,
607
+ ArrowDown: 40,
608
+ Insert: 45,
609
+ Delete: 46,
610
+ Meta: 91
611
+ };
612
+ function virtualKeyCode(input) {
613
+ const named = VIRTUAL_KEYS[input.key ?? ""] ?? VIRTUAL_KEYS[input.code ?? ""];
614
+ if (typeof named === "number") return named;
615
+ const letter = /^Key([A-Z])$/.exec(input.code ?? "");
616
+ if (letter) return letter[1].charCodeAt(0);
617
+ const digit = /^Digit([0-9])$/.exec(input.code ?? "");
618
+ if (digit) return digit[1].charCodeAt(0);
619
+ const key = input.key ?? "";
620
+ if (/^[A-Za-z0-9]$/.test(key)) return key.toUpperCase().charCodeAt(0);
621
+ const functionKey = /^F([1-9]|1[0-9]|2[0-4])$/.exec(input.key ?? "");
622
+ if (functionKey) return 111 + Number(functionKey[1]);
623
+ }
269
624
  var InputInjector = class {
270
625
  cdp = null;
271
626
  viewport;
627
+ generation = 0;
272
628
  constructor(viewport) {
273
- this.viewport = viewport;
629
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
274
630
  }
275
631
  async attach(page) {
276
- await this.detach();
277
- this.cdp = await page.createCDPSession();
632
+ const generation = ++this.generation;
633
+ const old = this.cdp;
634
+ this.cdp = null;
635
+ if (old) await old.detach().catch(() => void 0);
636
+ const cdp = await page.createCDPSession();
637
+ if (generation !== this.generation) {
638
+ await cdp.detach().catch(() => void 0);
639
+ return;
640
+ }
641
+ this.cdp = cdp;
278
642
  }
279
643
  async detach() {
644
+ this.generation += 1;
280
645
  const cdp = this.cdp;
281
646
  this.cdp = null;
282
647
  if (cdp) try {
@@ -284,12 +649,12 @@ var InputInjector = class {
284
649
  } catch {}
285
650
  }
286
651
  updateViewport(viewport) {
287
- this.viewport = viewport;
652
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
288
653
  }
289
654
  toCssPx(nx, ny) {
290
655
  return {
291
- x: Math.round(clamp01(nx) * this.viewport.width),
292
- y: Math.round(clamp01(ny) * this.viewport.height)
656
+ x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
657
+ y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
293
658
  };
294
659
  }
295
660
  mouse(p) {
@@ -320,19 +685,18 @@ var InputInjector = class {
320
685
  key(p) {
321
686
  if (!this.cdp) return;
322
687
  if (p.type === "char") {
323
- this.cdp.send("Input.dispatchKeyEvent", {
324
- type: "char",
325
- text: p.text ?? "",
326
- modifiers: p.modifiers ?? 0
327
- }).catch(() => {});
688
+ this.cdp.send("Input.insertText", { text: p.text ?? "" }).catch(() => {});
328
689
  return;
329
690
  }
691
+ const modifiers = p.modifiers ?? 0;
692
+ const text = p.type === "keydown" && !(modifiers & 7) ? p.text ?? (p.key === "Enter" ? "\r" : void 0) : void 0;
330
693
  this.cdp.send("Input.dispatchKeyEvent", {
331
- type: p.type === "keydown" ? "keyDown" : "keyUp",
694
+ type: p.type === "keyup" ? "keyUp" : text ? "keyDown" : "rawKeyDown",
332
695
  key: p.key,
333
696
  code: p.code,
334
- text: p.type === "keydown" ? p.text : void 0,
335
- modifiers: p.modifiers ?? 0
697
+ text,
698
+ windowsVirtualKeyCode: virtualKeyCode(p),
699
+ modifiers
336
700
  }).catch(() => {});
337
701
  }
338
702
  };
@@ -342,64 +706,129 @@ function clamp01(n) {
342
706
  }
343
707
  //#endregion
344
708
  //#region src/automation.ts
709
+ const OPERATION_TIMEOUT_MS = 3e4;
345
710
  var BrowserAutomation = class {
711
+ operationTail = Promise.resolve();
712
+ stopping = new AbortController();
346
713
  constructor(session, turn, isNavigationAllowed) {
347
714
  this.session = session;
348
715
  this.turn = turn;
349
716
  this.isNavigationAllowed = isNavigationAllowed;
350
717
  }
351
718
  async navigate(url) {
352
- await this.turn.acquireAgent();
353
- if (!this.isNavigationAllowed(url)) throw new Error(`Navigation to ${url} blocked by SSRF policy`);
354
- const page = await this.session.getActivePage();
355
- await page.goto(url, { waitUntil: "domcontentloaded" });
356
- return {
357
- url: page.url(),
358
- title: await page.title()
359
- };
719
+ return this.run(async () => {
720
+ const target = validateNavigationUrl(url);
721
+ let allowed = false;
722
+ try {
723
+ allowed = this.isNavigationAllowed(target);
724
+ } catch {}
725
+ if (!allowed) throw new Error("Navigation blocked by browser policy");
726
+ const page = await this.session.getActivePage();
727
+ await page.goto(target, {
728
+ waitUntil: "domcontentloaded",
729
+ timeout: OPERATION_TIMEOUT_MS
730
+ });
731
+ return {
732
+ url: page.url(),
733
+ title: await page.title()
734
+ };
735
+ });
360
736
  }
361
737
  async click(selector) {
362
- await this.turn.acquireAgent();
363
- await (await this.session.getActivePage()).click(selector);
738
+ await this.run(async () => {
739
+ await (await this.session.getActivePage()).click(validateSelector(selector));
740
+ });
364
741
  }
365
742
  async type(selector, text) {
366
- await this.turn.acquireAgent();
367
- await (await this.session.getActivePage()).type(selector, text);
743
+ await this.run(async () => {
744
+ await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
745
+ });
368
746
  }
369
747
  async waitFor(opts) {
370
- await this.turn.acquireAgent();
371
- const page = await this.session.getActivePage();
372
- if (opts.selector) await page.waitForSelector(opts.selector);
373
- else if (opts.urlPattern) {
374
- const pattern = opts.urlPattern;
375
- await page.waitForFunction((p) => window.location.href.includes(p), {}, pattern);
376
- } else if (typeof opts.ms === "number") await new Promise((resolve) => setTimeout(resolve, opts.ms));
748
+ await this.run(async () => {
749
+ const normalized = validateWaitOptions(opts);
750
+ const page = await this.session.getActivePage();
751
+ if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
752
+ else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
753
+ else await new Promise((resolve) => {
754
+ setTimeout(resolve, normalized.ms ?? 0).unref();
755
+ });
756
+ });
377
757
  }
378
758
  /** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
379
759
  * from the continuous screencast stream to viewers. */
380
760
  async screenshot() {
381
- await this.turn.acquireAgent();
382
- return (await this.session.getActivePage()).screenshot({
383
- type: "jpeg",
384
- quality: 70,
385
- encoding: "base64"
761
+ return this.run(async () => {
762
+ const image = await (await this.session.getActivePage()).screenshot({
763
+ type: "jpeg",
764
+ quality: 70,
765
+ encoding: "base64"
766
+ });
767
+ if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
768
+ return image;
386
769
  });
387
770
  }
388
771
  /** Evaluate an expression in the page context via CDP (no eval on our side). */
389
772
  async evaluate(expression) {
390
- await this.turn.acquireAgent();
391
- const cdp = await (await this.session.getActivePage()).createCDPSession();
392
- try {
393
- return (await cdp.send("Runtime.evaluate", {
394
- expression,
395
- returnByValue: true,
396
- awaitPromise: true
397
- })).result.value;
398
- } finally {
773
+ return this.run(async () => {
774
+ const cdp = await (await this.session.getActivePage()).createCDPSession();
399
775
  try {
400
- await cdp.detach();
401
- } catch {}
402
- }
776
+ const res = await cdp.send("Runtime.evaluate", {
777
+ expression: validateExpression(expression),
778
+ returnByValue: true,
779
+ awaitPromise: true,
780
+ timeout: OPERATION_TIMEOUT_MS,
781
+ disableBreaks: true
782
+ });
783
+ if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
784
+ return assertBoundedAutomationResult(res.result.value ?? null);
785
+ } finally {
786
+ try {
787
+ await cdp.detach();
788
+ } catch {}
789
+ }
790
+ });
791
+ }
792
+ /** Wait until all agent operations that were already queued have settled. */
793
+ async waitUntilIdle() {
794
+ await this.operationTail;
795
+ }
796
+ /** Trusted local adapters share the built-in automation queue and exact page.
797
+ * The callback must disconnect/await children in finally, including on abort.
798
+ * Do not invoke another automation operation or handoff from the callback. */
799
+ withCdpOperation(operation, options = {}) {
800
+ return this.run((signal) => this.session.withCdpTarget(operation, signal), options);
801
+ }
802
+ /** Insert the claim at a precise queue position; later automation parks. */
803
+ async yieldToHuman(grant) {
804
+ await this.run(() => {
805
+ grant();
806
+ return Promise.resolve();
807
+ });
808
+ }
809
+ /** Abort first, then await callback cleanup before the owner closes Chrome. */
810
+ async shutdown() {
811
+ this.stopping.abort(/* @__PURE__ */ new Error("Browser automation stopped"));
812
+ this.turn.releaseHuman();
813
+ await this.operationTail;
814
+ }
815
+ run(operation, options = {}) {
816
+ const signal = options.signal ? AbortSignal.any([this.stopping.signal, options.signal]) : this.stopping.signal;
817
+ const result = this.operationTail.catch(() => void 0).then(async () => {
818
+ signal.throwIfAborted();
819
+ this.session.addHold();
820
+ try {
821
+ await this.turn.acquireAgent(signal);
822
+ signal.throwIfAborted();
823
+ const result = await operation(signal);
824
+ signal.throwIfAborted();
825
+ return result;
826
+ } finally {
827
+ this.session.removeHold();
828
+ }
829
+ });
830
+ this.operationTail = result.then(() => void 0, () => void 0);
831
+ return result;
403
832
  }
404
833
  };
405
834
  //#endregion
@@ -434,12 +863,29 @@ var BrowserSurface = class {
434
863
  dpr: 1
435
864
  };
436
865
  handoff = null;
866
+ controllerSessionId = null;
867
+ pendingControllerSessionId = null;
868
+ claimGeneration = 0;
869
+ streamGeneration = 0;
870
+ streamQueue = Promise.resolve();
871
+ closed = false;
872
+ removePageListener;
437
873
  log;
438
874
  constructor(options, sendFrame) {
439
- this.options = options;
440
875
  this.sendFrame = sendFrame;
441
876
  this.log = options.logger ?? noopLogger;
442
- this.session = new BrowserSession(options);
877
+ const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
878
+ try {
879
+ validateNavigationUrl(url);
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ });
885
+ this.session = new BrowserSession({
886
+ ...options,
887
+ isNavigationAllowed: navigationPolicy
888
+ });
443
889
  this.turn = new _alfe_ai_remote.TurnController({ onOwnerChange: () => {
444
890
  this.broadcastState();
445
891
  } });
@@ -450,56 +896,93 @@ var BrowserSurface = class {
450
896
  logger: this.log
451
897
  });
452
898
  this.injector = new InputInjector(this.viewport);
453
- this.automation = new BrowserAutomation(this.session, this.turn, options.isNavigationAllowed ?? (() => true));
899
+ this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
900
+ this.removePageListener = this.session.onActivePageChange(async () => {
901
+ if (this.viewers.size > 0) {
902
+ await this.restartStreaming();
903
+ this.broadcastState();
904
+ }
905
+ });
454
906
  }
455
907
  async openSession(sessionId, open) {
908
+ if (this.closed) throw new Error("Browser surface is shut down");
909
+ if (this.viewers.has(sessionId)) {
910
+ this.sendState(sessionId);
911
+ return;
912
+ }
913
+ const firstViewer = this.viewers.size === 0;
456
914
  this.viewers.add(sessionId);
457
915
  this.session.addHold();
458
- if (open.width && open.height) this.viewport = {
459
- width: open.width,
460
- height: open.height,
461
- dpr: open.dpr ?? 1
462
- };
463
- await this.ensureStreaming();
464
- this.sendState(sessionId);
916
+ try {
917
+ if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
918
+ if (firstViewer) await this.restartStreaming();
919
+ this.sendState(sessionId);
920
+ } catch (error) {
921
+ this.viewers.delete(sessionId);
922
+ this.session.removeHold();
923
+ if (this.viewers.size === 0) {
924
+ this.streamGeneration += 1;
925
+ this.enqueueStreamCleanup();
926
+ }
927
+ throw error;
928
+ }
465
929
  }
466
930
  handleFrame(frame) {
931
+ if (this.closed || !this.viewers.has(frame.sessionId)) return;
467
932
  switch (frame.type) {
468
933
  case _alfe_ai_remote.RemoteFrameType.SCREENCAST_ACK: {
469
- const ack = (0, _alfe_ai_remote.decodeJson)(frame.payload);
934
+ const ack = (0, _alfe_ai_remote.decodeScreencastAckPayload)(frame.payload);
470
935
  if (ack) this.pump.ackFromViewer(ack.frameSeq);
471
936
  this.session.touch();
472
937
  break;
473
938
  }
474
939
  case _alfe_ai_remote.RemoteFrameType.RESIZE: {
475
- const r = (0, _alfe_ai_remote.decodeJson)(frame.payload);
476
- if (r) this.applyResize(r.width, r.height, r.dpr ?? 1);
940
+ const resize = (0, _alfe_ai_remote.decodeResizePayload)(frame.payload);
941
+ if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
477
942
  break;
478
943
  }
479
944
  case _alfe_ai_remote.RemoteFrameType.INPUT_MOUSE:
480
- if (this.turn.humanInControl) {
481
- const p = (0, _alfe_ai_remote.decodeJson)(frame.payload);
482
- if (p) this.injector.mouse(p);
945
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
946
+ const input = (0, _alfe_ai_remote.decodeMouseInputPayload)(frame.payload);
947
+ if (input) this.injector.mouse(input);
483
948
  }
484
949
  break;
485
950
  case _alfe_ai_remote.RemoteFrameType.INPUT_WHEEL:
486
- if (this.turn.humanInControl) {
487
- const p = (0, _alfe_ai_remote.decodeJson)(frame.payload);
488
- if (p) this.injector.wheel(p);
951
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
952
+ const input = (0, _alfe_ai_remote.decodeWheelInputPayload)(frame.payload);
953
+ if (input) this.injector.wheel(input);
489
954
  }
490
955
  break;
491
956
  case _alfe_ai_remote.RemoteFrameType.INPUT_KEY:
492
- if (this.turn.humanInControl) {
493
- const p = (0, _alfe_ai_remote.decodeJson)(frame.payload);
494
- if (p) this.injector.key(p);
957
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
958
+ const input = (0, _alfe_ai_remote.decodeKeyInputPayload)(frame.payload);
959
+ if (input) this.injector.key(input);
495
960
  }
496
961
  break;
497
- case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST:
498
- if (this.turn.grantHuman()) this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
499
- else this.sendFrame((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
962
+ case _alfe_ai_remote.RemoteFrameType.TAKEOVER_REQUEST: {
963
+ if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
964
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
965
+ break;
966
+ }
967
+ this.pendingControllerSessionId = frame.sessionId;
968
+ const claimGeneration = ++this.claimGeneration;
969
+ this.automation.yieldToHuman(() => {
970
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
971
+ this.pendingControllerSessionId = null;
972
+ if (this.turn.grantHuman()) {
973
+ this.controllerSessionId = frame.sessionId;
974
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
975
+ } else this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
976
+ }).catch((error) => {
977
+ if (this.claimGeneration !== claimGeneration || this.pendingControllerSessionId !== frame.sessionId) return;
978
+ this.pendingControllerSessionId = null;
979
+ this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
980
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
981
+ });
500
982
  break;
983
+ }
501
984
  case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
502
- this.releaseToAgent();
985
+ if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
503
986
  break;
504
987
  default: break;
505
988
  }
@@ -507,9 +990,14 @@ var BrowserSurface = class {
507
990
  closeSession(sessionId) {
508
991
  if (!this.viewers.delete(sessionId)) return;
509
992
  this.session.removeHold();
993
+ if (this.pendingControllerSessionId === sessionId) {
994
+ this.pendingControllerSessionId = null;
995
+ this.claimGeneration += 1;
996
+ }
997
+ if (this.controllerSessionId === sessionId) this.releaseToAgent();
510
998
  if (this.viewers.size === 0) {
511
- this.pump.stop();
512
- if (this.turn.humanInControl) this.releaseToAgent();
999
+ this.streamGeneration += 1;
1000
+ this.enqueueStreamCleanup();
513
1001
  }
514
1002
  }
515
1003
  /**
@@ -527,17 +1015,28 @@ var BrowserSurface = class {
527
1015
  * under the user (mislabeled 409 on the real claim; agent only resuming when
528
1016
  * the tab closed).
529
1017
  */
530
- async requestHandoff(timeoutMs) {
1018
+ async requestHandoff(timeoutMs, signal) {
1019
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
1020
+ if (this.closed) throw new Error("Browser surface is shut down");
1021
+ signal?.throwIfAborted();
531
1022
  if (this.handoff) this.releaseToAgent();
532
- return new Promise((resolve) => {
1023
+ return new Promise((resolve, reject) => {
1024
+ const settle = (result) => {
1025
+ signal?.removeEventListener("abort", onAbort);
1026
+ resolve(result);
1027
+ };
533
1028
  const timer = setTimeout(() => {
1029
+ if (this.handoff !== waiter) return;
534
1030
  this.handoff = null;
1031
+ this.pendingControllerSessionId = null;
1032
+ this.claimGeneration += 1;
535
1033
  if (this.turn.humanInControl) {
536
1034
  this.turn.releaseHuman();
1035
+ this.controllerSessionId = null;
537
1036
  this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
538
1037
  }
539
1038
  this.currentPageInfo().then(({ url, title }) => {
540
- resolve({
1039
+ settle({
541
1040
  released: false,
542
1041
  timedOut: true,
543
1042
  url,
@@ -546,10 +1045,27 @@ var BrowserSurface = class {
546
1045
  });
547
1046
  }, timeoutMs);
548
1047
  timer.unref();
549
- this.handoff = {
550
- resolve,
1048
+ const waiter = {
1049
+ resolve: settle,
551
1050
  timer
552
1051
  };
1052
+ const onAbort = () => {
1053
+ if (this.handoff !== waiter) return;
1054
+ this.handoff = null;
1055
+ clearTimeout(timer);
1056
+ signal?.removeEventListener("abort", onAbort);
1057
+ this.pendingControllerSessionId = null;
1058
+ this.claimGeneration += 1;
1059
+ if (this.turn.humanInControl) {
1060
+ this.turn.releaseHuman();
1061
+ this.controllerSessionId = null;
1062
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
1063
+ }
1064
+ reject(/* @__PURE__ */ new Error("Browser handoff cancelled"));
1065
+ };
1066
+ this.handoff = waiter;
1067
+ signal?.addEventListener("abort", onAbort, { once: true });
1068
+ if (signal?.aborted) onAbort();
553
1069
  });
554
1070
  }
555
1071
  /**
@@ -565,11 +1081,33 @@ var BrowserSurface = class {
565
1081
  this.session.removeHold();
566
1082
  }
567
1083
  async shutdown() {
568
- await this.pump.stop();
569
- await this.injector.detach();
1084
+ if (this.closed) return;
1085
+ this.closed = true;
1086
+ this.removePageListener();
1087
+ this.streamGeneration += 1;
1088
+ this.pendingControllerSessionId = null;
1089
+ this.claimGeneration += 1;
1090
+ this.controllerSessionId = null;
1091
+ const waiter = this.handoff;
1092
+ this.handoff = null;
1093
+ if (waiter) {
1094
+ clearTimeout(waiter.timer);
1095
+ waiter.resolve({
1096
+ released: false,
1097
+ timedOut: true,
1098
+ url: "",
1099
+ title: ""
1100
+ });
1101
+ }
1102
+ this.turn.releaseHuman();
1103
+ await this.automation.shutdown();
1104
+ await this.enqueueStreamCleanup();
570
1105
  await this.session.shutdown();
571
1106
  }
572
1107
  releaseToAgent() {
1108
+ this.pendingControllerSessionId = null;
1109
+ this.claimGeneration += 1;
1110
+ this.controllerSessionId = null;
573
1111
  this.turn.releaseHuman();
574
1112
  const waiter = this.handoff;
575
1113
  this.handoff = null;
@@ -585,51 +1123,82 @@ var BrowserSurface = class {
585
1123
  });
586
1124
  }
587
1125
  }
588
- async ensureStreaming() {
589
- if (this.pump.isRunning || this.viewers.size === 0) return;
590
- const page = await this.session.getActivePage();
591
- await this.injector.attach(page);
592
- this.injector.updateViewport(this.viewport);
593
- await this.pump.start(page, this.viewport);
1126
+ restartStreaming() {
1127
+ const generation = ++this.streamGeneration;
1128
+ const run = this.streamQueue.catch(() => void 0).then(async () => {
1129
+ if (!this.shouldStream(generation)) return;
1130
+ try {
1131
+ const page = await this.session.getActivePage();
1132
+ if (!this.shouldStream(generation)) return;
1133
+ await this.injector.attach(page);
1134
+ this.injector.updateViewport(this.viewport);
1135
+ await this.pump.start(page, this.viewport);
1136
+ if (!this.shouldStream(generation)) {
1137
+ await this.pump.stop();
1138
+ await this.injector.detach();
1139
+ }
1140
+ } catch (error) {
1141
+ await this.pump.stop();
1142
+ await this.injector.detach();
1143
+ throw error;
1144
+ }
1145
+ });
1146
+ this.streamQueue = run.catch((error) => {
1147
+ this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
1148
+ });
1149
+ return run;
1150
+ }
1151
+ enqueueStreamCleanup() {
1152
+ const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
1153
+ await this.pump.stop();
1154
+ await this.injector.detach();
1155
+ });
1156
+ this.streamQueue = cleanup.catch(() => void 0);
1157
+ return cleanup;
1158
+ }
1159
+ shouldStream(generation) {
1160
+ return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
594
1161
  }
595
1162
  async applyResize(width, height, dpr) {
596
- this.viewport = {
597
- width,
598
- height,
599
- dpr
600
- };
1163
+ this.viewport = normalizeViewport(width, height, dpr);
601
1164
  this.injector.updateViewport(this.viewport);
602
- if (this.pump.isRunning) {
603
- const page = await this.session.getActivePage();
604
- await this.pump.start(page, this.viewport);
605
- }
1165
+ if (this.viewers.size > 0) await this.restartStreaming();
606
1166
  }
607
1167
  broadcastFrame(jpeg, meta) {
608
- for (const sessionId of this.viewers) this.sendFrame((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
1168
+ for (const sessionId of this.viewers) try {
1169
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
1170
+ } catch (error) {
1171
+ this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
1172
+ }
609
1173
  }
610
1174
  broadcast(type) {
611
- for (const sessionId of this.viewers) this.sendFrame((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
1175
+ for (const sessionId of this.viewers) this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
612
1176
  }
613
1177
  broadcastState() {
614
1178
  for (const sessionId of this.viewers) this.sendState(sessionId);
615
1179
  }
616
1180
  sendState(sessionId) {
617
1181
  this.currentPageInfo().then(({ url, title }) => {
1182
+ if (!this.viewers.has(sessionId) || this.closed) return;
618
1183
  const state = {
619
1184
  surface: "browser",
620
1185
  url,
621
1186
  title,
622
1187
  controller: this.turn.currentOwner
623
1188
  };
624
- this.sendFrame((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
1189
+ try {
1190
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
1191
+ } catch (error) {
1192
+ this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
1193
+ }
625
1194
  });
626
1195
  }
627
1196
  async currentPageInfo() {
628
1197
  try {
629
1198
  const page = await this.session.getActivePage();
630
1199
  return {
631
- url: page.url(),
632
- title: await page.title()
1200
+ url: page.url().slice(0, 8192),
1201
+ title: (await page.title()).slice(0, 4096)
633
1202
  };
634
1203
  } catch {
635
1204
  return {
@@ -638,6 +1207,13 @@ var BrowserSurface = class {
638
1207
  };
639
1208
  }
640
1209
  }
1210
+ sendFrameSafe(frame) {
1211
+ try {
1212
+ this.sendFrame(frame);
1213
+ } catch (error) {
1214
+ this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
1215
+ }
1216
+ }
641
1217
  };
642
1218
  //#endregion
643
1219
  exports.BrowserAutomation = BrowserAutomation;