@alfe.ai/browser 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,70 @@ var BrowserSession = class {
29
158
  launching = null;
30
159
  holds = 0;
31
160
  idleTimer = null;
161
+ generation = 0;
162
+ preparedPages = /* @__PURE__ */ new WeakSet();
163
+ activePageListeners = /* @__PURE__ */ new Set();
32
164
  log;
33
165
  idleMs;
34
166
  constructor(options) {
35
167
  this.options = options;
36
168
  this.log = options.logger ?? noopLogger$2;
37
- this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
169
+ this.idleMs = validateIdleShutdownMs(options.idleShutdownMs ?? DEFAULT_IDLE_MS);
38
170
  }
39
171
  /** Launch Chrome if not already running (idempotent, concurrent-safe). */
40
172
  async ensureLaunched() {
41
173
  if (this.browser) return;
42
174
  if (this.launching) return this.launching;
43
- 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);
69
- });
70
- }).catch(() => {});
71
- });
72
- this.browser.on("disconnected", () => {
73
- this.browser = null;
74
- this.activePage = null;
75
- });
195
+ try {
196
+ if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
197
+ browser.on("disconnected", () => {
198
+ if (this.browser !== browser) return;
199
+ this.generation += 1;
200
+ this.browser = null;
201
+ this.activePage = null;
202
+ });
203
+ const pages = await browser.pages();
204
+ const initialPage = pages[0] ?? await browser.newPage();
205
+ await Promise.all(pages.map((page) => this.preparePage(page)));
206
+ await this.preparePage(initialPage);
207
+ if (!this.isLaunchCurrent(generation, browser)) throw new Error("Browser launch superseded");
208
+ this.browser = browser;
209
+ this.setActivePage(initialPage);
210
+ browser.on("targetcreated", (target) => {
211
+ if (this.browser !== browser || target.type() !== TargetType.PAGE) return;
212
+ target.page().then(async (page) => {
213
+ if (!page || this.browser !== browser) return;
214
+ await this.preparePage(page);
215
+ if (this.adoptIfNavigable(page)) return;
216
+ page.on("framenavigated", (frame) => {
217
+ if (this.browser === browser && frame === page.mainFrame()) this.adoptIfNavigable(page);
218
+ });
219
+ }).catch(() => {});
220
+ });
221
+ } catch (error) {
222
+ await browser.close().catch(() => void 0);
223
+ throw error;
224
+ }
76
225
  }
77
226
  /**
78
227
  * Adopt `page` as the streamed active page iff it has a real navigable
@@ -82,7 +231,7 @@ var BrowserSession = class {
82
231
  adoptIfNavigable(page) {
83
232
  if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
84
233
  if (this.activePage === page) return true;
85
- this.activePage = page;
234
+ this.setActivePage(page);
86
235
  this.log.debug("Active page switched to new target");
87
236
  return true;
88
237
  }
@@ -91,9 +240,19 @@ var BrowserSession = class {
91
240
  await this.ensureLaunched();
92
241
  if (!this.activePage || this.activePage.isClosed()) {
93
242
  if (!this.browser) throw new Error("Browser not available");
94
- this.activePage = (await this.browser.pages()).find((p) => !p.isClosed()) ?? await this.browser.newPage();
243
+ const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
244
+ await this.preparePage(page);
245
+ this.setActivePage(page);
95
246
  }
96
- return this.activePage;
247
+ const activePage = this.activePage;
248
+ if (activePage === null) throw new Error("Browser page not available");
249
+ return activePage;
250
+ }
251
+ onActivePageChange(listener) {
252
+ this.activePageListeners.add(listener);
253
+ return () => {
254
+ this.activePageListeners.delete(listener);
255
+ };
97
256
  }
98
257
  /** Prevent idle shutdown while a viewer or op is active. */
99
258
  addHold() {
@@ -128,6 +287,8 @@ var BrowserSession = class {
128
287
  }
129
288
  async shutdown() {
130
289
  this.clearIdleTimer();
290
+ this.generation += 1;
291
+ this.launching = null;
131
292
  const browser = this.browser;
132
293
  this.browser = null;
133
294
  this.activePage = null;
@@ -135,6 +296,40 @@ var BrowserSession = class {
135
296
  await browser.close();
136
297
  } catch {}
137
298
  }
299
+ setActivePage(page) {
300
+ if (this.activePage === page) return;
301
+ this.activePage = page;
302
+ for (const listener of this.activePageListeners) try {
303
+ Promise.resolve(listener(page)).catch((error) => {
304
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
305
+ });
306
+ } catch (error) {
307
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
308
+ }
309
+ }
310
+ async preparePage(page) {
311
+ if (this.preparedPages.has(page)) return;
312
+ this.preparedPages.add(page);
313
+ const policy = this.options.isNavigationAllowed;
314
+ if (policy === void 0) return;
315
+ try {
316
+ await page.setRequestInterception(true);
317
+ page.on("request", (request) => {
318
+ if (request.isInterceptResolutionHandled()) return;
319
+ let allowed = false;
320
+ try {
321
+ allowed = policy(request.url());
322
+ } catch {}
323
+ (allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
324
+ });
325
+ } catch (error) {
326
+ this.preparedPages.delete(page);
327
+ throw error;
328
+ }
329
+ }
330
+ isLaunchCurrent(generation, browser) {
331
+ return generation === this.generation && browser.connected;
332
+ }
138
333
  };
139
334
  //#endregion
140
335
  //#region src/screencast-pump.ts
@@ -149,6 +344,12 @@ var ScreencastPump = class {
149
344
  running = false;
150
345
  frameSeq = 0;
151
346
  outstanding = null;
347
+ generation = 0;
348
+ viewport = {
349
+ width: 1280,
350
+ height: 720,
351
+ dpr: 1
352
+ };
152
353
  log;
153
354
  quality;
154
355
  ackTimeoutMs;
@@ -157,6 +358,8 @@ var ScreencastPump = class {
157
358
  this.log = options.logger ?? noopLogger$1;
158
359
  this.quality = options.quality ?? 70;
159
360
  this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
361
+ if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
362
+ if (!Number.isInteger(this.ackTimeoutMs) || this.ackTimeoutMs < 100 || this.ackTimeoutMs > 3e4) throw new Error("Screencast ack timeout must be an integer from 100 to 30000ms");
160
363
  }
161
364
  get isRunning() {
162
365
  return this.running;
@@ -164,33 +367,60 @@ var ScreencastPump = class {
164
367
  /** Start (or restart) the screencast on the given page. */
165
368
  async start(page, viewport) {
166
369
  await this.stop();
370
+ const generation = ++this.generation;
371
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
167
372
  const cdp = await page.createCDPSession();
373
+ if (generation !== this.generation) {
374
+ await cdp.detach().catch(() => void 0);
375
+ return;
376
+ }
168
377
  this.cdp = cdp;
169
- 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");
378
+ try {
379
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
380
+ width: this.viewport.width,
381
+ height: this.viewport.height,
382
+ deviceScaleFactor: this.viewport.dpr,
383
+ mobile: false
384
+ });
385
+ if (generation !== this.generation || this.cdp !== cdp) return;
386
+ cdp.on("Page.screencastFrame", (event) => {
387
+ if (generation !== this.generation || this.cdp !== cdp) return;
388
+ this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
389
+ });
390
+ await cdp.send("Page.startScreencast", {
391
+ format: "jpeg",
392
+ quality: this.quality,
393
+ maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
394
+ maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
395
+ everyNthFrame: 1
396
+ });
397
+ if (generation !== this.generation || this.cdp !== cdp) return;
398
+ this.running = true;
399
+ this.log.debug("Screencast started");
400
+ } catch (error) {
401
+ if (this.cdp === cdp) this.cdp = null;
402
+ await cdp.detach().catch(() => void 0);
403
+ throw error;
404
+ }
187
405
  }
188
- onCdpFrame(dataBase64, cdpSessionId, metadata) {
406
+ onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
189
407
  if (this.outstanding) this.ackCdp(this.outstanding);
190
- const frameSeq = ++this.frameSeq;
408
+ if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
409
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
410
+ this.log.warn("Dropped oversized browser screencast frame");
411
+ return;
412
+ }
413
+ const jpeg = Buffer.from(dataBase64, "base64");
414
+ if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
415
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
416
+ this.log.warn("Dropped invalid browser screencast frame");
417
+ return;
418
+ }
419
+ this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
420
+ const frameSeq = this.frameSeq;
191
421
  const meta = {
192
- deviceWidth: metadata.deviceWidth ?? 0,
193
- deviceHeight: metadata.deviceHeight ?? 0,
422
+ deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
423
+ deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
194
424
  frameSeq,
195
425
  offsetTop: metadata.offsetTop,
196
426
  pageScaleFactor: metadata.pageScaleFactor,
@@ -202,11 +432,19 @@ var ScreencastPump = class {
202
432
  }, this.ackTimeoutMs);
203
433
  timer.unref();
204
434
  this.outstanding = {
435
+ cdp,
436
+ generation,
205
437
  cdpSessionId,
206
438
  frameSeq,
207
439
  timer
208
440
  };
209
- this.options.onFrame(Buffer.from(dataBase64, "base64"), meta);
441
+ try {
442
+ this.options.onFrame(jpeg, meta);
443
+ } catch (error) {
444
+ this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
445
+ const outstanding = this.outstanding;
446
+ if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
447
+ }
210
448
  }
211
449
  /** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
212
450
  ackFromViewer(frameSeq) {
@@ -215,9 +453,10 @@ var ScreencastPump = class {
215
453
  ackCdp(outstanding) {
216
454
  clearTimeout(outstanding.timer);
217
455
  this.outstanding = null;
218
- if (this.cdp && this.running) this.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
456
+ if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
219
457
  }
220
458
  async stop() {
459
+ this.generation += 1;
221
460
  this.running = false;
222
461
  if (this.outstanding) {
223
462
  clearTimeout(this.outstanding.timer);
@@ -245,14 +484,24 @@ const MOUSE_TYPE = {
245
484
  var InputInjector = class {
246
485
  cdp = null;
247
486
  viewport;
487
+ generation = 0;
248
488
  constructor(viewport) {
249
- this.viewport = viewport;
489
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
250
490
  }
251
491
  async attach(page) {
252
- await this.detach();
253
- this.cdp = await page.createCDPSession();
492
+ const generation = ++this.generation;
493
+ const old = this.cdp;
494
+ this.cdp = null;
495
+ if (old) await old.detach().catch(() => void 0);
496
+ const cdp = await page.createCDPSession();
497
+ if (generation !== this.generation) {
498
+ await cdp.detach().catch(() => void 0);
499
+ return;
500
+ }
501
+ this.cdp = cdp;
254
502
  }
255
503
  async detach() {
504
+ this.generation += 1;
256
505
  const cdp = this.cdp;
257
506
  this.cdp = null;
258
507
  if (cdp) try {
@@ -260,12 +509,12 @@ var InputInjector = class {
260
509
  } catch {}
261
510
  }
262
511
  updateViewport(viewport) {
263
- this.viewport = viewport;
512
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
264
513
  }
265
514
  toCssPx(nx, ny) {
266
515
  return {
267
- x: Math.round(clamp01(nx) * this.viewport.width),
268
- y: Math.round(clamp01(ny) * this.viewport.height)
516
+ x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
517
+ y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
269
518
  };
270
519
  }
271
520
  mouse(p) {
@@ -318,64 +567,103 @@ function clamp01(n) {
318
567
  }
319
568
  //#endregion
320
569
  //#region src/automation.ts
570
+ const OPERATION_TIMEOUT_MS = 3e4;
321
571
  var BrowserAutomation = class {
572
+ operationTail = Promise.resolve();
322
573
  constructor(session, turn, isNavigationAllowed) {
323
574
  this.session = session;
324
575
  this.turn = turn;
325
576
  this.isNavigationAllowed = isNavigationAllowed;
326
577
  }
327
578
  async navigate(url) {
328
- 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
- };
579
+ return this.run(async () => {
580
+ const target = validateNavigationUrl(url);
581
+ let allowed = false;
582
+ try {
583
+ allowed = this.isNavigationAllowed(target);
584
+ } catch {}
585
+ if (!allowed) throw new Error("Navigation blocked by browser policy");
586
+ const page = await this.session.getActivePage();
587
+ await page.goto(target, {
588
+ waitUntil: "domcontentloaded",
589
+ timeout: OPERATION_TIMEOUT_MS
590
+ });
591
+ return {
592
+ url: page.url(),
593
+ title: await page.title()
594
+ };
595
+ });
336
596
  }
337
597
  async click(selector) {
338
- await this.turn.acquireAgent();
339
- await (await this.session.getActivePage()).click(selector);
598
+ await this.run(async () => {
599
+ await (await this.session.getActivePage()).click(validateSelector(selector));
600
+ });
340
601
  }
341
602
  async type(selector, text) {
342
- await this.turn.acquireAgent();
343
- await (await this.session.getActivePage()).type(selector, text);
603
+ await this.run(async () => {
604
+ await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
605
+ });
344
606
  }
345
607
  async waitFor(opts) {
346
- await this.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));
608
+ await this.run(async () => {
609
+ const normalized = validateWaitOptions(opts);
610
+ const page = await this.session.getActivePage();
611
+ if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
612
+ else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
613
+ else await new Promise((resolve) => {
614
+ setTimeout(resolve, normalized.ms ?? 0).unref();
615
+ });
616
+ });
353
617
  }
354
618
  /** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
355
619
  * from the continuous screencast stream to viewers. */
356
620
  async screenshot() {
357
- await this.turn.acquireAgent();
358
- return (await this.session.getActivePage()).screenshot({
359
- type: "jpeg",
360
- quality: 70,
361
- encoding: "base64"
621
+ return this.run(async () => {
622
+ const image = await (await this.session.getActivePage()).screenshot({
623
+ type: "jpeg",
624
+ quality: 70,
625
+ encoding: "base64"
626
+ });
627
+ if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
628
+ return image;
362
629
  });
363
630
  }
364
631
  /** Evaluate an expression in the page context via CDP (no eval on our side). */
365
632
  async evaluate(expression) {
366
- 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 {
633
+ return this.run(async () => {
634
+ const cdp = await (await this.session.getActivePage()).createCDPSession();
375
635
  try {
376
- await cdp.detach();
377
- } catch {}
378
- }
636
+ const res = await cdp.send("Runtime.evaluate", {
637
+ expression: validateExpression(expression),
638
+ returnByValue: true,
639
+ awaitPromise: true,
640
+ timeout: OPERATION_TIMEOUT_MS,
641
+ disableBreaks: true
642
+ });
643
+ if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
644
+ return assertBoundedAutomationResult(res.result.value ?? null);
645
+ } finally {
646
+ try {
647
+ await cdp.detach();
648
+ } catch {}
649
+ }
650
+ });
651
+ }
652
+ /** Wait until all agent operations that were already queued have settled. */
653
+ async waitUntilIdle() {
654
+ await this.operationTail;
655
+ }
656
+ run(operation) {
657
+ const result = this.operationTail.catch(() => void 0).then(async () => {
658
+ await this.turn.acquireAgent();
659
+ try {
660
+ return await operation();
661
+ } finally {
662
+ this.session.touch();
663
+ }
664
+ });
665
+ this.operationTail = result.then(() => void 0, () => void 0);
666
+ return result;
379
667
  }
380
668
  };
381
669
  //#endregion
@@ -410,12 +698,29 @@ var BrowserSurface = class {
410
698
  dpr: 1
411
699
  };
412
700
  handoff = null;
701
+ controllerSessionId = null;
702
+ pendingControllerSessionId = null;
703
+ streamGeneration = 0;
704
+ streamQueue = Promise.resolve();
705
+ closed = false;
706
+ removePageListener;
413
707
  log;
414
708
  constructor(options, sendFrame) {
415
709
  this.options = options;
416
710
  this.sendFrame = sendFrame;
417
711
  this.log = options.logger ?? noopLogger;
418
- this.session = new BrowserSession(options);
712
+ const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
713
+ try {
714
+ validateNavigationUrl(url);
715
+ return true;
716
+ } catch {
717
+ return false;
718
+ }
719
+ });
720
+ this.session = new BrowserSession({
721
+ ...options,
722
+ isNavigationAllowed: navigationPolicy
723
+ });
419
724
  this.turn = new TurnController({ onOwnerChange: () => {
420
725
  this.broadcastState();
421
726
  } });
@@ -426,56 +731,87 @@ var BrowserSurface = class {
426
731
  logger: this.log
427
732
  });
428
733
  this.injector = new InputInjector(this.viewport);
429
- this.automation = new BrowserAutomation(this.session, this.turn, options.isNavigationAllowed ?? (() => true));
734
+ this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
735
+ this.removePageListener = this.session.onActivePageChange(() => {
736
+ if (this.viewers.size > 0) this.restartStreaming();
737
+ });
430
738
  }
431
739
  async openSession(sessionId, open) {
740
+ if (this.closed) throw new Error("Browser surface is shut down");
741
+ if (this.viewers.has(sessionId)) {
742
+ this.sendState(sessionId);
743
+ return;
744
+ }
745
+ const firstViewer = this.viewers.size === 0;
432
746
  this.viewers.add(sessionId);
433
747
  this.session.addHold();
434
- 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);
748
+ try {
749
+ if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
750
+ if (firstViewer) await this.restartStreaming();
751
+ this.sendState(sessionId);
752
+ } catch (error) {
753
+ this.viewers.delete(sessionId);
754
+ this.session.removeHold();
755
+ if (this.viewers.size === 0) {
756
+ this.streamGeneration += 1;
757
+ this.enqueueStreamCleanup();
758
+ }
759
+ throw error;
760
+ }
441
761
  }
442
762
  handleFrame(frame) {
763
+ if (this.closed || !this.viewers.has(frame.sessionId)) return;
443
764
  switch (frame.type) {
444
765
  case RemoteFrameType.SCREENCAST_ACK: {
445
- const ack = decodeJson(frame.payload);
766
+ const ack = decodeScreencastAckPayload(frame.payload);
446
767
  if (ack) this.pump.ackFromViewer(ack.frameSeq);
447
768
  this.session.touch();
448
769
  break;
449
770
  }
450
771
  case RemoteFrameType.RESIZE: {
451
- const r = decodeJson(frame.payload);
452
- if (r) this.applyResize(r.width, r.height, r.dpr ?? 1);
772
+ const resize = decodeResizePayload(frame.payload);
773
+ if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
453
774
  break;
454
775
  }
455
776
  case RemoteFrameType.INPUT_MOUSE:
456
- if (this.turn.humanInControl) {
457
- const p = decodeJson(frame.payload);
458
- if (p) this.injector.mouse(p);
777
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
778
+ const input = decodeMouseInputPayload(frame.payload);
779
+ if (input) this.injector.mouse(input);
459
780
  }
460
781
  break;
461
782
  case RemoteFrameType.INPUT_WHEEL:
462
- if (this.turn.humanInControl) {
463
- const p = decodeJson(frame.payload);
464
- if (p) this.injector.wheel(p);
783
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
784
+ const input = decodeWheelInputPayload(frame.payload);
785
+ if (input) this.injector.wheel(input);
465
786
  }
466
787
  break;
467
788
  case RemoteFrameType.INPUT_KEY:
468
- if (this.turn.humanInControl) {
469
- const p = decodeJson(frame.payload);
470
- if (p) this.injector.key(p);
789
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
790
+ const input = decodeKeyInputPayload(frame.payload);
791
+ if (input) this.injector.key(input);
471
792
  }
472
793
  break;
473
794
  case RemoteFrameType.TAKEOVER_REQUEST:
474
- if (this.turn.grantHuman()) this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
475
- else this.sendFrame(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
795
+ if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
796
+ this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
797
+ break;
798
+ }
799
+ this.pendingControllerSessionId = frame.sessionId;
800
+ this.automation.waitUntilIdle().then(() => {
801
+ if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
802
+ this.pendingControllerSessionId = null;
803
+ if (this.turn.grantHuman()) {
804
+ this.controllerSessionId = frame.sessionId;
805
+ this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
806
+ } else this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
807
+ }).catch((error) => {
808
+ if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
809
+ this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
810
+ this.sendFrameSafe(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
811
+ });
476
812
  break;
477
813
  case RemoteFrameType.RELEASE_CONTROL:
478
- this.releaseToAgent();
814
+ if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
479
815
  break;
480
816
  default: break;
481
817
  }
@@ -483,9 +819,11 @@ var BrowserSurface = class {
483
819
  closeSession(sessionId) {
484
820
  if (!this.viewers.delete(sessionId)) return;
485
821
  this.session.removeHold();
822
+ if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
823
+ if (this.controllerSessionId === sessionId) this.releaseToAgent();
486
824
  if (this.viewers.size === 0) {
487
- this.pump.stop();
488
- if (this.turn.humanInControl) this.releaseToAgent();
825
+ this.streamGeneration += 1;
826
+ this.enqueueStreamCleanup();
489
827
  }
490
828
  }
491
829
  /**
@@ -504,12 +842,15 @@ var BrowserSurface = class {
504
842
  * the tab closed).
505
843
  */
506
844
  async requestHandoff(timeoutMs) {
845
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
507
846
  if (this.handoff) this.releaseToAgent();
508
847
  return new Promise((resolve) => {
509
848
  const timer = setTimeout(() => {
510
849
  this.handoff = null;
850
+ this.pendingControllerSessionId = null;
511
851
  if (this.turn.humanInControl) {
512
852
  this.turn.releaseHuman();
853
+ this.controllerSessionId = null;
513
854
  this.broadcast(RemoteFrameType.CONTROL_REVOKED);
514
855
  }
515
856
  this.currentPageInfo().then(({ url, title }) => {
@@ -541,11 +882,30 @@ var BrowserSurface = class {
541
882
  this.session.removeHold();
542
883
  }
543
884
  async shutdown() {
544
- await this.pump.stop();
545
- await this.injector.detach();
885
+ if (this.closed) return;
886
+ this.closed = true;
887
+ this.removePageListener();
888
+ this.streamGeneration += 1;
889
+ this.pendingControllerSessionId = null;
890
+ this.controllerSessionId = null;
891
+ const waiter = this.handoff;
892
+ this.handoff = null;
893
+ if (waiter) {
894
+ clearTimeout(waiter.timer);
895
+ waiter.resolve({
896
+ released: false,
897
+ timedOut: true,
898
+ url: "",
899
+ title: ""
900
+ });
901
+ }
902
+ this.turn.releaseHuman();
903
+ await this.enqueueStreamCleanup();
546
904
  await this.session.shutdown();
547
905
  }
548
906
  releaseToAgent() {
907
+ this.pendingControllerSessionId = null;
908
+ this.controllerSessionId = null;
549
909
  this.turn.releaseHuman();
550
910
  const waiter = this.handoff;
551
911
  this.handoff = null;
@@ -561,51 +921,82 @@ var BrowserSurface = class {
561
921
  });
562
922
  }
563
923
  }
564
- 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);
924
+ restartStreaming() {
925
+ const generation = ++this.streamGeneration;
926
+ const run = this.streamQueue.catch(() => void 0).then(async () => {
927
+ if (!this.shouldStream(generation)) return;
928
+ try {
929
+ const page = await this.session.getActivePage();
930
+ if (!this.shouldStream(generation)) return;
931
+ await this.injector.attach(page);
932
+ this.injector.updateViewport(this.viewport);
933
+ await this.pump.start(page, this.viewport);
934
+ if (!this.shouldStream(generation)) {
935
+ await this.pump.stop();
936
+ await this.injector.detach();
937
+ }
938
+ } catch (error) {
939
+ await this.pump.stop();
940
+ await this.injector.detach();
941
+ throw error;
942
+ }
943
+ });
944
+ this.streamQueue = run.catch((error) => {
945
+ this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
946
+ });
947
+ return run;
948
+ }
949
+ enqueueStreamCleanup() {
950
+ const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
951
+ await this.pump.stop();
952
+ await this.injector.detach();
953
+ });
954
+ this.streamQueue = cleanup.catch(() => void 0);
955
+ return cleanup;
956
+ }
957
+ shouldStream(generation) {
958
+ return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
570
959
  }
571
960
  async applyResize(width, height, dpr) {
572
- this.viewport = {
573
- width,
574
- height,
575
- dpr
576
- };
961
+ this.viewport = normalizeViewport(width, height, dpr);
577
962
  this.injector.updateViewport(this.viewport);
578
- if (this.pump.isRunning) {
579
- const page = await this.session.getActivePage();
580
- await this.pump.start(page, this.viewport);
581
- }
963
+ if (this.viewers.size > 0) await this.restartStreaming();
582
964
  }
583
965
  broadcastFrame(jpeg, meta) {
584
- for (const sessionId of this.viewers) this.sendFrame(encodeScreencastFrame(sessionId, meta, jpeg));
966
+ for (const sessionId of this.viewers) try {
967
+ this.sendFrameSafe(encodeScreencastFrame(sessionId, meta, jpeg));
968
+ } catch (error) {
969
+ this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
970
+ }
585
971
  }
586
972
  broadcast(type) {
587
- for (const sessionId of this.viewers) this.sendFrame(encodeFrame(type, sessionId));
973
+ for (const sessionId of this.viewers) this.sendFrameSafe(encodeFrame(type, sessionId));
588
974
  }
589
975
  broadcastState() {
590
976
  for (const sessionId of this.viewers) this.sendState(sessionId);
591
977
  }
592
978
  sendState(sessionId) {
593
979
  this.currentPageInfo().then(({ url, title }) => {
980
+ if (!this.viewers.has(sessionId) || this.closed) return;
594
981
  const state = {
595
982
  surface: "browser",
596
983
  url,
597
984
  title,
598
985
  controller: this.turn.currentOwner
599
986
  };
600
- this.sendFrame(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
987
+ try {
988
+ this.sendFrameSafe(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
989
+ } catch (error) {
990
+ this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
991
+ }
601
992
  });
602
993
  }
603
994
  async currentPageInfo() {
604
995
  try {
605
996
  const page = await this.session.getActivePage();
606
997
  return {
607
- url: page.url(),
608
- title: await page.title()
998
+ url: page.url().slice(0, 8192),
999
+ title: (await page.title()).slice(0, 4096)
609
1000
  };
610
1001
  } catch {
611
1002
  return {
@@ -614,6 +1005,13 @@ var BrowserSurface = class {
614
1005
  };
615
1006
  }
616
1007
  }
1008
+ sendFrameSafe(frame) {
1009
+ try {
1010
+ this.sendFrame(frame);
1011
+ } catch (error) {
1012
+ this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
1013
+ }
1014
+ }
617
1015
  };
618
1016
  //#endregion
619
1017
  export { BrowserAutomation, BrowserSession, BrowserSurface };