@alfe.ai/browser 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,574 @@
1
+ import { RemoteFrameType, TurnController, decodeJson, encodeFrame, encodeJsonFrame, encodeScreencastFrame } from "@alfe.ai/remote";
2
+ import puppeteer from "puppeteer-core";
3
+ //#region src/browser-session.ts
4
+ /**
5
+ * BrowserSession — owns the single headless Chrome instance the agent
6
+ * automates and the human takes over. Lazy-launched on first use, idle-shut
7
+ * down when nothing holds it open (cookies/login persist on disk in
8
+ * `userDataDir` across restarts). Tracks the *active* target so OAuth popups /
9
+ * new tabs are what gets screencast, not a stale main window.
10
+ */
11
+ const DEFAULT_IDLE_MS = 300 * 1e3;
12
+ const noopLogger$2 = {
13
+ info: () => {},
14
+ warn: () => {},
15
+ error: () => {},
16
+ debug: () => {}
17
+ };
18
+ var BrowserSession = class {
19
+ browser = null;
20
+ activePage = null;
21
+ launching = null;
22
+ holds = 0;
23
+ idleTimer = null;
24
+ log;
25
+ idleMs;
26
+ constructor(options) {
27
+ this.options = options;
28
+ this.log = options.logger ?? noopLogger$2;
29
+ this.idleMs = options.idleShutdownMs ?? DEFAULT_IDLE_MS;
30
+ }
31
+ /** Launch Chrome if not already running (idempotent, concurrent-safe). */
32
+ async ensureLaunched() {
33
+ if (this.browser) return;
34
+ if (this.launching) return this.launching;
35
+ this.launching = this.doLaunch().finally(() => {
36
+ this.launching = null;
37
+ });
38
+ return this.launching;
39
+ }
40
+ async doLaunch() {
41
+ const args = [
42
+ "--disable-blink-features=AutomationControlled",
43
+ ...this.options.noSandbox ? ["--no-sandbox", "--disable-setuid-sandbox"] : [],
44
+ ...this.options.extraArgs ?? []
45
+ ];
46
+ this.log.info(`Launching Chrome (${this.options.executablePath})`);
47
+ this.browser = await puppeteer.launch({
48
+ executablePath: this.options.executablePath,
49
+ headless: this.options.headless ?? true,
50
+ userDataDir: this.options.userDataDir,
51
+ args
52
+ });
53
+ this.activePage = (await this.browser.pages())[0] ?? await this.browser.newPage();
54
+ this.browser.on("targetcreated", (target) => {
55
+ target.page().then((page) => {
56
+ if (page) {
57
+ this.activePage = page;
58
+ this.log.debug("Active page switched to new target");
59
+ }
60
+ }).catch(() => {});
61
+ });
62
+ this.browser.on("disconnected", () => {
63
+ this.browser = null;
64
+ this.activePage = null;
65
+ });
66
+ }
67
+ /** The current active page, launching Chrome first if needed. */
68
+ async getActivePage() {
69
+ await this.ensureLaunched();
70
+ if (!this.activePage || this.activePage.isClosed()) {
71
+ if (!this.browser) throw new Error("Browser not available");
72
+ this.activePage = (await this.browser.pages()).find((p) => !p.isClosed()) ?? await this.browser.newPage();
73
+ }
74
+ return this.activePage;
75
+ }
76
+ /** Prevent idle shutdown while a viewer or op is active. */
77
+ addHold() {
78
+ this.holds += 1;
79
+ this.clearIdleTimer();
80
+ }
81
+ /** Release a hold; arm idle shutdown when the last one is released. */
82
+ removeHold() {
83
+ this.holds = Math.max(0, this.holds - 1);
84
+ if (this.holds === 0) this.armIdleTimer();
85
+ }
86
+ /** Reset the idle timer on any activity (only relevant when unheld). */
87
+ touch() {
88
+ if (this.holds === 0) this.armIdleTimer();
89
+ }
90
+ armIdleTimer() {
91
+ this.clearIdleTimer();
92
+ const timer = setTimeout(() => {
93
+ if (this.holds === 0) {
94
+ this.log.info("Chrome idle — shutting down (cookies persist on disk)");
95
+ this.shutdown();
96
+ }
97
+ }, this.idleMs);
98
+ timer.unref();
99
+ this.idleTimer = timer;
100
+ }
101
+ clearIdleTimer() {
102
+ if (this.idleTimer) {
103
+ clearTimeout(this.idleTimer);
104
+ this.idleTimer = null;
105
+ }
106
+ }
107
+ async shutdown() {
108
+ this.clearIdleTimer();
109
+ const browser = this.browser;
110
+ this.browser = null;
111
+ this.activePage = null;
112
+ if (browser) try {
113
+ await browser.close();
114
+ } catch {}
115
+ }
116
+ };
117
+ //#endregion
118
+ //#region src/screencast-pump.ts
119
+ const noopLogger$1 = {
120
+ info: () => {},
121
+ warn: () => {},
122
+ error: () => {},
123
+ debug: () => {}
124
+ };
125
+ var ScreencastPump = class {
126
+ cdp = null;
127
+ running = false;
128
+ frameSeq = 0;
129
+ outstanding = null;
130
+ log;
131
+ quality;
132
+ ackTimeoutMs;
133
+ constructor(options) {
134
+ this.options = options;
135
+ this.log = options.logger ?? noopLogger$1;
136
+ this.quality = options.quality ?? 70;
137
+ this.ackTimeoutMs = options.ackTimeoutMs ?? 2e3;
138
+ }
139
+ get isRunning() {
140
+ return this.running;
141
+ }
142
+ /** Start (or restart) the screencast on the given page. */
143
+ async start(page, viewport) {
144
+ await this.stop();
145
+ const cdp = await page.createCDPSession();
146
+ this.cdp = cdp;
147
+ await cdp.send("Emulation.setDeviceMetricsOverride", {
148
+ width: viewport.width,
149
+ height: viewport.height,
150
+ deviceScaleFactor: viewport.dpr,
151
+ mobile: false
152
+ });
153
+ cdp.on("Page.screencastFrame", (event) => {
154
+ this.onCdpFrame(event.data, event.sessionId, event.metadata);
155
+ });
156
+ await cdp.send("Page.startScreencast", {
157
+ format: "jpeg",
158
+ quality: this.quality,
159
+ maxWidth: Math.round(viewport.width * viewport.dpr),
160
+ maxHeight: Math.round(viewport.height * viewport.dpr),
161
+ everyNthFrame: 1
162
+ });
163
+ this.running = true;
164
+ this.log.debug("Screencast started");
165
+ }
166
+ onCdpFrame(dataBase64, cdpSessionId, metadata) {
167
+ if (this.outstanding) this.ackCdp(this.outstanding);
168
+ const frameSeq = ++this.frameSeq;
169
+ const meta = {
170
+ deviceWidth: metadata.deviceWidth ?? 0,
171
+ deviceHeight: metadata.deviceHeight ?? 0,
172
+ frameSeq,
173
+ offsetTop: metadata.offsetTop,
174
+ pageScaleFactor: metadata.pageScaleFactor,
175
+ scrollOffsetX: metadata.scrollOffsetX,
176
+ scrollOffsetY: metadata.scrollOffsetY
177
+ };
178
+ const timer = setTimeout(() => {
179
+ if (this.outstanding?.frameSeq === frameSeq) this.ackCdp(this.outstanding);
180
+ }, this.ackTimeoutMs);
181
+ timer.unref();
182
+ this.outstanding = {
183
+ cdpSessionId,
184
+ frameSeq,
185
+ timer
186
+ };
187
+ this.options.onFrame(Buffer.from(dataBase64, "base64"), meta);
188
+ }
189
+ /** Viewer acked frame `frameSeq` — release Chrome to send the next frame. */
190
+ ackFromViewer(frameSeq) {
191
+ if (this.outstanding?.frameSeq === frameSeq) this.ackCdp(this.outstanding);
192
+ }
193
+ ackCdp(outstanding) {
194
+ clearTimeout(outstanding.timer);
195
+ this.outstanding = null;
196
+ if (this.cdp && this.running) this.cdp.send("Page.screencastFrameAck", { sessionId: outstanding.cdpSessionId }).catch(() => {});
197
+ }
198
+ async stop() {
199
+ this.running = false;
200
+ if (this.outstanding) {
201
+ clearTimeout(this.outstanding.timer);
202
+ this.outstanding = null;
203
+ }
204
+ const cdp = this.cdp;
205
+ this.cdp = null;
206
+ if (cdp) {
207
+ try {
208
+ await cdp.send("Page.stopScreencast");
209
+ } catch {}
210
+ try {
211
+ await cdp.detach();
212
+ } catch {}
213
+ }
214
+ }
215
+ };
216
+ //#endregion
217
+ //#region src/input-injector.ts
218
+ const MOUSE_TYPE = {
219
+ mousemoved: "mouseMoved",
220
+ mousepressed: "mousePressed",
221
+ mousereleased: "mouseReleased"
222
+ };
223
+ var InputInjector = class {
224
+ cdp = null;
225
+ viewport;
226
+ constructor(viewport) {
227
+ this.viewport = viewport;
228
+ }
229
+ async attach(page) {
230
+ await this.detach();
231
+ this.cdp = await page.createCDPSession();
232
+ }
233
+ async detach() {
234
+ const cdp = this.cdp;
235
+ this.cdp = null;
236
+ if (cdp) try {
237
+ await cdp.detach();
238
+ } catch {}
239
+ }
240
+ updateViewport(viewport) {
241
+ this.viewport = viewport;
242
+ }
243
+ toCssPx(nx, ny) {
244
+ return {
245
+ x: Math.round(clamp01(nx) * this.viewport.width),
246
+ y: Math.round(clamp01(ny) * this.viewport.height)
247
+ };
248
+ }
249
+ mouse(p) {
250
+ if (!this.cdp) return;
251
+ const { x, y } = this.toCssPx(p.nx, p.ny);
252
+ this.cdp.send("Input.dispatchMouseEvent", {
253
+ type: MOUSE_TYPE[p.type],
254
+ x,
255
+ y,
256
+ button: p.button ?? "none",
257
+ buttons: p.buttons ?? 0,
258
+ clickCount: p.clickCount ?? (p.type === "mousepressed" ? 1 : 0),
259
+ modifiers: p.modifiers ?? 0
260
+ }).catch(() => {});
261
+ }
262
+ wheel(p) {
263
+ if (!this.cdp) return;
264
+ const { x, y } = this.toCssPx(p.nx, p.ny);
265
+ this.cdp.send("Input.dispatchMouseEvent", {
266
+ type: "mouseWheel",
267
+ x,
268
+ y,
269
+ deltaX: p.deltaX,
270
+ deltaY: p.deltaY,
271
+ modifiers: p.modifiers ?? 0
272
+ }).catch(() => {});
273
+ }
274
+ key(p) {
275
+ if (!this.cdp) return;
276
+ if (p.type === "char") {
277
+ this.cdp.send("Input.dispatchKeyEvent", {
278
+ type: "char",
279
+ text: p.text ?? "",
280
+ modifiers: p.modifiers ?? 0
281
+ }).catch(() => {});
282
+ return;
283
+ }
284
+ this.cdp.send("Input.dispatchKeyEvent", {
285
+ type: p.type === "keydown" ? "keyDown" : "keyUp",
286
+ key: p.key,
287
+ code: p.code,
288
+ text: p.type === "keydown" ? p.text : void 0,
289
+ modifiers: p.modifiers ?? 0
290
+ }).catch(() => {});
291
+ }
292
+ };
293
+ function clamp01(n) {
294
+ if (Number.isNaN(n)) return 0;
295
+ return Math.max(0, Math.min(1, n));
296
+ }
297
+ //#endregion
298
+ //#region src/automation.ts
299
+ var BrowserAutomation = class {
300
+ constructor(session, turn, isNavigationAllowed) {
301
+ this.session = session;
302
+ this.turn = turn;
303
+ this.isNavigationAllowed = isNavigationAllowed;
304
+ }
305
+ async navigate(url) {
306
+ await this.turn.acquireAgent();
307
+ if (!this.isNavigationAllowed(url)) throw new Error(`Navigation to ${url} blocked by SSRF policy`);
308
+ const page = await this.session.getActivePage();
309
+ await page.goto(url, { waitUntil: "domcontentloaded" });
310
+ return {
311
+ url: page.url(),
312
+ title: await page.title()
313
+ };
314
+ }
315
+ async click(selector) {
316
+ await this.turn.acquireAgent();
317
+ await (await this.session.getActivePage()).click(selector);
318
+ }
319
+ async type(selector, text) {
320
+ await this.turn.acquireAgent();
321
+ await (await this.session.getActivePage()).type(selector, text);
322
+ }
323
+ async waitFor(opts) {
324
+ await this.turn.acquireAgent();
325
+ const page = await this.session.getActivePage();
326
+ if (opts.selector) await page.waitForSelector(opts.selector);
327
+ else if (opts.urlPattern) {
328
+ const pattern = opts.urlPattern;
329
+ await page.waitForFunction((p) => window.location.href.includes(p), {}, pattern);
330
+ } else if (typeof opts.ms === "number") await new Promise((resolve) => setTimeout(resolve, opts.ms));
331
+ }
332
+ /** One-shot JPEG screenshot (base64) for the agent's own reasoning — distinct
333
+ * from the continuous screencast stream to viewers. */
334
+ async screenshot() {
335
+ await this.turn.acquireAgent();
336
+ return (await this.session.getActivePage()).screenshot({
337
+ type: "jpeg",
338
+ quality: 70,
339
+ encoding: "base64"
340
+ });
341
+ }
342
+ /** Evaluate an expression in the page context via CDP (no eval on our side). */
343
+ async evaluate(expression) {
344
+ await this.turn.acquireAgent();
345
+ const cdp = await (await this.session.getActivePage()).createCDPSession();
346
+ try {
347
+ return (await cdp.send("Runtime.evaluate", {
348
+ expression,
349
+ returnByValue: true,
350
+ awaitPromise: true
351
+ })).result.value;
352
+ } finally {
353
+ try {
354
+ await cdp.detach();
355
+ } catch {}
356
+ }
357
+ }
358
+ };
359
+ //#endregion
360
+ //#region src/browser-surface.ts
361
+ /**
362
+ * BrowserSurface — the `SurfaceHandler` for browser co-browsing. Ties together
363
+ * the shared Chrome (BrowserSession), the screencast pump, the input injector,
364
+ * the agent/human turn-mutex, and the agent automation tools. Viewers watch the
365
+ * live page; the turn token gates who can write.
366
+ *
367
+ * Multiple viewers can attach to one browser; screencast frames broadcast to
368
+ * all, and the pump's ack-gating advances on the first viewer ack (a slow
369
+ * second viewer may drop frames — acceptable for v1).
370
+ */
371
+ const noopLogger = {
372
+ info: () => {},
373
+ warn: () => {},
374
+ error: () => {},
375
+ debug: () => {}
376
+ };
377
+ var BrowserSurface = class {
378
+ surface = "browser";
379
+ session;
380
+ turn;
381
+ pump;
382
+ injector;
383
+ automation;
384
+ viewers = /* @__PURE__ */ new Set();
385
+ viewport = {
386
+ width: 1280,
387
+ height: 720,
388
+ dpr: 1
389
+ };
390
+ handoff = null;
391
+ log;
392
+ constructor(options, sendFrame) {
393
+ this.options = options;
394
+ this.sendFrame = sendFrame;
395
+ this.log = options.logger ?? noopLogger;
396
+ this.session = new BrowserSession(options);
397
+ this.turn = new TurnController({ onOwnerChange: () => {
398
+ this.broadcastState();
399
+ } });
400
+ this.pump = new ScreencastPump({
401
+ onFrame: (jpeg, meta) => {
402
+ this.broadcastFrame(jpeg, meta);
403
+ },
404
+ logger: this.log
405
+ });
406
+ this.injector = new InputInjector(this.viewport);
407
+ this.automation = new BrowserAutomation(this.session, this.turn, options.isNavigationAllowed ?? (() => true));
408
+ }
409
+ async openSession(sessionId, open) {
410
+ this.viewers.add(sessionId);
411
+ this.session.addHold();
412
+ if (open.width && open.height) this.viewport = {
413
+ width: open.width,
414
+ height: open.height,
415
+ dpr: open.dpr ?? 1
416
+ };
417
+ await this.ensureStreaming();
418
+ this.sendState(sessionId);
419
+ }
420
+ handleFrame(frame) {
421
+ switch (frame.type) {
422
+ case RemoteFrameType.SCREENCAST_ACK: {
423
+ const ack = decodeJson(frame.payload);
424
+ if (ack) this.pump.ackFromViewer(ack.frameSeq);
425
+ this.session.touch();
426
+ break;
427
+ }
428
+ case RemoteFrameType.RESIZE: {
429
+ const r = decodeJson(frame.payload);
430
+ if (r) this.applyResize(r.width, r.height, r.dpr ?? 1);
431
+ break;
432
+ }
433
+ case RemoteFrameType.INPUT_MOUSE:
434
+ if (this.turn.humanInControl) {
435
+ const p = decodeJson(frame.payload);
436
+ if (p) this.injector.mouse(p);
437
+ }
438
+ break;
439
+ case RemoteFrameType.INPUT_WHEEL:
440
+ if (this.turn.humanInControl) {
441
+ const p = decodeJson(frame.payload);
442
+ if (p) this.injector.wheel(p);
443
+ }
444
+ break;
445
+ case RemoteFrameType.INPUT_KEY:
446
+ if (this.turn.humanInControl) {
447
+ const p = decodeJson(frame.payload);
448
+ if (p) this.injector.key(p);
449
+ }
450
+ break;
451
+ case RemoteFrameType.TAKEOVER_REQUEST:
452
+ if (this.turn.grantHuman()) this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
453
+ else this.sendFrame(encodeFrame(RemoteFrameType.TAKEOVER_DENIED, frame.sessionId));
454
+ break;
455
+ case RemoteFrameType.RELEASE_CONTROL:
456
+ this.releaseToAgent();
457
+ break;
458
+ default: break;
459
+ }
460
+ }
461
+ closeSession(sessionId) {
462
+ if (!this.viewers.delete(sessionId)) return;
463
+ this.session.removeHold();
464
+ if (this.viewers.size === 0) {
465
+ this.pump.stop();
466
+ if (this.turn.humanInControl) this.releaseToAgent();
467
+ }
468
+ }
469
+ /**
470
+ * Agent tool entry point: hand control to the human and block until they
471
+ * release it (RELEASE_CONTROL) or `timeoutMs` elapses. Resolves with the
472
+ * final page URL/title so the agent resumes on the same live page.
473
+ */
474
+ async requestHandoff(timeoutMs) {
475
+ this.turn.grantHuman();
476
+ this.broadcast(RemoteFrameType.TAKEOVER_GRANTED);
477
+ return new Promise((resolve) => {
478
+ const timer = setTimeout(() => {
479
+ this.handoff = null;
480
+ this.turn.releaseHuman();
481
+ this.broadcast(RemoteFrameType.CONTROL_REVOKED);
482
+ this.currentPageInfo().then(({ url, title }) => {
483
+ resolve({
484
+ released: false,
485
+ timedOut: true,
486
+ url,
487
+ title
488
+ });
489
+ });
490
+ }, timeoutMs);
491
+ timer.unref();
492
+ this.handoff = {
493
+ resolve,
494
+ timer
495
+ };
496
+ });
497
+ }
498
+ async shutdown() {
499
+ await this.pump.stop();
500
+ await this.injector.detach();
501
+ await this.session.shutdown();
502
+ }
503
+ releaseToAgent() {
504
+ this.turn.releaseHuman();
505
+ const waiter = this.handoff;
506
+ this.handoff = null;
507
+ if (waiter) {
508
+ clearTimeout(waiter.timer);
509
+ this.currentPageInfo().then(({ url, title }) => {
510
+ waiter.resolve({
511
+ released: true,
512
+ timedOut: false,
513
+ url,
514
+ title
515
+ });
516
+ });
517
+ }
518
+ }
519
+ async ensureStreaming() {
520
+ if (this.pump.isRunning || this.viewers.size === 0) return;
521
+ const page = await this.session.getActivePage();
522
+ await this.injector.attach(page);
523
+ this.injector.updateViewport(this.viewport);
524
+ await this.pump.start(page, this.viewport);
525
+ }
526
+ async applyResize(width, height, dpr) {
527
+ this.viewport = {
528
+ width,
529
+ height,
530
+ dpr
531
+ };
532
+ this.injector.updateViewport(this.viewport);
533
+ if (this.pump.isRunning) {
534
+ const page = await this.session.getActivePage();
535
+ await this.pump.start(page, this.viewport);
536
+ }
537
+ }
538
+ broadcastFrame(jpeg, meta) {
539
+ for (const sessionId of this.viewers) this.sendFrame(encodeScreencastFrame(sessionId, meta, jpeg));
540
+ }
541
+ broadcast(type) {
542
+ for (const sessionId of this.viewers) this.sendFrame(encodeFrame(type, sessionId));
543
+ }
544
+ broadcastState() {
545
+ for (const sessionId of this.viewers) this.sendState(sessionId);
546
+ }
547
+ sendState(sessionId) {
548
+ this.currentPageInfo().then(({ url, title }) => {
549
+ const state = {
550
+ surface: "browser",
551
+ url,
552
+ title,
553
+ controller: this.turn.currentOwner
554
+ };
555
+ this.sendFrame(encodeJsonFrame(RemoteFrameType.SESSION_STATE, sessionId, state));
556
+ });
557
+ }
558
+ async currentPageInfo() {
559
+ try {
560
+ const page = await this.session.getActivePage();
561
+ return {
562
+ url: page.url(),
563
+ title: await page.title()
564
+ };
565
+ } catch {
566
+ return {
567
+ url: "",
568
+ title: ""
569
+ };
570
+ }
571
+ }
572
+ };
573
+ //#endregion
574
+ export { BrowserAutomation, BrowserSession, BrowserSurface };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@alfe.ai/browser",
3
+ "version": "0.0.0",
4
+ "description": "CDP-driven browser surface for the Alfe interactive remote-control relay — one shared headless Chrome for agent automation + human co-browse takeover",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "require": "./dist/index.cjs",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "dependencies": {
19
+ "puppeteer-core": "^24.0.0",
20
+ "@alfe.ai/remote": "^0.0.0"
21
+ },
22
+ "license": "UNLICENSED",
23
+ "scripts": {
24
+ "build": "tsdown",
25
+ "dev": "tsdown --watch",
26
+ "test": "vitest run --passWithNoTests",
27
+ "typecheck": "tsc --noEmit",
28
+ "lint": "eslint ."
29
+ }
30
+ }