@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.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,70 @@ 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);
93
- });
94
- }).catch(() => {});
95
- });
96
- this.browser.on("disconnected", () => {
97
- this.browser = null;
98
- this.activePage = null;
99
- });
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
+ } catch (error) {
246
+ await browser.close().catch(() => void 0);
247
+ throw error;
248
+ }
100
249
  }
101
250
  /**
102
251
  * Adopt `page` as the streamed active page iff it has a real navigable
@@ -106,7 +255,7 @@ var BrowserSession = class {
106
255
  adoptIfNavigable(page) {
107
256
  if (page.isClosed() || !isNavigablePageUrl(page.url())) return false;
108
257
  if (this.activePage === page) return true;
109
- this.activePage = page;
258
+ this.setActivePage(page);
110
259
  this.log.debug("Active page switched to new target");
111
260
  return true;
112
261
  }
@@ -115,9 +264,19 @@ var BrowserSession = class {
115
264
  await this.ensureLaunched();
116
265
  if (!this.activePage || this.activePage.isClosed()) {
117
266
  if (!this.browser) throw new Error("Browser not available");
118
- this.activePage = (await this.browser.pages()).find((p) => !p.isClosed()) ?? await this.browser.newPage();
267
+ const page = (await this.browser.pages()).find((candidate) => !candidate.isClosed()) ?? await this.browser.newPage();
268
+ await this.preparePage(page);
269
+ this.setActivePage(page);
119
270
  }
120
- return this.activePage;
271
+ const activePage = this.activePage;
272
+ if (activePage === null) throw new Error("Browser page not available");
273
+ return activePage;
274
+ }
275
+ onActivePageChange(listener) {
276
+ this.activePageListeners.add(listener);
277
+ return () => {
278
+ this.activePageListeners.delete(listener);
279
+ };
121
280
  }
122
281
  /** Prevent idle shutdown while a viewer or op is active. */
123
282
  addHold() {
@@ -152,6 +311,8 @@ var BrowserSession = class {
152
311
  }
153
312
  async shutdown() {
154
313
  this.clearIdleTimer();
314
+ this.generation += 1;
315
+ this.launching = null;
155
316
  const browser = this.browser;
156
317
  this.browser = null;
157
318
  this.activePage = null;
@@ -159,6 +320,40 @@ var BrowserSession = class {
159
320
  await browser.close();
160
321
  } catch {}
161
322
  }
323
+ setActivePage(page) {
324
+ if (this.activePage === page) return;
325
+ this.activePage = page;
326
+ for (const listener of this.activePageListeners) try {
327
+ Promise.resolve(listener(page)).catch((error) => {
328
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
329
+ });
330
+ } catch (error) {
331
+ this.log.warn(`Active-page listener failed: ${error instanceof Error ? error.message : String(error)}`);
332
+ }
333
+ }
334
+ async preparePage(page) {
335
+ if (this.preparedPages.has(page)) return;
336
+ this.preparedPages.add(page);
337
+ const policy = this.options.isNavigationAllowed;
338
+ if (policy === void 0) return;
339
+ try {
340
+ await page.setRequestInterception(true);
341
+ page.on("request", (request) => {
342
+ if (request.isInterceptResolutionHandled()) return;
343
+ let allowed = false;
344
+ try {
345
+ allowed = policy(request.url());
346
+ } catch {}
347
+ (allowed ? request.continue() : request.abort("blockedbyclient")).catch(() => void 0);
348
+ });
349
+ } catch (error) {
350
+ this.preparedPages.delete(page);
351
+ throw error;
352
+ }
353
+ }
354
+ isLaunchCurrent(generation, browser) {
355
+ return generation === this.generation && browser.connected;
356
+ }
162
357
  };
163
358
  //#endregion
164
359
  //#region src/screencast-pump.ts
@@ -173,6 +368,12 @@ var ScreencastPump = class {
173
368
  running = false;
174
369
  frameSeq = 0;
175
370
  outstanding = null;
371
+ generation = 0;
372
+ viewport = {
373
+ width: 1280,
374
+ height: 720,
375
+ dpr: 1
376
+ };
176
377
  log;
177
378
  quality;
178
379
  ackTimeoutMs;
@@ -181,6 +382,8 @@ var ScreencastPump = class {
181
382
  this.log = options.logger ?? noopLogger$1;
182
383
  this.quality = options.quality ?? 70;
183
384
  this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
385
+ if (!Number.isInteger(this.quality) || this.quality < 1 || this.quality > 100) throw new Error("Screencast quality must be an integer from 1 to 100");
386
+ 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
387
  }
185
388
  get isRunning() {
186
389
  return this.running;
@@ -188,33 +391,60 @@ var ScreencastPump = class {
188
391
  /** Start (or restart) the screencast on the given page. */
189
392
  async start(page, viewport) {
190
393
  await this.stop();
394
+ const generation = ++this.generation;
395
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
191
396
  const cdp = await page.createCDPSession();
397
+ if (generation !== this.generation) {
398
+ await cdp.detach().catch(() => void 0);
399
+ return;
400
+ }
192
401
  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");
402
+ try {
403
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
404
+ width: this.viewport.width,
405
+ height: this.viewport.height,
406
+ deviceScaleFactor: this.viewport.dpr,
407
+ mobile: false
408
+ });
409
+ if (generation !== this.generation || this.cdp !== cdp) return;
410
+ cdp.on("Page.screencastFrame", (event) => {
411
+ if (generation !== this.generation || this.cdp !== cdp) return;
412
+ this.onCdpFrame(cdp, generation, event.data, event.sessionId, event.metadata);
413
+ });
414
+ await cdp.send("Page.startScreencast", {
415
+ format: "jpeg",
416
+ quality: this.quality,
417
+ maxWidth: Math.round(this.viewport.width * this.viewport.dpr),
418
+ maxHeight: Math.round(this.viewport.height * this.viewport.dpr),
419
+ everyNthFrame: 1
420
+ });
421
+ if (generation !== this.generation || this.cdp !== cdp) return;
422
+ this.running = true;
423
+ this.log.debug("Screencast started");
424
+ } catch (error) {
425
+ if (this.cdp === cdp) this.cdp = null;
426
+ await cdp.detach().catch(() => void 0);
427
+ throw error;
428
+ }
211
429
  }
212
- onCdpFrame(dataBase64, cdpSessionId, metadata) {
430
+ onCdpFrame(cdp, generation, dataBase64, cdpSessionId, metadata) {
213
431
  if (this.outstanding) this.ackCdp(this.outstanding);
214
- const frameSeq = ++this.frameSeq;
432
+ if (dataBase64.length > Math.ceil(9437184 * 4 / 3) + 4) {
433
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
434
+ this.log.warn("Dropped oversized browser screencast frame");
435
+ return;
436
+ }
437
+ const jpeg = Buffer.from(dataBase64, "base64");
438
+ if (jpeg.length < 3 || jpeg.length > 9437184 || jpeg[0] !== 255 || jpeg[1] !== 216 || jpeg[2] !== 255) {
439
+ cdp.send("Page.screencastFrameAck", { sessionId: cdpSessionId }).catch(() => void 0);
440
+ this.log.warn("Dropped invalid browser screencast frame");
441
+ return;
442
+ }
443
+ this.frameSeq = this.frameSeq >= 4294967295 ? 1 : this.frameSeq + 1;
444
+ const frameSeq = this.frameSeq;
215
445
  const meta = {
216
- deviceWidth: metadata.deviceWidth ?? 0,
217
- deviceHeight: metadata.deviceHeight ?? 0,
446
+ deviceWidth: metadata.deviceWidth ?? this.viewport.width * this.viewport.dpr,
447
+ deviceHeight: metadata.deviceHeight ?? this.viewport.height * this.viewport.dpr,
218
448
  frameSeq,
219
449
  offsetTop: metadata.offsetTop,
220
450
  pageScaleFactor: metadata.pageScaleFactor,
@@ -226,11 +456,19 @@ var ScreencastPump = class {
226
456
  }, this.ackTimeoutMs);
227
457
  timer.unref();
228
458
  this.outstanding = {
459
+ cdp,
460
+ generation,
229
461
  cdpSessionId,
230
462
  frameSeq,
231
463
  timer
232
464
  };
233
- this.options.onFrame(Buffer.from(dataBase64, "base64"), meta);
465
+ try {
466
+ this.options.onFrame(jpeg, meta);
467
+ } catch (error) {
468
+ this.log.warn(`Screencast consumer failed: ${error instanceof Error ? error.message : String(error)}`);
469
+ const outstanding = this.outstanding;
470
+ if (outstanding.frameSeq === frameSeq) this.ackCdp(outstanding);
471
+ }
234
472
  }
235
473
  /** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
236
474
  ackFromViewer(frameSeq) {
@@ -239,9 +477,10 @@ var ScreencastPump = class {
239
477
  ackCdp(outstanding) {
240
478
  clearTimeout(outstanding.timer);
241
479
  this.outstanding = null;
242
- if (this.cdp && this.running) this.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
480
+ if (this.cdp === outstanding.cdp && this.running && this.generation === outstanding.generation) outstanding.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
243
481
  }
244
482
  async stop() {
483
+ this.generation += 1;
245
484
  this.running = false;
246
485
  if (this.outstanding) {
247
486
  clearTimeout(this.outstanding.timer);
@@ -269,14 +508,24 @@ const MOUSE_TYPE = {
269
508
  var InputInjector = class {
270
509
  cdp = null;
271
510
  viewport;
511
+ generation = 0;
272
512
  constructor(viewport) {
273
- this.viewport = viewport;
513
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
274
514
  }
275
515
  async attach(page) {
276
- await this.detach();
277
- this.cdp = await page.createCDPSession();
516
+ const generation = ++this.generation;
517
+ const old = this.cdp;
518
+ this.cdp = null;
519
+ if (old) await old.detach().catch(() => void 0);
520
+ const cdp = await page.createCDPSession();
521
+ if (generation !== this.generation) {
522
+ await cdp.detach().catch(() => void 0);
523
+ return;
524
+ }
525
+ this.cdp = cdp;
278
526
  }
279
527
  async detach() {
528
+ this.generation += 1;
280
529
  const cdp = this.cdp;
281
530
  this.cdp = null;
282
531
  if (cdp) try {
@@ -284,12 +533,12 @@ var InputInjector = class {
284
533
  } catch {}
285
534
  }
286
535
  updateViewport(viewport) {
287
- this.viewport = viewport;
536
+ this.viewport = normalizeViewport(viewport.width, viewport.height, viewport.dpr);
288
537
  }
289
538
  toCssPx(nx, ny) {
290
539
  return {
291
- x: Math.round(clamp01(nx) * this.viewport.width),
292
- y: Math.round(clamp01(ny) * this.viewport.height)
540
+ x: Math.round(clamp01(nx) * Math.max(0, this.viewport.width - 1)),
541
+ y: Math.round(clamp01(ny) * Math.max(0, this.viewport.height - 1))
293
542
  };
294
543
  }
295
544
  mouse(p) {
@@ -342,64 +591,103 @@ function clamp01(n) {
342
591
  }
343
592
  //#endregion
344
593
  //#region src/automation.ts
594
+ const OPERATION_TIMEOUT_MS = 3e4;
345
595
  var BrowserAutomation = class {
596
+ operationTail = Promise.resolve();
346
597
  constructor(session, turn, isNavigationAllowed) {
347
598
  this.session = session;
348
599
  this.turn = turn;
349
600
  this.isNavigationAllowed = isNavigationAllowed;
350
601
  }
351
602
  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
- };
603
+ return this.run(async () => {
604
+ const target = validateNavigationUrl(url);
605
+ let allowed = false;
606
+ try {
607
+ allowed = this.isNavigationAllowed(target);
608
+ } catch {}
609
+ if (!allowed) throw new Error("Navigation blocked by browser policy");
610
+ const page = await this.session.getActivePage();
611
+ await page.goto(target, {
612
+ waitUntil: "domcontentloaded",
613
+ timeout: OPERATION_TIMEOUT_MS
614
+ });
615
+ return {
616
+ url: page.url(),
617
+ title: await page.title()
618
+ };
619
+ });
360
620
  }
361
621
  async click(selector) {
362
- await this.turn.acquireAgent();
363
- await (await this.session.getActivePage()).click(selector);
622
+ await this.run(async () => {
623
+ await (await this.session.getActivePage()).click(validateSelector(selector));
624
+ });
364
625
  }
365
626
  async type(selector, text) {
366
- await this.turn.acquireAgent();
367
- await (await this.session.getActivePage()).type(selector, text);
627
+ await this.run(async () => {
628
+ await (await this.session.getActivePage()).type(validateSelector(selector), validateTypeText(text));
629
+ });
368
630
  }
369
631
  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));
632
+ await this.run(async () => {
633
+ const normalized = validateWaitOptions(opts);
634
+ const page = await this.session.getActivePage();
635
+ if (normalized.selector !== void 0) await page.waitForSelector(normalized.selector, { timeout: OPERATION_TIMEOUT_MS });
636
+ else if (normalized.urlPattern !== void 0) await page.waitForFunction((pattern) => window.location.href.includes(pattern), { timeout: OPERATION_TIMEOUT_MS }, normalized.urlPattern);
637
+ else await new Promise((resolve) => {
638
+ setTimeout(resolve, normalized.ms ?? 0).unref();
639
+ });
640
+ });
377
641
  }
378
642
  /** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
379
643
  * from the continuous screencast stream to viewers. */
380
644
  async screenshot() {
381
- await this.turn.acquireAgent();
382
- return (await this.session.getActivePage()).screenshot({
383
- type: "jpeg",
384
- quality: 70,
385
- encoding: "base64"
645
+ return this.run(async () => {
646
+ const image = await (await this.session.getActivePage()).screenshot({
647
+ type: "jpeg",
648
+ quality: 70,
649
+ encoding: "base64"
650
+ });
651
+ if (image.length > 14680064) throw new Error("Browser screenshot exceeds the byte limit");
652
+ return image;
386
653
  });
387
654
  }
388
655
  /** Evaluate an expression in the page context via CDP (no eval on our side). */
389
656
  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 {
657
+ return this.run(async () => {
658
+ const cdp = await (await this.session.getActivePage()).createCDPSession();
399
659
  try {
400
- await cdp.detach();
401
- } catch {}
402
- }
660
+ const res = await cdp.send("Runtime.evaluate", {
661
+ expression: validateExpression(expression),
662
+ returnByValue: true,
663
+ awaitPromise: true,
664
+ timeout: OPERATION_TIMEOUT_MS,
665
+ disableBreaks: true
666
+ });
667
+ if (res.exceptionDetails !== void 0) throw new Error("Browser evaluation failed");
668
+ return assertBoundedAutomationResult(res.result.value ?? null);
669
+ } finally {
670
+ try {
671
+ await cdp.detach();
672
+ } catch {}
673
+ }
674
+ });
675
+ }
676
+ /** Wait until all agent operations that were already queued have settled. */
677
+ async waitUntilIdle() {
678
+ await this.operationTail;
679
+ }
680
+ run(operation) {
681
+ const result = this.operationTail.catch(() => void 0).then(async () => {
682
+ await this.turn.acquireAgent();
683
+ try {
684
+ return await operation();
685
+ } finally {
686
+ this.session.touch();
687
+ }
688
+ });
689
+ this.operationTail = result.then(() => void 0, () => void 0);
690
+ return result;
403
691
  }
404
692
  };
405
693
  //#endregion
@@ -434,12 +722,29 @@ var BrowserSurface = class {
434
722
  dpr: 1
435
723
  };
436
724
  handoff = null;
725
+ controllerSessionId = null;
726
+ pendingControllerSessionId = null;
727
+ streamGeneration = 0;
728
+ streamQueue = Promise.resolve();
729
+ closed = false;
730
+ removePageListener;
437
731
  log;
438
732
  constructor(options, sendFrame) {
439
733
  this.options = options;
440
734
  this.sendFrame = sendFrame;
441
735
  this.log = options.logger ?? noopLogger;
442
- this.session = new BrowserSession(options);
736
+ const navigationPolicy = options.isNavigationAllowed ?? ((url) => {
737
+ try {
738
+ validateNavigationUrl(url);
739
+ return true;
740
+ } catch {
741
+ return false;
742
+ }
743
+ });
744
+ this.session = new BrowserSession({
745
+ ...options,
746
+ isNavigationAllowed: navigationPolicy
747
+ });
443
748
  this.turn = new _alfe_ai_remote.TurnController({ onOwnerChange: () => {
444
749
  this.broadcastState();
445
750
  } });
@@ -450,56 +755,87 @@ var BrowserSurface = class {
450
755
  logger: this.log
451
756
  });
452
757
  this.injector = new InputInjector(this.viewport);
453
- this.automation = new BrowserAutomation(this.session, this.turn, options.isNavigationAllowed ?? (() => true));
758
+ this.automation = new BrowserAutomation(this.session, this.turn, navigationPolicy);
759
+ this.removePageListener = this.session.onActivePageChange(() => {
760
+ if (this.viewers.size > 0) this.restartStreaming();
761
+ });
454
762
  }
455
763
  async openSession(sessionId, open) {
764
+ if (this.closed) throw new Error("Browser surface is shut down");
765
+ if (this.viewers.has(sessionId)) {
766
+ this.sendState(sessionId);
767
+ return;
768
+ }
769
+ const firstViewer = this.viewers.size === 0;
456
770
  this.viewers.add(sessionId);
457
771
  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);
772
+ try {
773
+ if (open.width !== void 0 && open.height !== void 0) this.viewport = normalizeViewport(open.width, open.height, open.dpr ?? 1);
774
+ if (firstViewer) await this.restartStreaming();
775
+ this.sendState(sessionId);
776
+ } catch (error) {
777
+ this.viewers.delete(sessionId);
778
+ this.session.removeHold();
779
+ if (this.viewers.size === 0) {
780
+ this.streamGeneration += 1;
781
+ this.enqueueStreamCleanup();
782
+ }
783
+ throw error;
784
+ }
465
785
  }
466
786
  handleFrame(frame) {
787
+ if (this.closed || !this.viewers.has(frame.sessionId)) return;
467
788
  switch (frame.type) {
468
789
  case _alfe_ai_remote.RemoteFrameType.SCREENCAST_ACK: {
469
- const ack = (0, _alfe_ai_remote.decodeJson)(frame.payload);
790
+ const ack = (0, _alfe_ai_remote.decodeScreencastAckPayload)(frame.payload);
470
791
  if (ack) this.pump.ackFromViewer(ack.frameSeq);
471
792
  this.session.touch();
472
793
  break;
473
794
  }
474
795
  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);
796
+ const resize = (0, _alfe_ai_remote.decodeResizePayload)(frame.payload);
797
+ if (resize && frame.sessionId === this.controllerSessionId) this.applyResize(resize.width, resize.height, resize.dpr ?? 1);
477
798
  break;
478
799
  }
479
800
  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);
801
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
802
+ const input = (0, _alfe_ai_remote.decodeMouseInputPayload)(frame.payload);
803
+ if (input) this.injector.mouse(input);
483
804
  }
484
805
  break;
485
806
  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);
807
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
808
+ const input = (0, _alfe_ai_remote.decodeWheelInputPayload)(frame.payload);
809
+ if (input) this.injector.wheel(input);
489
810
  }
490
811
  break;
491
812
  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);
813
+ if (frame.sessionId === this.controllerSessionId && this.turn.humanInControl) {
814
+ const input = (0, _alfe_ai_remote.decodeKeyInputPayload)(frame.payload);
815
+ if (input) this.injector.key(input);
495
816
  }
496
817
  break;
497
818
  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));
819
+ if (this.controllerSessionId !== null || this.pendingControllerSessionId !== null) {
820
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
821
+ break;
822
+ }
823
+ this.pendingControllerSessionId = frame.sessionId;
824
+ this.automation.waitUntilIdle().then(() => {
825
+ if (this.pendingControllerSessionId !== frame.sessionId || !this.viewers.has(frame.sessionId) || this.closed) return;
826
+ this.pendingControllerSessionId = null;
827
+ if (this.turn.grantHuman()) {
828
+ this.controllerSessionId = frame.sessionId;
829
+ this.broadcast(_alfe_ai_remote.RemoteFrameType.TAKEOVER_GRANTED);
830
+ } else this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
831
+ }).catch((error) => {
832
+ if (this.pendingControllerSessionId === frame.sessionId) this.pendingControllerSessionId = null;
833
+ this.log.warn(`Could not grant browser control: ${error instanceof Error ? error.message : String(error)}`);
834
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(_alfe_ai_remote.RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
835
+ });
500
836
  break;
501
837
  case _alfe_ai_remote.RemoteFrameType.RELEASE_CONTROL:
502
- this.releaseToAgent();
838
+ if (frame.sessionId === this.controllerSessionId) this.releaseToAgent();
503
839
  break;
504
840
  default: break;
505
841
  }
@@ -507,9 +843,11 @@ var BrowserSurface = class {
507
843
  closeSession(sessionId) {
508
844
  if (!this.viewers.delete(sessionId)) return;
509
845
  this.session.removeHold();
846
+ if (this.pendingControllerSessionId === sessionId) this.pendingControllerSessionId = null;
847
+ if (this.controllerSessionId === sessionId) this.releaseToAgent();
510
848
  if (this.viewers.size === 0) {
511
- this.pump.stop();
512
- if (this.turn.humanInControl) this.releaseToAgent();
849
+ this.streamGeneration += 1;
850
+ this.enqueueStreamCleanup();
513
851
  }
514
852
  }
515
853
  /**
@@ -528,12 +866,15 @@ var BrowserSurface = class {
528
866
  * the tab closed).
529
867
  */
530
868
  async requestHandoff(timeoutMs) {
869
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1e3 || timeoutMs > 1800 * 1e3) throw new Error("Browser handoff timeout must be an integer from 1000 to 1800000ms");
531
870
  if (this.handoff) this.releaseToAgent();
532
871
  return new Promise((resolve) => {
533
872
  const timer = setTimeout(() => {
534
873
  this.handoff = null;
874
+ this.pendingControllerSessionId = null;
535
875
  if (this.turn.humanInControl) {
536
876
  this.turn.releaseHuman();
877
+ this.controllerSessionId = null;
537
878
  this.broadcast(_alfe_ai_remote.RemoteFrameType.CONTROL_REVOKED);
538
879
  }
539
880
  this.currentPageInfo().then(({ url, title }) => {
@@ -565,11 +906,30 @@ var BrowserSurface = class {
565
906
  this.session.removeHold();
566
907
  }
567
908
  async shutdown() {
568
- await this.pump.stop();
569
- await this.injector.detach();
909
+ if (this.closed) return;
910
+ this.closed = true;
911
+ this.removePageListener();
912
+ this.streamGeneration += 1;
913
+ this.pendingControllerSessionId = null;
914
+ this.controllerSessionId = null;
915
+ const waiter = this.handoff;
916
+ this.handoff = null;
917
+ if (waiter) {
918
+ clearTimeout(waiter.timer);
919
+ waiter.resolve({
920
+ released: false,
921
+ timedOut: true,
922
+ url: "",
923
+ title: ""
924
+ });
925
+ }
926
+ this.turn.releaseHuman();
927
+ await this.enqueueStreamCleanup();
570
928
  await this.session.shutdown();
571
929
  }
572
930
  releaseToAgent() {
931
+ this.pendingControllerSessionId = null;
932
+ this.controllerSessionId = null;
573
933
  this.turn.releaseHuman();
574
934
  const waiter = this.handoff;
575
935
  this.handoff = null;
@@ -585,51 +945,82 @@ var BrowserSurface = class {
585
945
  });
586
946
  }
587
947
  }
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);
948
+ restartStreaming() {
949
+ const generation = ++this.streamGeneration;
950
+ const run = this.streamQueue.catch(() => void 0).then(async () => {
951
+ if (!this.shouldStream(generation)) return;
952
+ try {
953
+ const page = await this.session.getActivePage();
954
+ if (!this.shouldStream(generation)) return;
955
+ await this.injector.attach(page);
956
+ this.injector.updateViewport(this.viewport);
957
+ await this.pump.start(page, this.viewport);
958
+ if (!this.shouldStream(generation)) {
959
+ await this.pump.stop();
960
+ await this.injector.detach();
961
+ }
962
+ } catch (error) {
963
+ await this.pump.stop();
964
+ await this.injector.detach();
965
+ throw error;
966
+ }
967
+ });
968
+ this.streamQueue = run.catch((error) => {
969
+ this.log.warn(`Browser streaming failed: ${error instanceof Error ? error.message : String(error)}`);
970
+ });
971
+ return run;
972
+ }
973
+ enqueueStreamCleanup() {
974
+ const cleanup = this.streamQueue.catch(() => void 0).then(async () => {
975
+ await this.pump.stop();
976
+ await this.injector.detach();
977
+ });
978
+ this.streamQueue = cleanup.catch(() => void 0);
979
+ return cleanup;
980
+ }
981
+ shouldStream(generation) {
982
+ return !this.closed && this.viewers.size > 0 && generation === this.streamGeneration;
594
983
  }
595
984
  async applyResize(width, height, dpr) {
596
- this.viewport = {
597
- width,
598
- height,
599
- dpr
600
- };
985
+ this.viewport = normalizeViewport(width, height, dpr);
601
986
  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
- }
987
+ if (this.viewers.size > 0) await this.restartStreaming();
606
988
  }
607
989
  broadcastFrame(jpeg, meta) {
608
- for (const sessionId of this.viewers) this.sendFrame((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
990
+ for (const sessionId of this.viewers) try {
991
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeScreencastFrame)(sessionId, meta, jpeg));
992
+ } catch (error) {
993
+ this.log.warn(`Browser frame encoding failed: ${error instanceof Error ? error.message : String(error)}`);
994
+ }
609
995
  }
610
996
  broadcast(type) {
611
- for (const sessionId of this.viewers) this.sendFrame((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
997
+ for (const sessionId of this.viewers) this.sendFrameSafe((0, _alfe_ai_remote.encodeFrame)(type, sessionId));
612
998
  }
613
999
  broadcastState() {
614
1000
  for (const sessionId of this.viewers) this.sendState(sessionId);
615
1001
  }
616
1002
  sendState(sessionId) {
617
1003
  this.currentPageInfo().then(({ url, title }) => {
1004
+ if (!this.viewers.has(sessionId) || this.closed) return;
618
1005
  const state = {
619
1006
  surface: "browser",
620
1007
  url,
621
1008
  title,
622
1009
  controller: this.turn.currentOwner
623
1010
  };
624
- this.sendFrame((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
1011
+ try {
1012
+ this.sendFrameSafe((0, _alfe_ai_remote.encodeJsonFrame)(_alfe_ai_remote.RemoteFrameType.SESSION_STATE, sessionId, state));
1013
+ } catch (error) {
1014
+ this.log.warn(`Browser state encoding failed: ${error instanceof Error ? error.message : String(error)}`);
1015
+ }
625
1016
  });
626
1017
  }
627
1018
  async currentPageInfo() {
628
1019
  try {
629
1020
  const page = await this.session.getActivePage();
630
1021
  return {
631
- url: page.url(),
632
- title: await page.title()
1022
+ url: page.url().slice(0, 8192),
1023
+ title: (await page.title()).slice(0, 4096)
633
1024
  };
634
1025
  } catch {
635
1026
  return {
@@ -638,6 +1029,13 @@ var BrowserSurface = class {
638
1029
  };
639
1030
  }
640
1031
  }
1032
+ sendFrameSafe(frame) {
1033
+ try {
1034
+ this.sendFrame(frame);
1035
+ } catch (error) {
1036
+ this.log.warn(`Browser frame send failed: ${error instanceof Error ? error.message : String(error)}`);
1037
+ }
1038
+ }
641
1039
  };
642
1040
  //#endregion
643
1041
  exports.BrowserAutomation = BrowserAutomation;